Merge branch 'feat/1.0.8/ui' into feat/ui-1.1.0

This commit is contained in:
haitaoo 2023-04-11 11:56:10 +08:00
commit 0c3efb66ab
59 changed files with 772 additions and 635 deletions

View File

@ -304,6 +304,7 @@ ui:
oauth_callback: Processing
http_404: HTTP Error 404
http_50X: HTTP Error 500
http_403: HTTP Error 403
notifications:
title: Notifications
inbox: Inbox
@ -526,6 +527,7 @@ ui:
tip_answer: >-
Use comments to reply to other users or notify them of changes. If you are
adding new information, edit your post instead of commenting.
tip_vote: It adds something useful to the post
edit_answer:
title: Edit Answer
default_reason: Edit answer
@ -802,6 +804,11 @@ ui:
answered: answered
closed_in: Closed in
show_exist: Show existing question.
useful: Useful
question_useful: It is useful and clear
question_un_useful: It is unclear or not useful
answer_useful: It is useful
answer_un_useful: It is not useful
answers:
title: Answers
score: Score
@ -819,10 +826,19 @@ ui:
edit link to refine and improve your existing answer, instead.</p>
empty: Answer cannot be empty.
characters: content must be at least 6 characters in length.
tips:
header_1: Thanks for your answer
li1_1: Please be sure to <strong>answer the question</strong>. Provide details and share your research.
li1_2: Back up any statements you make with references or personal experience.
header_2: But <strong>avoid</strong> ...
li2_1: Asking for help, seeking clarification, or responding to other answers.
reopen:
confirm_btn: Reopen
title: Reopen this post
content: Are you sure you want to reopen?
success: This post has been reopened
delete:
title: Delete this post
question: >-
@ -1047,13 +1063,11 @@ ui:
votes: votes
answers: answers
accepted: Accepted
page_404:
http_error: HTTP Error 404
desc: "Unfortunately, this page doesn't exist."
back_home: Back to homepage
page_50X:
http_error: HTTP Error 500
desc: The server encountered an error and could not complete your request.
page_error:
http_error: HTTP Error {{ code }}
desc_403: You dont have permission to access this page.
desc_404: Unfortunately, this page doesn't exist.
desc_50X: The server encountered an error and could not complete your request.
back_home: Back to homepage
page_maintenance:
desc: "We are under maintenance, we'll be back soon."
@ -1467,8 +1481,8 @@ ui:
no_data: "We couldn't find anything."
users:
title: Users
users_with_the_most_reputation: Users with the highest reputation scores
users_with_the_most_vote: Users who voted the most
users_with_the_most_reputation: Users with the highest reputation scores this week
users_with_the_most_vote: Users who voted the most this week
staffs: Our community staff
reputation: reputation
votes: votes

View File

@ -979,13 +979,11 @@ ui:
votes: 个点赞
answers: 个回答
accepted: 已被采纳
page_404:
http_error: HTTP Error 404
desc: "很抱歉,此页面不存在。"
back_home: 回到主页
page_50X:
http_error: HTTP Error 500
desc: 服务器遇到了一个错误,无法完成你的请求。
page_error:
http_error: HTTP Error {{ code }}
desc_403: 你无权访问此页面。
desc_404: 很抱歉,此页面不存在。
desc_50X: 服务器遇到了一个错误,无法完成你的请求。
back_home: 回到主页
page_maintenance:
desc: "我们正在进行维护,我们将很快回来。"
@ -1366,8 +1364,8 @@ ui:
no_data: "空空如也"
users:
title: 用户
users_with_the_most_reputation: 信誉积分最高的用户
users_with_the_most_vote: 投票最多的用户
users_with_the_most_reputation: 本周信誉积分最高的用户
users_with_the_most_vote: 本周投票最多的用户
staffs: 我们的社区工作人员
reputation: 声望值
votes: 投票

View File

@ -977,13 +977,11 @@ ui:
votes: 得票
answers: 回答
accepted: 已採納
page_404:
http_error: HTTP Error 404
desc: "很抱歉,此頁面不存在。"
back_home: 回到首頁
page_50X:
http_error: HTTP Error 500
desc: 伺服器遇到了一個錯誤,無法完成你的請求。
page_error:
http_error: HTTP Error {{ code }}
desc_403: 你无权访问此頁面。
desc_404: 很抱歉,此頁面不存在。
desc_50X: 伺服器遇到了一個錯誤,無法完成你的請求。
back_home: 回到首頁
page_maintenance:
desc: "我們正在維護中,很快就會回來。"

View File

