Merge pull request #206 from answerdev/feat/1.0.5/ui

Feat/1.0.5/UI
This commit is contained in:
dashuai 2023-02-16 17:16:06 +08:00 committed by GitHub
commit 58f1c199fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 536 additions and 1158 deletions

View File

@ -1406,5 +1406,7 @@ ui:
staffs: Our community staff
reputation: reputation
votes: votes
prompt:
leave_page: "Are you sure you want to leave the page?"
changes_not_save: "Your changes may not be saved."

View File

@ -38,7 +38,7 @@
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-i18next": "^11.18.3",
"react-router-dom": "^6.4.0",
"react-router-dom": "^6.8.1",
"semver": "^7.3.8",
"swr": "^1.3.0",
"urlcat": "^3.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="generator" content="Answer %AnswerVersion% - https://github.com/answerdev/answer">
<meta name="generator" content="Answer - https://github.com/answerdev/answer">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
</head>
<body>

View File

@ -1,7 +1,7 @@
import { RouterProvider } from 'react-router-dom';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import './i18n/init';
import { routes, createBrowserRouter } from '@/router';
import routes from '@/router';
function App() {
const router = createBrowserRouter(routes);

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers } from '@/hooks';
import { usePageUsers, usePromptWithUnload } from '@/hooks';
const Index = ({
className = '',
@ -16,13 +16,19 @@ const Index = ({
mode,
}) => {
const [value, setValue] = useState('');
const [immData, setImmData] = useState('');
const pageUsers = usePageUsers();
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
const [validationErrorMsg, setValidationErrorMsg] = useState('');
usePromptWithUnload({
when: type === 'edit' ? immData !== value : Boolean(value),
});
useEffect(() => {
if (!initialValue) {
return;
}
setImmData(initialValue);
setValue(initialValue);
}, [initialValue]);

View File

@ -5,13 +5,18 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers } from '@/hooks';
import { usePageUsers, usePromptWithUnload } from '@/hooks';
const Index = ({ userName, onSendReply, onCancel, mode }) => {
const [value, setValue] = useState('');
const pageUsers = usePageUsers();
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
const [validationErrorMsg, setValidationErrorMsg] = useState('');
usePromptWithUnload({
when: Boolean(value),
});
const handleChange = (e) => {
setValue(e.target.value);
};

View File

@ -8,6 +8,7 @@ import useChangeUserRoleModal from './useChangeUserRoleModal';
import useUserModal from './useUserModal';
import useChangePasswordModal from './useChangePasswordModal';
import usePageTags from './usePageTags';
import usePromptWithUnload from './usePrompt';
export {
useTagModal,
@ -20,4 +21,5 @@ export {
useUserModal,
useChangePasswordModal,
usePageTags,
usePromptWithUnload,
};

View File

@ -0,0 +1,40 @@
import { useCallback } from 'react';
import {
useBeforeUnload,
unstable_usePrompt as usePrompt,
} from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// https://gist.github.com/chaance/2f3c14ec2351a175024f62fd6ba64aa6
// The link above is an example of implementing usePromt with useBlocer.
interface PromptProps {
when: boolean;
beforeUnload?: boolean;
}
const usePromptWithUnload = ({
when = false,
beforeUnload = true,
}: PromptProps) => {
const { t } = useTranslation('translation', { keyPrefix: 'prompt' });
usePrompt({
when,
message: `${t('leave_page')} ${t('changes_not_save')}`,
});
useBeforeUnload(
useCallback(
(event) => {
if (beforeUnload && when) {
const msg = t('changes_not_save');
event.preventDefault();
event.returnValue = msg;
}
},
[when, beforeUnload],
),
{ capture: true },
);
};
export default usePromptWithUnload;

View File

@ -92,6 +92,7 @@ const General: FC = () => {
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
);
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
@ -104,12 +105,21 @@ const General: FC = () => {
};
updateGeneralSetting(reqParams)
.then(() => {
.then((res) => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
updateSiteInfo(reqParams);
if (res.name) {
formData.name.value = res.name;
formData.description.value = res.description;
formData.short_description.value = res.short_description;
formData.site_url.value = res.site_url;
formData.contact_email.value = res.contact_email;
}
setFormData({ ...formData });
updateSiteInfo(res);
})
.catch((err) => {
if (err.isError) {

View File

@ -5,8 +5,9 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { isEqual } from 'lodash';
import { usePageTags } from '@/hooks';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { Editor, EditorRef, TagSelector } from '@/components';
import type * as Type from '@/common/interface';
import {
@ -61,7 +62,9 @@ const Ask = () => {
};
const { t } = useTranslation('translation', { keyPrefix: 'ask' });
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const [immData, setImmData] = useState<FormDataItem>(initFormData);
const [checked, setCheckState] = useState(false);
const [contentChanged, setContentChanged] = useState(false);
const [focusType, setForceType] = useState('');
const resetForm = () => {
setFormData(initFormData);
@ -94,6 +97,40 @@ const Ask = () => {
const { data: similarQuestions = { list: [] } } = useQueryQuestionByTitle(
isEdit ? '' : formData.title.value,
);
useEffect(() => {
const { title, tags, content, answer } = formData;
const { title: editTitle, tags: editTags, content: editContent } = immData;
// edited
if (qid) {
if (
editTitle.value !== title.value ||
editContent.value !== content.value ||
!isEqual(
editTags.value.map((v) => v.slug_name),
tags.value.map((v) => v.slug_name),
)
) {
setContentChanged(true);
} else {
setContentChanged(false);
}
return;
}
// write
if (title.value || tags.value.length > 0 || content.value || answer.value) {
setContentChanged(true);
} else {
setContentChanged(false);
}
}, [formData]);
usePromptWithUnload({
when: contentChanged,
});
useEffect(() => {
if (!isEdit) {
resetForm();
@ -116,6 +153,7 @@ const Ask = () => {
original_text: '',
};
});
setImmData({ ...formData });
setFormData({ ...formData });
});
}, [qid]);
@ -154,6 +192,7 @@ const Ask = () => {
});
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
setContentChanged(false);
event.preventDefault();
event.stopPropagation();
@ -219,6 +258,7 @@ const Ask = () => {
const index = e.target.value;
const revision = revisions[index];
formData.content.value = revision.content.content;
setImmData({ ...formData });
setFormData({ ...formData });
};
const bool = similarQuestions.length > 0 && !isEdit;

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import classNames from 'classnames';
import { usePromptWithUnload } from '@/hooks';
import { Editor, Modal, TextArea } from '@/components';
import { FormDataType } from '@/common/interface';
import { postAnswer } from '@/services';
@ -35,6 +36,10 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false);
usePromptWithUnload({
when: Boolean(formData.content.value),
});
const checkValidated = (): boolean => {
let bol = true;
const { content } = formData;

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Container, Row, Col, Form, Button, Card } from 'react-bootstrap';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -7,7 +7,7 @@ import dayjs from 'dayjs';
import classNames from 'classnames';
import { handleFormError } from '@/utils';
import { usePageTags } from '@/hooks';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { pathFactory } from '@/router/pathFactory';
import { Editor, EditorRef, Icon } from '@/components';
import type * as Type from '@/common/interface';
@ -44,6 +44,8 @@ const Index = () => {
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 || '';
@ -55,6 +57,19 @@ const Index = () => {
const questionContentRef = useRef<HTMLDivElement>(null);
usePromptWithUnload({
when: contentChanged,
});
useEffect(() => {
const { content, description } = formData;
if (immData.content.value !== content.value || description.value) {
setContentChanged(true);
} else {
setContentChanged(false);
}
}, [formData.content.value, formData.description.value]);
const handleAnswerChange = (value: string) =>
setFormData({
...formData,
@ -93,7 +108,9 @@ const Index = () => {
return bol;
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
setContentChanged(false);
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
@ -131,6 +148,7 @@ const Index = () => {
const index = e.target.value;
const revision = revisions[index];
formData.content.value = revision.content.content;
setImmData({ ...formData });
setFormData({ ...formData });
};

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } 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,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { usePageTags } from '@/hooks';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { Editor, EditorRef } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import type * as Type from '@/common/interface';
@ -40,6 +40,7 @@ const initFormData = {
errorMsg: '',
},
};
const Index = () => {
const { is_admin = false } = loggedUserInfoStore((state) => state.user);
@ -54,11 +55,45 @@ const Index = () => {
initFormData.slugName.value = data?.slug_name || '';
initFormData.description.value = data?.original_text || '';
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const [immData, setImmData] = useState(initFormData);
const [contentChanged, setContentChanged] = useState(false);
const editorRef = useRef<EditorRef>({
getHtml: () => '',
});
usePromptWithUnload({
when: contentChanged,
});
useEffect(() => {
const { displayName, slugName, description, editSummary } = formData;
const {
displayName: display_name,
slugName: slug_name,
description: original_text,
} = immData;
if (!display_name || !slug_name || !original_text) {
return;
}
if (
display_name.value !== displayName.value ||
slug_name.value !== slugName.value ||
original_text.value !== description.value ||
editSummary.value
) {
setContentChanged(true);
} else {
setContentChanged(false);
}
}, [
formData.displayName.value,
formData.slugName.value,
formData.description.value,
formData.editSummary.value,
]);
const handleDescriptionChange = (value: string) =>
setFormData({
...formData,
@ -91,6 +126,8 @@ const Index = () => {
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
setContentChanged(false);
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
@ -117,6 +154,9 @@ const Index = () => {
const index = e.target.value;
const revision = revisions[index];
formData.description.value = revision.content.original_text;
formData.displayName.value = revision.content.display_name;
formData.slugName.value = revision.content.slug_name;
setImmData({ ...formData });
setFormData({ ...formData });
};

View File

@ -1,14 +1,14 @@
import { Suspense, lazy } from 'react';
import { RouteObject, createBrowserRouter } from 'react-router-dom';
import { RouteObject } from 'react-router-dom';
import Layout from '@/pages/Layout';
import ErrorBoundary from '@/pages/50X';
import baseRoutes, { RouteNode } from '@/router/routes';
import RouteGuard from '@/router/RouteGuard';
const routes: RouteObject[] = [];
const routes: RouteNode[] = [];
const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
routeNodes.forEach((rn) => {
if (rn.page === 'pages/Layout') {
rn.element = rn.guard ? (
@ -49,4 +49,4 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
routeWrapper(baseRoutes, routes);
export { routes, createBrowserRouter };
export default routes as RouteObject[];

View File

@ -1,9 +1,13 @@
import { RouteObject } from 'react-router-dom';
import type { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom';
import { guard } from '@/utils';
import type { TGuardFunc } from '@/utils/guard';
export interface RouteNode extends RouteObject {
type IndexRouteNode = Omit<IndexRouteObject, 'children'>;
type NonIndexRouteNode = Omit<NonIndexRouteObject, 'children'>;
type UnionRouteNode = IndexRouteNode | NonIndexRouteNode;
export type RouteNode = UnionRouteNode & {
page: string;
children?: RouteNode[];
/**
@ -14,7 +18,7 @@ export interface RouteNode extends RouteObject {
* then auto redirect route to the `redirect` target.
*/
guard?: TGuardFunc;
}
};
const routes: RouteNode[] = [
{
@ -230,6 +234,7 @@ const routes: RouteNode[] = [
page: 'pages/Admin',
loader: async () => {
await guard.pullLoggedUser(true);
return null;
},
guard: () => {
return guard.admin();

View File

@ -85,6 +85,7 @@ export const pullLoggedUser = async (forceRePull = false) => {
if (Date.now() - dedupeTimestamp < 1000 * 10) {
return;
}
dedupeTimestamp = Date.now();
const loggedUserInfo = await getLoggedUserInfo().catch((ex) => {
dedupeTimestamp = 0;