@ -22,9 +22,7 @@
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5",
"diff": "^5.1.0",
"dompurify": "^2.4.3",
"emoji-regex": "^10.2.1",
"html-react-parser": "^3.0.8",
"i18next": "^21.9.0",
"katex": "^0.16.2",
"lodash": "^4.17.21",
@ -84,7 +82,7 @@
"react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1",
"sass": "^1.54.4",
"typescript": "^4.8.3",
"typescript": "^4.9.5",
"yaml-loader": "^0.8.0"
},
"packageManager": "pnpm@7.9.5",

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,9 @@
{
name: 'Safari',
version: '15'
},
{
name: 'IE',
}
];
function getBrowerTypeAndVersion(){
@ -95,6 +98,7 @@
};
var ua = navigator.userAgent.toLowerCase();
var s;
((ua.indexOf("compatible") > -1 && ua.indexOf("MSIE") > -1) || (ua.indexOf('Trident') > -1 && ua.indexOf("rv:11.0") > -1)) ? brower = { name: 'IE', version: '' } :
(s = ua.match(/edge\/([\d\.]+)/)) ? brower = { name: 'Edge', version: s[1] } :
(s = ua.match(/firefox\/([\d\.]+)/)) ? brower = { name: 'Firefox', version: s[1] } :
(s = ua.match(/chrome\/([\d\.]+)/)) ? brower = { name: 'Chrome', version: s[1] } :
@ -126,16 +130,24 @@
}
const browerInfo = getBrowerTypeAndVersion();
const notSupport = defaultList.some(item => {
if (item.name === browerInfo.name) {
return compareVersion(browerInfo.version, item.version) === -1;
}
return false;
});
if (notSupport) {
if (browerInfo.name === 'IE') {
const div = document.getElementById('protect-brower');
div.innerText = 'The current browser version is too low, in order not to affect the normal use of the function, please upgrade the browser to the latest version.'
div.innerText = 'Sorry, this site does not support Internet Explorer. In order to avoid affecting the normal use of our features, please use a more modern browser such as Edge, Firefox, Chrome, or Safari.'
} else {
const notSupport = defaultList.some(item => {
if (item.name === browerInfo.name) {
return compareVersion(browerInfo.version, item.version) === -1;
}
return false;
});
if (notSupport) {
const div = document.getElementById('protect-brower');
div.innerText = 'The current browser version is too low, in order not to affect the normal use of the function, please upgrade the browser to the latest version.'
}
}
</script>
</html>

View File

@ -58,10 +58,13 @@ export interface QuestionParams {
title: string;
url_title?: string;
content: string;
html?: string;
tags: Tag[];
}
export interface QuestionWithAnswer extends QuestionParams {
answer_content: string;
}
export interface ListResult<T = any> {
count: number;
list: T[];

View File

@ -12,6 +12,7 @@ import { bookmark, postVote } from '@/services';
interface Props {
className?: string;
source: 'question' | 'answer';
data: {
id: string;
votesCount: number;
@ -24,7 +25,7 @@ interface Props {
};
}
const Index: FC<Props> = ({ className, data }) => {
const Index: FC<Props> = ({ className, data, source }) => {
const [votes, setVotes] = useState(0);
const [like, setLike] = useState(false);
const [hate, setHated] = useState(false);
@ -102,6 +103,11 @@ const Index: FC<Props> = ({ className, data }) => {
<div className={classNames(className)}>
<ButtonGroup>
<Button
title={
source === 'question'
? t('question_detail.question_useful')
: t('question_detail.answer_useful')
}
variant="outline-secondary"
active={like}
onClick={() => handleVote('up')}>
@ -111,6 +117,11 @@ const Index: FC<Props> = ({ className, data }) => {
{votes}
</Button>
<Button
title={
source === 'question'
? t('question_detail.question_un_useful')
: t('question_detail.answer_un_useful')
}
variant="outline-secondary"
active={hate}
onClick={() => handleVote('down')}>

View File

@ -32,6 +32,7 @@ const ActionBar = ({
<span className="mx-1"></span>
<FormatTime time={createdAt} className="me-3" />
<Button
title={t('tip_vote')}
variant="link"
size="sm"
className={`me-3 btn-no-border p-0 ${isVote ? '' : 'link-secondary'}`}

View File

@ -32,7 +32,7 @@ const ActivateScriptNodes = (el, part) => {
scriptList.push(node);
}
}
scriptList.forEach((so) => {
scriptList?.forEach((so) => {
const script = document.createElement('script');
script.text = so.text;
for (let i = 0; i < so.attributes.length; i += 1) {

View File

@ -8,7 +8,6 @@ import {
} from 'react';
import { markdownToHtml } from '@/services';
import { htmlToReact } from '@/utils';
import { htmlRender } from './utils';
@ -51,9 +50,9 @@ const Index = ({ value }, ref) => {
return (
<div
ref={previewRef}
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt">
{htmlToReact(html)}
</div>
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};

View File

@ -80,6 +80,15 @@ export function createEditorUtils(
export function htmlRender(el: HTMLElement | null) {
if (!el) return;
// Replace all br tags with newlines
// Fixed an issue where the BR tag in the editor block formula HTML caused rendering errors.
el.querySelectorAll('p').forEach((p) => {
if (p.innerHTML.startsWith('$$') && p.innerHTML.endsWith('$$')) {
const str = p.innerHTML.replace(/<br>/g, '\n');
p.innerHTML = str;
}
});
import('mermaid').then(({ default: mermaid }) => {
mermaid.initialize({ startOnLoad: false });
@ -99,6 +108,7 @@ export function htmlRender(el: HTMLElement | null) {
render(el, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$$<br>', right: '<br>$$', display: true },
{
left: '\\begin{equation}',
right: '\\end{equation}',
@ -114,8 +124,31 @@ export function htmlRender(el: HTMLElement | null) {
},
);
// remove change table style to htmlToReact function
/**
* @description: You modify the DOM with other scripts after React has rendered the DOM. This way, on the next render cycle (re-render), React cannot find the DOM node it rendered before, because it has been modified or removed by other scripts.
*/
// change table style
el.querySelectorAll('table').forEach((table) => {
if (
(table.parentNode as HTMLDivElement)?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
// add rel nofollow for link not inlcludes domain
el.querySelectorAll('a').forEach((a) => {
const base = window.location.origin;
const targetUrl = new URL(a.href, base);
if (targetUrl.origin !== base) {
a.rel = 'nofollow';
}
});
}

View File

@ -0,0 +1,47 @@
import { memo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
const Index = ({ httpCode = '', errMsg = '' }) => {
const { t } = useTranslation('translation', { keyPrefix: 'page_error' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap') as HTMLElement;
if (pageWrap) {
pageWrap.style.display = 'contents';
}
return () => {
if (pageWrap) {
pageWrap.style.display = 'block';
}
};
}, []);
usePageTags({
title: t(`http_${httpCode}`, { keyPrefix: 'page_title' }),
});
return (
<div className="d-flex flex-column flex-shrink-1 flex-grow-1 justify-content-center align-items-center">
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
<h4 className="text-center">{t('http_error', { code: httpCode })}</h4>
<div className="text-center mb-3 fs-5">
{errMsg || t(`desc_${httpCode}`)}
</div>
<div className="text-center">
<Link to="/" className="btn btn-link">
{t('back_home')}
</Link>
</div>
</div>
);
};
export default memo(Index);

View File

@ -49,7 +49,7 @@ const Index = ({
cancelBtnVariant={cancelBtnVariant}
confirmBtnVariant={confirmBtnVariant}
{...props}>
<div dangerouslySetInnerHTML={{ __html: content }} />
<p dangerouslySetInnerHTML={{ __html: content }} />
</Modal>,
);
}

View File

@ -69,7 +69,7 @@ const Index: FC<IProps> = ({
if (type === 'question') {
Modal.confirm({
title: t('title'),
content: hasAnswer ? `<p>${t('question')}</p>` : `<p>${t('other')}</p>`,
content: hasAnswer ? t('question') : t('other'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
@ -90,7 +90,7 @@ const Index: FC<IProps> = ({
if (type === 'answer' && aid) {
Modal.confirm({
title: t('title'),
content: isAccepted ? t('answer_accepted') : `<p>${t('other')}</p>`,
content: isAccepted ? t('answer_accepted') : t('other'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
@ -128,6 +128,7 @@ const Index: FC<IProps> = ({
title: t('title', { keyPrefix: 'question_detail.reopen' }),
content: t('content', { keyPrefix: 'question_detail.reopen' }),
cancelBtnVariant: 'link',
confirmText: t('confirm_btn', { keyPrefix: 'question_detail.reopen' }),
onConfirm: () => {
reopenQuestion({
question_id: qid,

View File

@ -17,8 +17,8 @@ import {
} from '@/components';
const QuestionOrderKeys: Type.QuestionOrderBy[] = [
'newest',
'active',
'newest',
'frequent',
'score',
'unanswered',

View File

@ -30,7 +30,9 @@ const Index: FC<IProps> = ({
data.recommend && 'badge-tag-required',
className,
)}>
<span className={textClassName}>{data.display_name}</span>
<span className={textClassName}>
{data.display_name || data.slug_name}
</span>
</Link>
);
};

View File

@ -36,6 +36,7 @@ import WelcomeTitle from './WelcomeTitle';
import Counts from './Counts';
import QuestionList from './QuestionList';
import HotQuestions from './HotQuestions';
import HttpErrorContent from './HttpErrorContent';
export {
Avatar,
@ -78,5 +79,6 @@ export {
Counts,
QuestionList,
HotQuestions,
HttpErrorContent,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -218,6 +218,9 @@ img:not(a img, img.broken) {
img {
max-width: 100%;
}
video {
max-width: 100%;
}
p {
> code {
background-color: #e9ecef;

View File

@ -1,44 +0,0 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line import/no-unresolved
import { usePageTags } from '@/hooks';
const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_404' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap');
pageWrap.style.display = 'contents';
return () => {
pageWrap.style.display = 'block';
};
}, []);
usePageTags({
title: t('http_404', { keyPrefix: 'page_title' }),
});
return (
<Container
className="d-flex flex-column justify-content-center align-items-center"
style={{ flex: 1 }}>
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
<h4 className="text-center">{t('http_error')}</h4>
<div className="text-center mb-3 fs-5">{t('desc')}</div>
<div className="text-center">
<Button as={Link} to="/" variant="link">
{t('back_home')}
</Button>
</div>
</Container>
);
};
export default Index;

View File

@ -0,0 +1,7 @@
import { HttpErrorContent } from '@/components';
const Index = () => {
return <HttpErrorContent httpCode="404" />;
};
export default Index;

View File

@ -1,45 +0,0 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line import/no-unresolved
import { usePageTags } from '@/hooks';
const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_50X' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap');
pageWrap.style.display = 'contents';
return () => {
pageWrap.style.display = 'block';
};
}, []);
usePageTags({
title: t('http_50X', { keyPrefix: 'page_title' }),
});
return (
<Container
className="d-flex flex-column justify-content-center align-items-center"
style={{ flex: 1 }}>
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=T^T=)
</div>
<h4 className="text-center">{t('http_error')}</h4>
<div className="text-center mb-3 fs-5">{t('desc')}</div>
<div className="text-center">
<Button as={Link} to="/" variant="link">
{t('back_home')}
</Button>
</div>
</Container>
);
};
export default Index;

View File

@ -0,0 +1,7 @@
import { HttpErrorContent } from '@/components';
const Index = () => {
return <HttpErrorContent httpCode="50X" />;
};
export default Index;

View File

@ -58,7 +58,7 @@ const Answers: FC = () => {
content:
item.accepted === 2
? t('answer_accepted', { keyPrefix: 'delete' })
: `<p>${t('other', { keyPrefix: 'delete' })}</p>`,
: t('other', { keyPrefix: 'delete' }),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),

View File

@ -67,8 +67,8 @@ const Questions: FC = () => {
title: t('title', { keyPrefix: 'delete' }),
content:
item.answer_count > 0
? `<p>${t('question', { keyPrefix: 'delete' })}</p>`
: `<p>${t('other', { keyPrefix: 'delete' })}</p>`,
? t('question', { keyPrefix: 'delete' })
: t('other', { keyPrefix: 'delete' }),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),

View File

@ -19,14 +19,14 @@ const Index: FC = () => {
type: 'number',
title: t('permalink.label'),
description: t('permalink.text'),
enum: [1, 2, 3, 4],
enum: [4, 3, 2, 1],
enumNames: [
'/questions/10010000000000001/post-title',
'/questions/10010000000000001',
'/questions/D1D1/post-title',
'/questions/D1D1',
'/questions/D1D1/post-title',
'/questions/10010000000000001',
'/questions/10010000000000001/post-title',
],
default: 1,
default: 4,
},
robots: {
type: 'string',

View File

@ -4,7 +4,7 @@ import { HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import { toastStore, loginToContinueStore, errorCode } from '@/stores';
import { toastStore, loginToContinueStore, errorCodeStore } from '@/stores';
import {
Header,
Footer,
@ -12,11 +12,10 @@ import {
Customize,
CustomizeTheme,
PageTags,
HttpErrorContent,
} from '@/components';
import { LoginToContinueModal } from '@/components/Modal';
import { useImgViewer } from '@/hooks';
import Component404 from '@/pages/404';
import Component50X from '@/pages/50X';
const Layout: FC = () => {
const location = useLocation();
@ -24,8 +23,7 @@ const Layout: FC = () => {
const closeToast = () => {
toastClear();
};
const { code: httpStatusCode, reset: httpStatusReset } = errorCode();
const { code: httpStatusCode, reset: httpStatusReset } = errorCodeStore();
const imgViewer = useImgViewer();
const { show: showLoginToContinueModal } = loginToContinueStore();
@ -45,10 +43,8 @@ const Layout: FC = () => {
<div
className="position-relative page-wrap"
onClick={imgViewer.checkClickForImgView}>
{httpStatusCode === '404' ? (
<Component404 />
) : httpStatusCode === '50X' ? (
<Component50X />
{httpStatusCode ? (
<HttpErrorContent httpCode={httpStatusCode} />
) : (
<Outlet />
)}

View File

@ -1,8 +1,9 @@
import { FC } from 'react';
import { FC, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { useLegalPrivacy } from '@/services';
import { htmlRender } from '@/components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
@ -12,6 +13,15 @@ const Index: FC = () => {
const { data: privacy } = useLegalPrivacy();
const contentText = privacy?.privacy_policy_original_text;
let matchUrl: URL | undefined;
useEffect(() => {
const fmt = document.querySelector('.fmt') as HTMLElement;
if (!fmt) {
return;
}
htmlRender(fmt);
}, [privacy?.privacy_policy_parsed_text]);
try {
if (contentText) {
matchUrl = new URL(contentText);

View File

@ -1,8 +1,9 @@
import { FC } from 'react';
import { FC, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { useLegalTos } from '@/services';
import { htmlRender } from '@/components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
@ -12,6 +13,15 @@ const Index: FC = () => {
const { data: tos } = useLegalTos();
const contentText = tos?.terms_of_service_original_text;
let matchUrl: URL | undefined;
useEffect(() => {
const fmt = document.querySelector('.fmt') as HTMLElement;
if (!fmt) {
return;
}
htmlRender(fmt);
}, [tos?.terms_of_service_parsed_text]);
try {
if (contentText) {
matchUrl = new URL(contentText);
@ -22,8 +32,9 @@ const Index: FC = () => {
window.location.replace(matchUrl.toString());
return null;
}
return (
<>
<div>
<h3 className="mb-4">{t('tos')}</h3>
<div
className="fmt"
@ -31,7 +42,7 @@ const Index: FC = () => {
__html: tos?.terms_of_service_parsed_text || '',
}}
/>
</>
</div>
);
};

View File

@ -16,9 +16,10 @@ import {
questionDetail,
modifyQuestion,
useQueryRevisions,
postAnswer,
// postAnswer,
useQueryQuestionByTitle,
getTagsBySlugName,
saveQuestionWidthAnaser,
} from '@/services';
import { handleFormError, SaveDraft, storageExpires } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
@ -29,7 +30,7 @@ interface FormDataItem {
title: Type.FormValue<string>;
tags: Type.FormValue<Type.Tag[]>;
content: Type.FormValue<string>;
answer: Type.FormValue<string>;
answer_content: Type.FormValue<string>;
edit_summary: Type.FormValue<string>;
}
@ -52,7 +53,7 @@ const Ask = () => {
isInvalid: false,
errorMsg: '',
},
answer: {
answer_content: {
value: '',
isInvalid: false,
errorMsg: '',
@ -92,7 +93,7 @@ const Ask = () => {
return;
}
getTagsBySlugName(queryTags).then((tags) => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
// eslint-disable-next-line
handleTagsChange(tags);
});
};
@ -116,8 +117,8 @@ const Ask = () => {
formData.title.value = draft.title;
formData.content.value = draft.content;
formData.tags.value = draft.tags;
formData.answer.value = draft.answer;
setCheckState(Boolean(draft.answer));
formData.answer_content.value = draft.answer_content;
setCheckState(Boolean(draft.answer_content));
setHasDraft(true);
setFormData({ ...formData });
} else {
@ -131,7 +132,7 @@ const Ask = () => {
}, [qid]);
useEffect(() => {
const { title, tags, content, answer } = formData;
const { title, tags, content, answer_content } = formData;
const { title: editTitle, tags: editTags, content: editContent } = immData;
// edited
@ -151,14 +152,19 @@ const Ask = () => {
return;
}
// write
if (title.value || tags.value.length > 0 || content.value || answer.value) {
if (
title.value ||
tags.value.length > 0 ||
content.value ||
answer_content.value
) {
// save draft
saveDraft.save({
params: {
title: title.value,
tags: tags.value,
content: content.value,
answer: answer.value,
answer_content: answer_content.value,
},
callback: () => setHasDraft(true),
});
@ -215,7 +221,7 @@ const Ask = () => {
const handleAnswerChange = (value: string) =>
setFormData({
...formData,
answer: { ...formData.answer, value, errorMsg: '' },
answer_content: { ...formData.answer_content, value, errorMsg: '' },
});
const handleSummaryChange = (evt: React.ChangeEvent<HTMLInputElement>) =>
@ -263,31 +269,30 @@ const Ask = () => {
}
});
} else {
const res = await saveQuestion(params).catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
let res;
if (checked) {
res = await saveQuestionWidthAnaser({
...params,
answer_content: formData.answer_content.value,
}).catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
} else {
res = await saveQuestion(params).catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
}
const id = res?.id;
const id = res?.id || res?.question?.id;
if (id) {
if (checked) {
postAnswer({
question_id: id,
content: formData.answer.value,
})
.then(() => {
navigate(pathFactory.questionLanding(id, params.url_title));
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData, [
{ from: 'content', to: 'answer' },
]);
setFormData({ ...data });
}
});
navigate(pathFactory.questionLanding(id, res?.question?.url_title));
} else {
navigate(pathFactory.questionLanding(id));
}
@ -448,7 +453,7 @@ const Ask = () => {
<Form.Group controlId="answer" className="mt-4">
<Form.Label>{t('form.fields.answer.label')}</Form.Label>
<Editor
value={formData.answer.value}
value={formData.answer_content.value}
onChange={handleAnswerChange}
ref={editorRef2}
className={classNames(
@ -464,11 +469,11 @@ const Ask = () => {
/>
<Form.Control
type="text"
isInvalid={formData.answer.isInvalid}
isInvalid={formData.answer_content.isInvalid}
hidden
/>
<Form.Control.Feedback type="invalid">
{formData.answer.errorMsg}
{formData.answer_content.errorMsg}
</Form.Control.Feedback>
</Form.Group>
)}

View File

@ -1,5 +1,5 @@
import { memo, FC, useEffect, useRef } from 'react';
import { Button, Alert } from 'react-bootstrap';
import { Button, Alert, Badge } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
@ -20,8 +20,7 @@ interface Props {
data: AnswerItem;
/** router answer id */
aid?: string;
/** is author */
isAuthor: boolean;
canAccept: boolean;
questionTitle: string;
slugTitle: string;
isLogged: boolean;
@ -30,11 +29,11 @@ interface Props {
const Index: FC<Props> = ({
aid,
data,
isAuthor,
isLogged,
questionTitle = '',
slugTitle,
callback,
canAccept = false,
}) => {
const { t } = useTranslation('translation', {
keyPrefix: 'question_detail',
@ -77,12 +76,21 @@ const Index: FC<Props> = ({
{t('post_deleted', { keyPrefix: 'messages' })}
</Alert>
)}
{data?.accepted === 2 && (
<div className="mb-3 lh-1">
<Badge bg="success" pill>
<Icon name="check-circle-fill me-1" />
Best answer
</Badge>
</div>
)}
<article
dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<div className="d-flex align-items-center mt-4">
<Actions
source="answer"
data={{
id: data?.id,
isHate: data?.vote_status === 'vote_down',
@ -95,24 +103,17 @@ const Index: FC<Props> = ({
}}
/>
{data?.accepted === 2 && (
{canAccept && (
<Button
disabled={!isAuthor}
variant="outline-success"
className="ms-3 active opacity-100 bg-success text-white"
onClick={acceptAnswer}>
<Icon name="check-circle-fill" className="me-2" />
<span>{t('answers.btn_accepted')}</span>
</Button>
)}
{isAuthor && data.accepted === 1 && (
<Button
variant="outline-success"
variant={data.accepted === 2 ? 'success' : 'outline-success'}
className="ms-3"
onClick={acceptAnswer}>
<Icon name="check-circle-fill" className="me-2" />
<span>{t('answers.btn_accept')}</span>
<span>
{data.accepted === 2
? t('answers.btn_accepted')
: t('answers.btn_accept')}
</span>
</Button>
)}
</div>

View File

@ -108,12 +108,13 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
</div>
<article
ref={ref}
dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap mt-4"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<Actions
className="mt-4"
source="question"
data={{
id: data?.id,
isHate: data?.vote_status === 'vote_down',

View File

@ -1,6 +1,6 @@
import { memo, useState, FC, useEffect } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Form, Button, Alert } from 'react-bootstrap';
import { useTranslation, Trans } from 'react-i18next';
import { marked } from 'marked';
import classNames from 'classnames';
@ -18,6 +18,7 @@ interface Props {
/** question id */
qid: string;
answered?: boolean;
loggedUserRank: number;
};
callback?: (obj) => void;
}
@ -39,6 +40,7 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false);
const [hasDraft, setHasDraft] = useState(false);
const [showTips, setShowTips] = useState(data.loggedUserRank < 100);
usePromptWithUnload({
when: Boolean(formData.content.value),
@ -212,29 +214,58 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
</div>
)}
{showEditor && (
<Editor
className={classNames(
'form-control p-0',
focusType === 'answer' && 'focus',
)}
value={formData.content.value}
autoFocus={editorFocusState}
onChange={(val) => {
setFormData({
content: {
value: val,
isInvalid: false,
errorMsg: '',
},
});
}}
onFocus={() => {
setFocusType('answer');
}}
onBlur={() => {
setFocusType('');
}}
/>
<>
<Editor
className={classNames(
'form-control p-0',
focusType === 'answer' && 'focus',
)}
value={formData.content.value}
autoFocus={editorFocusState}
onChange={(val) => {
setFormData({
content: {
value: val,
isInvalid: false,
errorMsg: '',
},
});
}}
onFocus={() => {
setFocusType('answer');
}}
onBlur={() => {
setFocusType('');
}}
/>
<Alert
variant="warning"
show={data.loggedUserRank < 100 && showTips}
onClose={() => setShowTips(false)}
dismissible
className="mt-3">
<p>{t('tips.header_1')}</p>
<ul>
<li>
<Trans
i18nKey="question_detail.write_answer.tips.li1_1"
components={{ strong: <strong /> }}
/>
</li>
<li>{t('tips.li1_2')}</li>
</ul>
<p>
<Trans
i18nKey="question_detail.write_answer.tips.header_2"
components={{ strong: <strong /> }}
/>
</p>
<ul className="mb-0">
<li>{t('tips.li2_1')}</li>
</ul>
</Alert>
</>
)}
<Form.Control.Feedback type="invalid">

View File

@ -56,7 +56,9 @@ const Index = () => {
const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username;
const isAdmin = userInfo?.role_id === 2;
const isModerator = userInfo?.role_id === 3;
const isLogged = Boolean(userInfo?.access_token);
const loggedUserRank = userInfo?.rank;
const { state: locationState } = useLocation();
useEffect(() => {
@ -221,7 +223,7 @@ const Index = () => {
data={item}
questionTitle={question?.title || ''}
slugTitle={question?.url_title}
isAuthor={isAuthor}
canAccept={isAuthor || isAdmin || isModerator}
callback={initPage}
isLogged={isLogged}
/>
@ -247,6 +249,7 @@ const Index = () => {
data={{
qid,
answered: question?.answered,
loggedUserRank,
}}
callback={writeAnswerCallback}
/>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { Container, Row, Col, Form, Button, Card } from 'react-bootstrap';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { handleFormError } from '@/utils';
import { handleFormError, scrollToDocTop } from '@/utils';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { pathFactory } from '@/router/pathFactory';
import { Editor, EditorRef, Icon } from '@/components';
import { Editor, EditorRef, Icon, htmlRender } from '@/components';
import type * as Type from '@/common/interface';
import {
useQueryAnswerInfo,
@ -23,31 +23,47 @@ interface FormDataItem {
content: Type.FormValue<string>;
description: Type.FormValue<string>;
}
const initFormData = {
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
};
const Index = () => {
const { aid = '', qid = '' } = useParams();
const [focusType, setForceType] = useState('');
useLayoutEffect(() => {
scrollToDocTop();
}, []);
const { t } = useTranslation('translation', { keyPrefix: 'edit_answer' });
const navigate = useNavigate();
const initFormData = {
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
};
const { data } = useQueryAnswerInfo(aid);
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const [immData, setImmData] = useState(initFormData);
const [contentChanged, setContentChanged] = useState(false);
initFormData.content.value = data?.info.content || '';
useLayoutEffect(() => {
if (data?.info?.content) {
setFormData({
...formData,
content: {
value: data.info.content,
isInvalid: false,
errorMsg: '',
},
});
}
}, [data?.info?.content]);
const { data: revisions = [] } = useQueryRevisions(aid);
@ -57,6 +73,13 @@ const Index = () => {
const questionContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!questionContentRef?.current) {
return;
}
htmlRender(questionContentRef.current);
}, [questionContentRef]);
usePromptWithUnload({
when: contentChanged,
});
@ -147,9 +170,11 @@ const Index = () => {
const handleSelectedRevision = (e) => {
const index = e.target.value;
const revision = revisions[index];
formData.content.value = revision.content.content;
setImmData({ ...formData });
setFormData({ ...formData });
if (revision?.content) {
formData.content.value = revision.content.content;
setImmData({ ...formData });
setFormData({ ...formData });
}
};
const backPage = () => {
@ -192,7 +217,7 @@ const Index = () => {
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="revision" className="mb-3">
<Form.Label>{t('form.fields.revision.label')}</Form.Label>
<Form.Select onChange={handleSelectedRevision}>
<Form.Select onChange={handleSelectedRevision} defaultValue={0}>
{revisions.map(({ create_at, reason, user_info }, index) => {
const date = dayjs(create_at * 1000)
.tz()

View File

@ -15,7 +15,7 @@ const Questions: FC = () => {
const { user: loggedUser } = loggedUserInfoStore((_) => _);
const [urlSearchParams] = useSearchParams();
const curPage = Number(urlSearchParams.get('page')) || 1;
const curOrder = urlSearchParams.get('order') || 'newest';
const curOrder = urlSearchParams.get('order') || 'active';
const reqParams: Type.QueryQuestionsReq = {
page_size: 20,
page: curPage,

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { QueryGroup } from '@/components';
const sortBtns = ['relevance', 'newest', 'active', 'score'];
const sortBtns = ['active', 'newest', 'relevance', 'score'];
interface Props {
count: number;

View File

@ -21,7 +21,7 @@ const Index = () => {
const [searchParams] = useSearchParams();
const page = searchParams.get('page') || 1;
const q = searchParams.get('q') || '';
const order = searchParams.get('order') || 'relevance';
const order = searchParams.get('order') || 'active';
const { data, isLoading } = useSearch({
q,

View File

@ -28,7 +28,7 @@ const Questions: FC = () => {
const routeParams = useParams();
const curTagName = routeParams.tagName || '';
const [urlSearchParams] = useSearchParams();
const curOrder = urlSearchParams.get('order') || 'newest';
const curOrder = urlSearchParams.get('order') || 'active';
const curPage = Number(urlSearchParams.get('page')) || 1;
const reqParams: Type.QueryQuestionsReq = {
page_size: 20,

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { usePageTags } from '@/hooks';
import { Tag, TagSelector, FormatTime, Modal } from '@/components';
import { Tag, TagSelector, FormatTime, Modal, htmlRender } from '@/components';
import {
useTagInfo,
useQuerySynonymsTags,
@ -44,6 +44,15 @@ const TagIntroduction = () => {
});
}
}, [locationState]);
useEffect(() => {
const fmt = document.querySelector('.content.fmt') as HTMLElement;
if (!fmt) {
return;
}
htmlRender(fmt);
}, [tagInfo?.parsed_text]);
if (!tagInfo) {
return null;
}
@ -108,7 +117,9 @@ const TagIntroduction = () => {
confirmText: t('delete', { keyPrefix: 'btns' }),
confirmBtnVariant: 'danger',
onConfirm: () => {
deleteTag(tagInfo.tag_id);
deleteTag(tagInfo.tag_id).then(() => {
navigate('/tags', { replace: true });
});
},
});
};
@ -143,7 +154,7 @@ const TagIntroduction = () => {
</div>
<div
className="content text-break"
className="content text-break fmt"
dangerouslySetInnerHTML={{ __html: tagInfo?.parsed_text }}
/>
<div className="mt-4">
@ -204,7 +215,8 @@ const TagIntroduction = () => {
data={{
slug_name: tagName || '',
main_tag_slug_name: '',
display_name: '',
display_name:
tagInfo?.display_name || tagInfo?.slug_name || '',
recommend: false,
reserved: false,
}}

View File

@ -27,7 +27,7 @@ const Tags = () => {
const { role_id } = loggedUserInfoStore((_) => _.user);
const page = Number(urlSearch.get('page')) || 1;
const sort = urlSearch.get('sort');
const sort = urlSearch.get('sort') || sortBtns[0];
const pageSize = 20;
const {

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { loggedUserInfoStore, siteInfoStore } from '@/stores';
import { changeEmailVerify, getLoggedUserInfo } from '@/services';
import { changeEmailVerify } from '@/services';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'account_result' });
@ -20,12 +20,12 @@ const Index: FC = () => {
if (code) {
// do
changeEmailVerify({ code })
.then(() => {
.then((res) => {
setStep('success');
getLoggedUserInfo().then((res) => {
if (res?.access_token) {
// update user info
updateUser(res);
});
}
})
.catch(() => {
setStep('invalid');

View File

@ -1,8 +1,7 @@
import Error50X from '@/pages/50X';
// import Page404 from '@/pages/404';
import { HttpErrorContent } from '@/components';
const Index = () => {
return <Error50X />;
const Index = ({ errCode = '50X', errMsg = '' }) => {
return <HttpErrorContent httpCode={errCode} errMsg={errMsg} />;
};
export default Index;

View File

@ -1,41 +1,53 @@
import { FC, ReactNode, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useLoaderData } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
import { TGuardFunc } from '@/utils/guard';
const Index: FC<{
import RouteErrorBoundary from './RouteErrorBoundary';
const RouteGuard: FC<{
children: ReactNode;
onEnter?: TGuardFunc;
onEnter: TGuardFunc;
path?: string;
}> = ({
children,
onEnter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
path,
}) => {
page?: string;
}> = ({ children, onEnter, path, page }) => {
const navigate = useNavigate();
const location = useLocation();
const callGuards = () => {
if (onEnter) {
const gr = onEnter();
const redirectUrl = gr.redirect;
if (redirectUrl) {
floppyNavigation.navigate(redirectUrl, () => {
navigate(redirectUrl, { replace: true });
});
}
const loaderData = useLoaderData();
const gr = onEnter({
loaderData,
path,
page,
});
let guardError;
const errCode = gr.error?.code;
if (errCode === '403' || errCode === '404' || errCode === '50X') {
guardError = {
code: errCode,
msg: gr.error?.msg,
};
}
const handleGuardRedirect = () => {
const redirectUrl = gr.redirect;
if (redirectUrl) {
floppyNavigation.navigate(redirectUrl, () => {
navigate(redirectUrl, { replace: true });
});
}
};
useEffect(() => {
callGuards();
handleGuardRedirect();
}, [location]);
return (
<>
{/* Route Guard */}
{children}
{gr.ok ? children : null}
{!gr.ok && guardError ? (
<RouteErrorBoundary errCode={guardError.code} />
) : null}
</>
);
};
export default Index;
export default RouteGuard;

View File

@ -13,7 +13,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
routeNodes.forEach((rn) => {
if (rn.page === 'pages/Layout') {
rn.element = rn.guard ? (
<RouteGuard onEnter={rn.guard} path={rn.path}>
<RouteGuard onEnter={rn.guard} path={rn.path} page={rn.page}>
<Layout />
</RouteGuard>
) : (
@ -30,7 +30,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
rn.element = (
<Suspense>
{rn.guard ? (
<RouteGuard onEnter={rn.guard} path={rn.path}>
<RouteGuard onEnter={rn.guard} path={rn.path} page={rn.page}>
<Ctrl />
</RouteGuard>
) : (

View File

@ -19,6 +19,9 @@ const tagEdit = (tagId: string) => {
};
const questionLanding = (questionId: string, slugTitle: string = '') => {
const { seo } = seoSettingStore.getState();
if (!questionId) {
return slugTitle ? `/questions/null/${slugTitle}` : '/questions/null';
}
// @ts-ignore
if (/[13]/.test(seo.permalink) && slugTitle) {
return urlcat('/questions/:questionId/:slugPermalink', {

View File

@ -2,6 +2,8 @@ import type { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom';
import { guard } from '@/utils';
import type { TGuardFunc } from '@/utils/guard';
import { editCheck } from '@/services';
import { isEditable } from '@/utils/guard';
type IndexRouteNode = Omit<IndexRouteObject, 'children'>;
type NonIndexRouteNode = Omit<NonIndexRouteObject, 'children'>;
@ -74,6 +76,13 @@ const routes: RouteNode[] = [
{
path: 'posts/:qid/:aid/edit',
page: 'pages/Questions/EditAnswer',
loader: async ({ params }) => {
const ret = await editCheck(params.aid as string, true);
return ret;
},
guard: (args) => {
return isEditable(args);
},
},
{
path: '/search',

View File

@ -34,7 +34,7 @@ export const useQueryNotificationStatus = () => {
return useSWR<Type.NotificationStatus>(
tryLoggedAndActivated().ok ? apiUrl : null,
request.instance.get,
(url) => request.get(url, { ignoreError: '50X' }),
{
refreshInterval: 3000,
},

View File

@ -1,9 +1,11 @@
import request from '@/utils/request';
import * as Type from '@/common/interface';
export const editCheck = (id: string) => {
export const editCheck = (id: string, passingError: boolean = false) => {
const apiUrl = `/answer/api/v1/revisions/edit/check?id=${id}`;
return request.get(apiUrl);
return request.get(apiUrl, {
passingError,
});
};
export const revisionAudit = (id: string, operation: 'approve' | 'reject') => {

View File

@ -274,3 +274,7 @@ export const markdownToHtml = (content: string) => {
const apiUrl = '/answer/api/v1/post/render';
return request.post(apiUrl, { content });
};
export const saveQuestionWidthAnaser = (params: Type.QuestionWithAnswer) => {
return request.post('/answer/api/v1/question/answer', params);
};

View File

@ -1,25 +1,27 @@
import create from 'zustand';
type codeType = '404' | '50X' | '';
type codeType = '403' | '404' | '50X' | '';
interface NotFoundType {
interface ErrorCodeType {
code: codeType;
update: (code: codeType) => void;
msg: string;
update: (code: codeType, msg?: string) => void;
reset: () => void;
}
const notFound = create<NotFoundType>((set) => ({
const Index = create<ErrorCodeType>((set) => ({
code: '',
update: (code: codeType) => {
msg: '',
update: (code: codeType, msg: string = '') => {
set(() => {
return { code };
return { code, msg };
});
},
reset: () => {
set(() => {
return { code: '' };
return { code: '', msg: '' };
});
},
}));
export default notFound;
export default Index;

View File

@ -10,7 +10,7 @@ import pageTagStore from './pageTags';
import customizeStore from './customize';
import themeSettingStore from './themeSetting';
import loginToContinueStore from './loginToContinue';
import errorCode from './errorCode';
import errorCodeStore from './errorCode';
export {
toastStore,
@ -24,5 +24,5 @@ export {
themeSettingStore,
seoSettingStore,
loginToContinueStore,
errorCode,
errorCodeStore,
};

View File

@ -34,7 +34,7 @@ const initUser: UserInfoRes = {
const loggedUserInfoStore = create<UserInfoStore>((set) => ({
user: initUser,
update: (params) => {
if (!params.language) {
if (!params?.language) {
params.language = 'Default';
}
set(() => {

View File

@ -7,7 +7,7 @@ interface IProps {
update: (params: AdminSettingsSeo) => void;
}
const siteInfo = create<IProps>((set) => ({
const Index = create<IProps>((set) => ({
seo: {
robots: '',
permalink: 1,
@ -25,4 +25,4 @@ const siteInfo = create<IProps>((set) => ({
}),
}));
export default siteInfo;
export default Index;

View File

@ -1,6 +1,4 @@
import i18next from 'i18next';
import parse from 'html-react-parser';
import * as DOMPurify from 'dompurify';
const Diff = require('diff');
@ -235,32 +233,6 @@ function diffText(newText: string, oldText?: string): string {
return result.join('');
}
function htmlToReact(html: string) {
const cleanedHtml = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
const ele = document.createElement('div');
ele.innerHTML = cleanedHtml;
ele.querySelectorAll('table').forEach((table) => {
if (
(!table || (table.parentNode as HTMLDivElement))?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
return parse(ele.innerHTML);
}
function base64ToSvg(base64: string) {
// base64 to svg xml
const svgxml = atob(base64);
@ -296,6 +268,5 @@ export {
labelStyle,
handleFormError,
diffText,
htmlToReact,
base64ToSvg,
};

View File

@ -30,8 +30,16 @@ type TLoginState = {
export type TGuardResult = {
ok: boolean;
redirect?: string;
error?: {
code?: number | string;
msg?: string;
};
};
export type TGuardFunc = () => TGuardResult;
export type TGuardFunc = (args: {
loaderData?: any;
path?: string;
page?: string;
}) => TGuardResult;
export const deriveLoginState = (): TLoginState => {
const ls: TLoginState = {
@ -173,7 +181,11 @@ export const admin = () => {
const us = deriveLoginState();
if (gr.ok && !us.isAdmin) {
gr.ok = false;
gr.redirect = RouteAlias.home;
gr.error = {
code: '403',
msg: '',
};
gr.redirect = '';
}
return gr;
};
@ -183,7 +195,24 @@ export const isAdminOrModerator = () => {
const us = deriveLoginState();
if (gr.ok && !us.isAdmin && !us.isModerator) {
gr.ok = false;
gr.redirect = RouteAlias.home;
gr.error = {
code: '403',
msg: '',
};
gr.redirect = '';
}
return gr;
};
export const isEditable = (args) => {
const loaderData = args?.loaderData || {};
const gr: TGuardResult = { ok: true };
if (loaderData.code === 400) {
gr.ok = false;
gr.error = {
code: '403',
msg: loaderData.msg,
};
}
return gr;
};

View File

@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { Modal } from '@/components';
import { loggedUserInfoStore, toastStore, errorCode } from '@/stores';
import { loggedUserInfoStore, toastStore, errorCodeStore } from '@/stores';
import { LOGGED_TOKEN_STORAGE_KEY, IGNORE_PATH_LIST } from '@/common/constants';
import { RouteAlias } from '@/router/alias';
import { getCurrentLang } from '@/utils/localize';
@ -16,8 +16,12 @@ const baseConfig = {
withCredentials: true,
};
interface APIconfig extends AxiosRequestConfig {
allow404: boolean;
interface ApiConfig extends AxiosRequestConfig {
// Configure whether to allow takeover of 404 errors
allow404?: boolean;
ignoreError?: '403' | '50X';
// Configure whether to pass errors directly
passingError?: boolean;
}
class Request {
@ -52,14 +56,17 @@ class Request {
return data;
},
(error) => {
const { status, data: respData } = error.response || {};
const { data = {}, msg = '', reason = '' } = respData || {};
console.log('response error:', error);
const {
status,
data: errModel,
config: errConfig,
} = error.response || {};
const { data = {}, msg = '' } = errModel || {};
if (status === 400) {
// show error message
if (data instanceof Object && data.err_type) {
if (data?.err_type && errConfig?.passingError) {
return errModel;
}
if (data?.err_type) {
if (data.err_type === 'toast') {
// toast error message
toastStore.getState().show({
@ -90,7 +97,6 @@ class Request {
return Promise.reject({
code: status,
msg,
reason,
isError: true,
list: data,
});
@ -107,14 +113,13 @@ class Request {
// 401: Re-login required
if (status === 401) {
// clear userinfo
errorCode.getState().reset();
errorCodeStore.getState().reset();
loggedUserInfoStore.getState().clear();
floppyNavigation.navigateToLogin();
return Promise.reject(false);
}
if (status === 403) {
// Permission interception
errorCode.getState().reset();
if (data?.type === 'url_expired') {
// url expired
floppyNavigation.navigate(RouteAlias.activationFailed, () => {
@ -137,6 +142,14 @@ class Request {
return Promise.reject(false);
}
if (isIgnoredPath(IGNORE_PATH_LIST)) {
return Promise.reject(false);
}
if (error.config?.url.includes('/admin/api')) {
errorCodeStore.getState().update('403');
return Promise.reject(false);
}
if (msg) {
toastStore.getState().show({
msg,
@ -150,14 +163,18 @@ class Request {
if (isIgnoredPath(IGNORE_PATH_LIST)) {
return Promise.reject(false);
}
errorCode.getState().update('404');
errorCodeStore.getState().update('404');
return Promise.reject(false);
}
if (status >= 500) {
if (isIgnoredPath(IGNORE_PATH_LIST)) {
return Promise.reject(false);
}
errorCode.getState().update('50X');
if (error.config?.ignoreError !== '50X') {
errorCodeStore.getState().update('50X');
}
console.error(
`Request failed with status code ${status}, ${msg || ''}`,
);
@ -171,7 +188,7 @@ class Request {
return this.instance.request(config);
}
public get<T = any>(url: string, config?: APIconfig): Promise<T> {
public get<T = any>(url: string, config?: ApiConfig): Promise<T> {
return this.instance.get(url, config);
}

View File

@ -11,7 +11,7 @@ export type QuestionDraft = {
title: string;
content: string;
tags: any[];
answer: string;
answer_content: string;
};
callback?: () => void;
};

View File

@ -14,6 +14,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noImplicitAny": false,
"ignoreDeprecations": "5.0",
"suppressImplicitAnyIndexErrors": true,
"noEmit": true,
"jsx": "react-jsx",