Merge branch 'feat/ui-0.6.0' into feat/0.6.0/seo

This commit is contained in:
haitao(lj) 2022-12-06 18:19:47 +08:00
commit 6904c9775c
55 changed files with 553 additions and 193 deletions

View File

@ -13,8 +13,6 @@ stages:
"compile the html and other static files":
image: node:16
stage: compile-html
tags:
- runner-nanjing
before_script:
- npm config set registry https://repo.huaweicloud.com/repository/npm/
- make install-ui-packages

View File

@ -147,32 +147,32 @@ backend:
spam:
name:
other: "spam"
description:
desc:
other: "This post is an advertisement, or vandalism. It is not useful or relevant to the current topic."
rude:
name:
other: "rude or abusive"
description:
desc:
other: "A reasonable person would find this content inappropriate for respectful discourse."
duplicate:
name:
other: "a duplicate"
description:
desc:
other: "This question has been asked before and already has an answer."
not_answer:
name:
other: "not an answer"
description:
desc:
other: "This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether."
not_need:
name:
other: "no longer needed"
description:
desc:
other: "This comment is outdated, conversational or not relevant to this post."
other:
name:
other: "something else"
description:
desc:
other: "This post requires staff attention for another reason not listed above."
question:
@ -180,22 +180,22 @@ backend:
duplicate:
name:
other: "spam"
description:
desc:
other: "This question has been asked before and already has an answer."
guideline:
name:
other: "a community-specific reason"
description:
desc:
other: "This question doesn't meet a community guideline."
multiple:
name:
other: "needs details or clarity"
description:
desc:
other: "This question currently includes multiple questions in one. It should focus on one problem only."
other:
name:
other: "something else"
description:
desc:
other: "This post requires another reason not listed above."
notification:
@ -229,7 +229,7 @@ backend:
ui:
how_to_format:
title: How to Format
description: >-
desc: >-
<ul class="mb-0"><li><p class="mb-2">to make links</p><pre
class="mb-2"><code>&lt;https://url.com&gt;<br/><br/>[Title](https://url.com)</code></pre></li><li><p
class="mb-2">put returns between paragraphs</p></li><li><p
@ -338,7 +338,7 @@ ui:
empty: File cannot be empty.
only_image: Only image files are allowed.
max_size: File size cannot exceed 4MB.
description:
desc:
label: Description (optional)
tab_url: Image URL
form_url:
@ -410,12 +410,12 @@ ui:
range: Display name up to 35 characters.
slug_name:
label: URL Slug
description: 'Must use the character set "a-z", "0-9", "+ # - ."'
msg: "todo"
# empty: URL slug cannot be empty.
# range: URL slug up to 35 characters.
# character: URL slug contains unallowed character set.
description:
desc: 'Must use the character set "a-z", "0-9", "+ # - ."'
msg:
empty: URL slug cannot be empty.
range: URL slug up to 35 characters.
character: URL slug contains unallowed character set.
desc:
label: Description (optional)
btn_cancel: Cancel
btn_submit: Submit
@ -450,7 +450,7 @@ ui:
slug_name:
label: URL Slug
info: 'Must use the character set "a-z", "0-9", "+ # - ."'
description:
desc:
label: Description
edit_summary:
label: Edit Summary
@ -511,7 +511,7 @@ ui:
button_following: Following
tag_label: questions
search_placeholder: Filter by tag name
no_description: The tag has no description.
no_desc: The tag has no description.
more: More
ask:
title: Add Question
@ -752,6 +752,10 @@ ui:
<p>Are you sure you want to add another answer?</p><p>You could use the
edit link to refine and improve your existing answer, instead.</p>
empty: Answer cannot be empty.
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: >-
@ -912,7 +916,7 @@ ui:
config_yaml:
title: Create config.yaml
label: The config.yaml file created.
description: >-
desc: >-
You can create the <1>config.yaml</1> file manually in the
<1>/var/wwww/xxx/</1> directory and paste the following text into it.
info: "After youve done that, click “Next” button."
@ -949,31 +953,31 @@ ui:
empty: Email cannot be empty.
incorrect: Email incorrect format.
ready_title: Your Answer is Ready!
ready_description: >-
ready_desc: >-
If you ever feel like changing more settings, visit <1>admin section</1>;
find it in the site menu.
good_luck: "Have fun, and good luck!"
warn_title: Warning
warn_description: >-
warn_desc: >-
The file <1>config.yaml</1> already exists. If you need to reset any of the
configuration items in this file, please delete it first.
install_now: You may try <1>installing now</1>.
installed: Already installed
installed_description: >-
installed_desc: >-
You appear to have already installed. To reinstall please clear your old
database tables first.
db_failed: Database connection failed
db_failed_description: >-
db_failed_desc: >-
This either means that the database information in your <1>config.yaml</1> file is incorrect or that contact with the database server could not be established. This could mean your hosts database server is down.
page_404:
description: "Unfortunately, this page doesn't exist."
desc: "Unfortunately, this page doesn't exist."
back_home: Back to homepage
page_50X:
description: The server encountered an error and could not complete your request.
desc: The server encountered an error and could not complete your request.
back_home: Back to homepage
page_maintenance:
description: "We are under maintenance, well be back soon."
desc: "We are under maintenance, well be back soon."
nav_menus:
dashboard: Dashboard
contents: Contents
@ -990,6 +994,7 @@ ui:
write: Write
tos: Terms of Service
privacy: Privacy
seo: SEO
admin:
admin_header:
title: Admin
@ -1039,13 +1044,13 @@ ui:
btn_cancel: Cancel
btn_submit: Submit
normal_name: normal
normal_description: A normal user can ask and answer questions.
normal_desc: A normal user can ask and answer questions.
suspended_name: suspended
suspended_description: A suspended user can't log in.
suspended_desc: A suspended user can't log in.
deleted_name: deleted
deleted_description: "Delete profile, authentication associations."
deleted_desc: "Delete profile, authentication associations."
inactive_name: inactive
inactive_description: An inactive user must re-validate their email.
inactive_desc: An inactive user must re-validate their email.
confirm_title: Delete this user
confirm_content: Are you sure you want to delete this user? This is permanent!
confirm_btn: Delete
@ -1054,11 +1059,11 @@ ui:
status_modal:
title: "Change {{ type }} status to..."
normal_name: normal
normal_description: A normal post available to everyone.
normal_desc: A normal post available to everyone.
closed_name: closed
closed_description: "A closed question can't answer, but still can edit, vote and comment."
closed_desc: "A closed question can't answer, but still can edit, vote and comment."
deleted_name: deleted
deleted_description: All reputation gained and lost will be restored.
deleted_desc: All reputation gained and lost will be restored.
btn_cancel: Cancel
btn_submit: Submit
btn_next: Next
@ -1130,11 +1135,11 @@ ui:
msg: Site url cannot be empty.
validate: Please enter a valid URL.
text: The address of your site.
short_description:
short_desc:
label: Short Site Description (optional)
msg: Short site description cannot be empty.
text: "Short description, as used in the title tag on homepage."
description:
desc:
label: Site Description (optional)
msg: Site description cannot be empty.
text: "Describe this site in one sentence, as used in the meta description tag."
@ -1143,6 +1148,9 @@ ui:
msg: Contact email cannot be empty.
validate: Contact email is not valid.
text: Email address of key contact responsible for this site.
permalink:
label: Permalink
text: Custom URL structures can improve the usability, and forward-compatibility of your links.
interface:
page_title: Interface
logo:
@ -1236,6 +1244,11 @@ ui:
reserved_tags:
label: Reserved Tags
text: "Reserved tags can only be added to a post by moderator."
seo:
page_title: SEO
robots:
label: robots.txt
text: This will permanently override any related site settings.
form:
empty: cannot be empty
invalid: is invalid

View File

@ -21,6 +21,7 @@
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5",
"diff": "^5.1.0",
"emoji-regex": "^10.2.1",
"i18next": "^21.9.0",
"katex": "^0.16.2",
"lodash": "^4.17.21",
@ -37,6 +38,7 @@
"react-router-dom": "^6.4.0",
"semver": "^7.3.8",
"swr": "^1.3.0",
"urlcat": "^3.0.0",
"zustand": "^4.1.1"
},
"devDependencies": {

View File

@ -26,6 +26,7 @@ specifiers:
customize-cra: ^1.0.0
dayjs: ^1.11.5
diff: ^5.1.0
emoji-regex: ^10.2.1
eslint: ^8.0.1
eslint-config-airbnb: ^19.0.4
eslint-config-airbnb-typescript: ^17.0.0
@ -63,6 +64,7 @@ specifiers:
semver: ^7.3.8
swr: ^1.3.0
typescript: ^4.8.3
urlcat: ^3.0.0
yaml-loader: ^0.8.0
zustand: ^4.1.1
@ -75,6 +77,7 @@ dependencies:
copy-to-clipboard: 3.3.2
dayjs: 1.11.5
diff: 5.1.0
emoji-regex: 10.2.1
i18next: 21.9.2
katex: 0.16.2
lodash: 4.17.21
@ -91,6 +94,7 @@ dependencies:
react-router-dom: 6.4.0_biqbaboplfbrettd7655fr4n2y
semver: 7.3.8
swr: 1.3.0_react@18.2.0
urlcat: 3.0.0
zustand: 4.1.1_react@18.2.0
devDependencies:
@ -4903,6 +4907,10 @@ packages:
resolution: {integrity: sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==}
engines: {node: '>=10'}
/emoji-regex/10.2.1:
resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
dev: false
/emoji-regex/8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -10417,6 +10425,12 @@ packages:
querystringify: 2.2.0
requires-port: 1.0.0
/urlcat/3.0.0:
resolution: {integrity: sha512-SSXrIzInzKdWjBfm5iOrPfO6E5Nt0aFs5PTZCauxJTjJE3qhfePAWz8tjGm7dnWMYIAdPGjio51aakunyZHMXQ==}
dependencies:
qs: 6.11.0
dev: false
/use-sync-external-store/1.2.0_react@18.2.0:
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:

View File

@ -62,6 +62,7 @@ export const ADMIN_NAV_MENUS = [
{ name: 'smtp' },
{ name: 'legal' },
{ name: 'write' },
{ name: 'seo' },
],
},
];

View File

@ -279,6 +279,19 @@ export interface AdminSettingsGeneral {
description: string;
site_url: string;
contact_email: string;
/**
* 0: not set
* 1with title
* 2: no title
*/
permalink: number;
}
export interface HeadInfo {
title?: string;
description?: string;
keywords?: string;
ldJSON?;
}
export interface AdminSettingsInterface {
@ -325,6 +338,10 @@ export interface AdminSettingsWrite {
reserved_tags: string[];
}
export interface AdminSettingsSeo {
robots: string;
}
/**
* @description interface for Activity
*/

View File

@ -1,6 +1,10 @@
import emojiRegex from 'emoji-regex';
const pattern = {
emoji: emojiRegex(),
email:
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+\.)+[a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}))$/,
isAnswerId: /^1002\d{13}$/,
};
export default pattern;

View File

@ -3,6 +3,7 @@ import { Card, ListGroup, ListGroupItem } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import { Icon } from '@/components';
import { useHotQuestions } from '@/services';
@ -29,7 +30,7 @@ const HotQuestions: FC = () => {
<ListGroupItem
key={li.id}
as={Link}
to={`/questions/${li.id}`}
to={pathFactory.questionLanding(li.id, li.title)}
action>
<div className="link-dark">{li.title}</div>
{li.answer_count > 0 ? (

View File

@ -6,7 +6,12 @@ import { useTranslation } from 'react-i18next';
import { Modal } from '@/components';
import { useReportModal, useToast } from '@/hooks';
import Share from '../Share';
import { deleteQuestion, deleteAnswer, editCheck } from '@/services';
import {
deleteQuestion,
deleteAnswer,
editCheck,
reopenQuestion,
} from '@/services';
import { tryNormalLogged } from '@/utils/guard';
interface IProps {
@ -33,7 +38,11 @@ const Index: FC<IProps> = ({
const toast = useToast();
const navigate = useNavigate();
const reportModal = useReportModal();
const closeModal = useReportModal();
const refershQuestion = () => {
callback?.('default');
};
const closeModal = useReportModal(refershQuestion);
const editUrl =
type === 'answer' ? `/posts/${qid}/${aid}/edit` : `/posts/${qid}/edit`;
@ -108,6 +117,25 @@ const Index: FC<IProps> = ({
});
};
const handleReopen = () => {
Modal.confirm({
title: t('title', { keyPrefix: 'question_detail.reopen' }),
content: t('content', { keyPrefix: 'question_detail.reopen' }),
cancelBtnVariant: 'link',
onConfirm: () => {
reopenQuestion({
question_id: qid,
}).then(() => {
toast.onShow({
msg: t('success', { keyPrefix: 'question_detail.reopen' }),
variant: 'success',
});
refershQuestion();
});
},
});
};
const handleAction = (action) => {
if (!tryNormalLogged(true)) {
return;
@ -123,6 +151,10 @@ const Index: FC<IProps> = ({
if (action === 'close') {
handleClose();
}
if (action === 'reopen') {
handleReopen();
}
};
return (

View File

@ -3,6 +3,7 @@ import { ListGroup } from 'react-bootstrap';
import { NavLink, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import type * as Type from '@/common/interface';
import {
Icon,
@ -123,7 +124,9 @@ const QuestionList: FC<Props> = ({ source }) => {
key={li.id}
className="border-bottom pt-3 pb-2 px-0">
<h5 className="text-wrap text-break">
<NavLink to={`/questions/${li.id}`} className="link-dark">
<NavLink
to={pathFactory.questionLanding(li.id, li.title)}
className="link-dark">
{li.title}
{li.status === 2 ? ` [${t('closed')}]` : ''}
</NavLink>

View File

@ -14,13 +14,13 @@ export interface JSONSchema {
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean';
type: 'string' | 'boolean' | 'number';
title: string;
label?: string;
description?: string;
enum?: Array<string | boolean>;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean;
default?: string | boolean | number;
};
};
}
@ -477,8 +477,10 @@ const SchemaForm: FC<IProps> = ({
export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {};
Object.keys(schema.properties).forEach((key) => {
const v = schema.properties[key]?.default;
// TODO: set default value by property type
formData[key] = {
value: '',
value: typeof v !== 'undefined' ? v : '',
isInvalid: false,
errorMsg: '',
};

View File

@ -6,6 +6,7 @@ import { FacebookShareButton, TwitterShareButton } from 'next-share';
import copy from 'copy-to-clipboard';
import { loggedUserInfoStore } from '@/stores';
import { pathFactory } from '@/router/pathFactory';
interface IProps {
type: 'answer' | 'question';
@ -23,8 +24,12 @@ const Index: FC<IProps> = ({ type, qid, aid, title }) => {
let baseUrl =
type === 'question'
? `${window.location.origin}/questions/${qid}`
: `${window.location.origin}/questions/${qid}/${aid}`;
? `${window.location.origin}${pathFactory.questionLanding(qid, title)}`
: `${window.location.origin}${pathFactory.answerLanding({
questionId: qid,
questionTitle: title,
answerId: aid,
})}`;
if (user.id) {
baseUrl = `${baseUrl}?shareUserId=${user.username}`;
}

View File

@ -18,7 +18,7 @@ const Index: FC<IProps> = ({
className = '',
textClassName = '',
}) => {
href ||= pathFactory.tagLanding(data);
href ||= pathFactory.tagLanding(data?.slug_name);
return (
<a

View File

@ -5,6 +5,7 @@ import usePageUsers from './usePageUsers';
import useChangeModal from './useChangeModal';
import useEditStatusModal from './useEditStatusModal';
import useChangeUserRoleModal from './useChangeUserRoleModal';
import useHeadInfo from './useHeadInfo';
export {
useTagModal,
@ -14,4 +15,5 @@ export {
useChangeModal,
useEditStatusModal,
useChangeUserRoleModal,
useHeadInfo,
};

View File

@ -35,22 +35,22 @@ const useChangeModal = ({ callback }: Props) => {
{
type: 'normal',
name: t('normal_name'),
description: t('normal_description'),
description: t('normal_desc'),
},
{
type: 'suspended',
name: t('suspended_name'),
description: t('suspended_description'),
description: t('suspended_desc'),
},
{
type: 'deleted',
name: t('deleted_name'),
description: t('deleted_description'),
description: t('deleted_desc'),
},
{
type: 'inactive',
name: t('inactive_name'),
description: t('inactive_description'),
description: t('inactive_desc'),
},
]);

View File

@ -27,17 +27,17 @@ const useEditStatusModal = ({
{
type: 'normal',
name: t('normal_name'),
description: t('normal_description'),
description: t('normal_desc'),
},
{
type: 'closed',
name: t('closed_name'),
description: t('closed_description'),
description: t('closed_desc'),
},
{
type: 'deleted',
name: t('deleted_name'),
description: t('deleted_description'),
description: t('deleted_desc'),
},
]);

View File

@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { HeadInfo } from '@/common/interface';
import { headInfoStore } from '@/stores';
export default function useHeadInfo(info: HeadInfo) {
const { update } = headInfoStore.getState();
useEffect(() => {
update(info);
}, [info]);
}

View File

@ -204,15 +204,13 @@ const useTagModal = (props: IProps = {}) => {
isInvalid={formData.slugName.isInvalid}
/>
<Form.Text as="div">
{t('form.fields.slug_name.description')}
</Form.Text>
<Form.Text as="div">{t('form.fields.slug_name.desc')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.slugName.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="description">
<Form.Label>{t('form.fields.description.label')}</Form.Label>
<Form.Label>{t('form.fields.desc.label')}</Form.Label>
<Form.Control
className="font-monospace"
value={formData.description.value}

View File

@ -11,7 +11,7 @@ const Index = () => {
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
<div className="text-center mb-4">{t('description')}</div>
<div className="text-center mb-4">{t('desc')}</div>
<div className="text-center">
<Button as={Link} to="/" variant="link">
{t('back_home')}

View File

@ -11,7 +11,7 @@ const Index = () => {
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=T^T=)
</div>
<div className="text-center mb-3">{t('description')}</div>
<div className="text-center mb-3">{t('desc')}</div>
<div className="text-center">
<Button as={Link} to="/" variant="link">
{t('back_home')}

View File

@ -19,8 +19,7 @@ import { useEditStatusModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useAnswerSearch, changeAnswerStatus } from '@/services';
import { escapeRemove } from '@/utils';
import '../index.scss';
import { pathFactory } from '@/router/pathFactory';
const answerFilterItems: Type.AdminContentsFilterBy[] = ['normal', 'deleted'];
@ -129,7 +128,11 @@ const Answers: FC = () => {
<Stack>
<Stack direction="horizontal" gap={2}>
<a
href={`/questions/${li.question_id}/${li.id}`}
href={pathFactory.answerLanding({
questionId: li.question_id,
questionTitle: li.question_info.title,
answerId: li.id,
})}
target="_blank"
className="text-break text-wrap"
rel="noreferrer">

View File

@ -14,8 +14,7 @@ import { useReportModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useFlagSearch } from '@/services';
import { escapeRemove } from '@/utils';
import '../index.scss';
import { pathFactory } from '@/router/pathFactory';
const flagFilterKeys: Type.FlagStatus[] = ['pending', 'completed'];
const flagTypeKeys: Type.FlagType[] = ['all', 'question', 'answer', 'comment'];
@ -101,7 +100,10 @@ const Flags: FC = () => {
</small>
<BaseUserCard data={li.reported_user} className="mt-2" />
<a
href={`/questions/${li.question_id}`}
href={pathFactory.questionLanding(
li.question_id,
li.title,
)}
target="_blank"
className="text-wrap text-break mt-2"
rel="noreferrer">

View File

@ -9,8 +9,6 @@ import { useGeneralSetting, updateGeneralSetting } from '@/services';
import Pattern from '@/common/pattern';
import { handleFormError } from '@/utils';
import '../index.scss';
const General: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.general',
@ -35,19 +33,27 @@ const General: FC = () => {
},
short_description: {
type: 'string',
title: t('short_description.label'),
description: t('short_description.text'),
title: t('short_desc.label'),
description: t('short_desc.text'),
},
description: {
type: 'string',
title: t('description.label'),
description: t('description.text'),
title: t('desc.label'),
description: t('desc.text'),
},
contact_email: {
type: 'string',
title: t('contact_email.label'),
description: t('contact_email.text'),
},
permalink: {
type: 'number',
title: t('permalink.label'),
description: t('permalink.text'),
enum: [1, 2],
enumNames: ['/questions/123/post-title', '/questions/123'],
default: 1,
},
},
};
const uiSchema: UISchema = {
@ -63,7 +69,7 @@ const General: FC = () => {
}
if (
!url ||
/^https?:$/.test(url.protocol) === false ||
!/^https?:$/.test(url.protocol) ||
url.pathname !== '/' ||
url.search !== '' ||
url.hash !== ''
@ -86,11 +92,13 @@ const General: FC = () => {
},
},
},
permalink: {
'ui:widget': 'select',
},
};
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
);
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
@ -100,6 +108,7 @@ const General: FC = () => {
short_description: formData.short_description.value,
site_url: formData.site_url.value,
contact_email: formData.contact_email.value,
permalink: Number(formData.permalink.value),
};
updateGeneralSetting(reqParams)
@ -122,10 +131,13 @@ const General: FC = () => {
if (!setting) {
return;
}
const formMeta = {};
Object.keys(setting).forEach((k) => {
const formMeta: Type.FormDataType = {};
Object.keys(formData).forEach((k) => {
formMeta[k] = { ...formData[k], value: setting[k] };
});
if (formMeta.permalink.value !== 1 && formMeta.permalink.value !== 2) {
formMeta.permalink.value = 1;
}
setFormData({ ...formData, ...formMeta });
}, [setting]);

View File

@ -8,7 +8,6 @@ import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useToast } from '@/hooks';
import { getLegalSetting, putLegalSetting } from '@/services';
import { handleFormError } from '@/utils';
import '../index.scss';
const Legal: FC = () => {
const { t } = useTranslation('translation', {

View File

@ -18,8 +18,7 @@ import { ADMIN_LIST_STATUS } from '@/common/constants';
import { useEditStatusModal, useReportModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useQuestionSearch, changeQuestionStatus } from '@/services';
import '../index.scss';
import { pathFactory } from '@/router/pathFactory';
const questionFilterItems: Type.AdminContentsFilterBy[] = [
'normal',
@ -138,7 +137,7 @@ const Questions: FC = () => {
<tr key={li.id}>
<td>
<a
href={`/questions/${li.id}`}
href={pathFactory.questionLanding(li.id, li.title)}
target="_blank"
className="text-break text-wrap"
rel="noreferrer">

View File

@ -0,0 +1,86 @@
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { getSeoSetting, putSeoSetting } from '@/services';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useToast } from '@/hooks';
import { handleFormError } from '@/utils';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.seo',
});
const Toast = useToast();
const schema: JSONSchema = {
title: t('page_title'),
properties: {
robots: {
type: 'string',
title: t('robots.label'),
description: t('robots.text'),
},
},
};
const uiSchema: UISchema = {
robots: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsSeo = {
robots: formData.robots.value,
};
putSeoSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getSeoSetting().then((setting) => {
if (setting) {
const formMeta = { ...formData };
formMeta.robots.value = setting.robots;
setFormData(formMeta);
}
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
formData={formData}
onSubmit={onSubmit}
uiSchema={uiSchema}
onChange={handleOnChange}
/>
</>
);
};
export default Index;

View File

@ -5,8 +5,7 @@ import type * as Type from '@/common/interface';
import { useToast } from '@/hooks';
import { useSmtpSetting, updateSmtpSetting } from '@/services';
import pattern from '@/common/pattern';
import { SchemaForm, JSONSchema, UISchema } from '@/components';
import { initFormData } from '../../../components/SchemaForm/index';
import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components';
import { handleFormError } from '@/utils';
const Smtp: FC = () => {

View File

@ -18,11 +18,9 @@ import { useChangeModal, useChangeUserRoleModal, useToast } from '@/hooks';
import { useQueryUsers } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import '../index.scss';
const UserFilterKeys: Type.UserFilterBy[] = [
'all',
// 'staff',
'staff',
'inactive',
'suspended',
'deleted',
@ -119,7 +117,7 @@ const Users: FC = () => {
<thead>
<tr>
<th>{t('name')}</th>
{/* <th style={{ width: '12%' }}>{t('reputation')}</th> */}
<th style={{ width: '12%' }}>{t('reputation')}</th>
<th style={{ width: '20%' }}>{t('email')}</th>
<th className="text-nowrap" style={{ width: '15%' }}>
{t('created_at')}
@ -131,7 +129,7 @@ const Users: FC = () => {
)}
<th style={{ width: '12%' }}>{t('status')}</th>
{/* <th style={{ width: '12%' }}>{t('role')}</th> */}
<th style={{ width: '12%' }}>{t('role')}</th>
{curFilter !== 'deleted' ? (
<th style={{ width: '8%' }} className="text-end">
{t('action')}
@ -153,7 +151,6 @@ const Users: FC = () => {
showReputation={false}
/>
</td>
{/* <td>{user.rank}</td> */}
<td className="text-break">{user.e_mail}</td>
<td>
<FormatTime time={user.created_at} />
@ -173,11 +170,11 @@ const Users: FC = () => {
{t(user.status)}
</span>
</td>
{/* <td> */}
{/* <span className="badge text-bg-light"> */}
{/* {t(user.role_name)} */}
{/* </span> */}
{/* </td> */}
<td>
<span className="badge text-bg-light">
{t(user.role_name)}
</span>
</td>
{curFilter !== 'deleted' ? (
<td className="text-end">
<Dropdown>
@ -185,28 +182,16 @@ const Users: FC = () => {
<Icon name="three-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
{/* <Dropdown.Item>{t('set_new_password')}</Dropdown.Item> */}
<Dropdown.Item
onClick={() => handleAction('status', user)}>
{t('change_status')}
</Dropdown.Item>
{/* <Dropdown.Item */}
{/* onClick={() => handleAction('role', user)}> */}
{/* {t('change_role')} */}
{/* </Dropdown.Item> */}
{/* <Dropdown.Divider />
<Dropdown.Item>{t('show_logs')}</Dropdown.Item> */}
<Dropdown.Item
onClick={() => handleAction('role', user)}>
{t('change_role')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
{/* {user.status !== 'deleted' && (
<Button
className="p-0 btn-no-border"
variant="link"
onClick={() => handleClick(user)}>
{t('change')}
</Button>
)} */}
</td>
) : null}
</tr>

View File

@ -10,14 +10,11 @@ import {
} from '@/services';
import { handleFormError } from '@/utils';
import '../index.scss';
const Legal: FC = () => {
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.write',
});
const Toast = useToast();
// const updateSiteInfo = siteInfoStore((state) => state.update);
const schema: JSONSchema = {
title: t('page_title'),
@ -125,4 +122,4 @@ const Legal: FC = () => {
);
};
export default Legal;
export default Index;

View File

@ -4,17 +4,29 @@ import { Helmet, HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import { siteInfoStore, toastStore, brandingStore } from '@/stores';
import {
siteInfoStore,
toastStore,
brandingStore,
headInfoStore,
} from '@/stores';
import { Header, Footer, Toast } from '@/components';
const Layout: FC = () => {
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const { siteInfo } = siteInfoStore.getState();
const siteInfo = siteInfoStore((state) => state.siteInfo);
const headInfo = headInfoStore((state) => state.headInfo);
const { favicon, square_icon } = brandingStore((state) => state.branding);
const closeToast = () => {
toastClear();
};
const {
title,
keywords,
description = siteInfo.description,
ldJSON,
} = headInfo;
return (
<HelmetProvider>
@ -26,8 +38,10 @@ const Layout: FC = () => {
/>
<link rel="icon" type="image/png" sizes="192x192" href={square_icon} />
<link rel="apple-touch-icon" type="image/png" href={square_icon} />
{siteInfo && <meta name="description" content={siteInfo.description} />}
{title && <title>{title}</title>}
{keywords && <meta name="keywords" content={keywords} />}
{description && <meta name="description" content={description} />}
{ldJSON && <script type="application/ld+json">{ldJSON}</script>}
</Helmet>
<SWRConfig
value={{

View File

@ -3,6 +3,7 @@ import { Accordion, ListGroup } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
import { pathFactory } from '@/router/pathFactory';
import './index.scss';
@ -28,7 +29,7 @@ const SearchQuestion = ({ similarQuestions }) => {
as="a"
className="link-dark"
key={item.id}
href={`/questions/${item.id}`}
href={pathFactory.questionLanding(item.id, item.title)}
target="_blank">
<span className="text-wrap text-break">
{item.title}

View File

@ -17,6 +17,7 @@ import {
useQueryQuestionByTitle,
} from '@/services';
import { handleFormError } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
import SearchQuestion from './components/SearchQuestion';
@ -235,7 +236,7 @@ const Ask = () => {
edit_summary: formData.edit_summary.value,
})
.then((res) => {
navigate(`/questions/${qid}`, {
navigate(pathFactory.questionLanding(qid, params.title), {
state: { isReview: res?.wait_for_review },
});
})
@ -262,7 +263,7 @@ const Ask = () => {
html: editorRef2.current.getHtml(),
})
.then(() => {
navigate(`/questions/${id}`);
navigate(pathFactory.questionLanding(id, params.title));
})
.catch((err) => {
if (err.isError) {
@ -271,7 +272,7 @@ const Ask = () => {
}
});
} else {
navigate(`/questions/${id}`);
navigate(pathFactory.questionLanding(id, params.title));
}
}
}
@ -466,7 +467,7 @@ const Ask = () => {
<Card.Body
className="fmt small"
dangerouslySetInnerHTML={{
__html: t('description', { keyPrefix: 'how_to_format' }),
__html: t('desc', { keyPrefix: 'how_to_format' }),
}}
/>
</Card>

View File

@ -14,6 +14,7 @@ import {
} from '@/components';
import { formatCount } from '@/utils';
import { following } from '@/services';
import { pathFactory } from '@/router/pathFactory';
interface Props {
data: any;
@ -57,10 +58,14 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
if (!data?.id) {
return null;
}
return (
<div>
<h1 className="h3 mb-3 text-wrap text-break">
<Link className="link-dark" reloadDocument to={`/questions/${data.id}`}>
<Link
className="link-dark"
reloadDocument
to={pathFactory.questionLanding(data.id, data.title)}>
{data.title}
{data.status === 2
? ` [${t('closed', { keyPrefix: 'question' })}]`

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
import { useSimilarQuestion } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import { pathFactory } from '@/router/pathFactory';
interface Props {
id: string;
@ -31,7 +32,7 @@ const Index: FC<Props> = ({ id }) => {
action
key={item.id}
as={Link}
to={`/questions/${item.id}`}>
to={pathFactory.questionLanding(item.id, item.title)}>
<div className="link-dark">{item.title}</div>
{item.answer_count > 0 && (
<div

View File

@ -8,6 +8,7 @@ import {
} from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Pattern from '@/common/pattern';
import { Pagination, PageTitle } from '@/components';
import { loggedUserInfoStore, toastStore } from '@/stores';
import { scrollTop } from '@/utils';
@ -33,9 +34,14 @@ import './index.scss';
const Index = () => {
const navigate = useNavigate();
const { t } = useTranslation('translation');
const { qid = '', aid = '' } = useParams();
const [urlSearch] = useSearchParams();
const { qid = '', slugPermalink = '' } = useParams();
// Compatible with Permalink
let { aid = '' } = useParams();
if (!aid && Pattern.isAnswerId.test(slugPermalink)) {
aid = slugPermalink;
}
const [urlSearch] = useSearchParams();
const page = Number(urlSearch.get('page') || 0);
const order = urlSearch.get('order') || '';
const [question, setQuestion] = useState<QuestionDetailRes | null>(null);
@ -108,6 +114,12 @@ const Index = () => {
}, 1000);
return;
}
if (type === 'default') {
window.scrollTo(0, 0);
getDetail();
return;
}
requestAnswers();
};

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { pathFactory } from '@/router/pathFactory';
import { Editor, EditorRef, Icon, PageTitle } from '@/components';
import type * as Type from '@/common/interface';
import {
@ -110,12 +111,18 @@ const Ask = () => {
edit_summary: formData.description.value,
};
modifyAnswer(params).then((res) => {
navigate(`/questions/${qid}/${aid}`, {
navigate(
pathFactory.answerLanding({
questionId: qid,
questionTitle: data?.question?.title,
answerId: aid,
}),
{
state: { isReview: res?.wait_for_review },
});
},
);
});
};
const handleSelectedRevision = (e) => {
const index = e.target.value;
const revision = revisions[index];
@ -138,7 +145,10 @@ const Ask = () => {
</Row>
<Row className="justify-content-center">
<Col xxl={7} lg={8} sm={12} className="mb-4 mb-md-0">
<a href={`/questions/${qid}`} target="_blank" rel="noreferrer">
<a
href={pathFactory.questionLanding(qid, data?.question.title)}
target="_blank"
rel="noreferrer">
<h5 className="mb-3">{data?.question.title}</h5>
</a>

View File

@ -100,22 +100,23 @@ const Index: FC = () => {
const editor = unreviewed_info?.user_info;
const editTime = unreviewed_info?.create_at;
if (type === 'question') {
itemLink = pathFactory.questionLanding(info?.object_id);
itemLink = pathFactory.questionLanding(info?.object_id, info?.title);
itemTitle = info?.title;
editBadge = t('question_edit');
editSummary ||= t('edit_question');
} else if (type === 'answer') {
itemLink = pathFactory.answerLanding(
itemLink = pathFactory.answerLanding({
// @ts-ignore
unreviewed_info.content.question_id,
unreviewed_info.object_id,
);
questionId: unreviewed_info.content.question_id,
questionTitle: info?.title,
answerId: unreviewed_info.object_id,
});
itemTitle = info?.title;
editBadge = t('answer_edit');
editSummary ||= t('edit_answer');
} else if (type === 'tag') {
const tagInfo = unreviewed_info.content as Type.Tag;
itemLink = pathFactory.tagLanding(tagInfo);
itemLink = pathFactory.tagLanding(tagInfo.slug_name);
itemTitle = tagInfo.display_name;
editBadge = t('tag_edit');
editSummary ||= t('edit_tag');

View File

@ -2,6 +2,7 @@ import { memo, FC } from 'react';
import { ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import { Icon, Tag, FormatTime, BaseUserCard } from '@/components';
import type { SearchResItem } from '@/common/interface';
import { escapeRemove } from '@/utils';
@ -14,9 +15,13 @@ const Index: FC<Props> = ({ data }) => {
if (!data?.object_type) {
return null;
}
let itemUrl = `/questions/${data.object.id}`;
if (data.object_type === 'answer') {
itemUrl = `/questions/${data.object.question_id}/${data.object.id}`;
let itemUrl = pathFactory.questionLanding(data.object.id, data.object.title);
if (data.object_type === 'answer' && data.object.question_id) {
itemUrl = pathFactory.answerLanding({
questionId: data.object.question_id,
questionTitle: data.object.title,
answerId: data.object.id,
});
}
return (
<ListGroupItem className="py-3 px-0">

View File

@ -31,7 +31,9 @@ const Questions: FC = () => {
if (tagResp) {
const info = { ...tagResp };
if (info.main_tag_slug_name) {
navigate(pathFactory.tagLanding(info), { replace: true });
navigate(pathFactory.tagLanding(info.main_tag_slug_name), {
replace: true,
});
return;
}
if (followResp) {
@ -63,7 +65,7 @@ const Questions: FC = () => {
<div className="tag-box mb-5">
<h3 className="mb-3">
<Link
to={pathFactory.tagLanding(tagInfo)}
to={pathFactory.tagLanding(tagInfo.slug_name)}
replace
className="link-dark">
{tagInfo.display_name}
@ -71,7 +73,7 @@ const Questions: FC = () => {
</h3>
<p className="text-break">
{escapeRemove(tagInfo.excerpt) || t('no_description')}
{escapeRemove(tagInfo.excerpt) || t('no_desc')}
<Link to={pathFactory.tagInfo(curTagName)} className="ms-1">
[{t('more')}]
</Link>

View File

@ -203,7 +203,7 @@ const Ask = () => {
</Form.Group>
<Form.Group controlId="description" className="mt-4">
<Form.Label>{t('form.fields.description.label')}</Form.Label>
<Form.Label>{t('form.fields.desc.label')}</Form.Label>
<Editor
value={formData.description.value}
onChange={handleDescriptionChange}
@ -260,7 +260,7 @@ const Ask = () => {
<Card.Body
className="fmt small"
dangerouslySetInnerHTML={{
__html: t('description', { keyPrefix: 'how_to_format' }),
__html: t('desc', { keyPrefix: 'how_to_format' }),
}}
/>
</Card>

View File

@ -106,6 +106,7 @@ const TagIntroduction = () => {
keyPrefix: 'page_title',
})}`;
}
return (
<>
<PageTitle title={pageTitle} />
@ -114,7 +115,7 @@ const TagIntroduction = () => {
<Col xxl={7} lg={8} sm={12}>
<h3 className="mb-3">
<Link
to={pathFactory.tagLanding(tagInfo)}
to={pathFactory.tagLanding(tagInfo.slug_name)}
replace
className="link-dark">
{tagInfo.display_name}

View File

@ -3,6 +3,7 @@ import { Container, Row, Col, Form, Table } from 'react-bootstrap';
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import { loggedUserInfoStore } from '@/stores';
import { getTimelineData } from '@/services';
import { PageTitle, Empty } from '@/components';
@ -44,12 +45,19 @@ const Index: FC = () => {
let linkUrl = '';
let pageTitle = '';
if (timelineData?.object_info.object_type === 'question') {
linkUrl = `/questions/${timelineData?.object_info.question_id}`;
linkUrl = pathFactory.questionLanding(
timelineData?.object_info.question_id,
timelineData?.object_info.title,
);
pageTitle = `${t('title_for_question')} ${timelineData?.object_info.title}`;
}
if (timelineData?.object_info.object_type === 'answer') {
linkUrl = `/questions/${timelineData?.object_info.question_id}/${timelineData?.object_info.answer_id}`;
linkUrl = pathFactory.answerLanding({
questionId: timelineData?.object_info.question_id,
questionTitle: timelineData?.object_info.title,
answerId: timelineData?.object_info.answer_id,
});
pageTitle = `${t('title_for_answer', {
title: timelineData?.object_info.title,
author: timelineData?.object_info.display_name,

View File

@ -3,6 +3,7 @@ import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon, FormatTime, Tag } from '@/components';
import { pathFactory } from '@/router/pathFactory';
interface Props {
visible: boolean;
@ -20,7 +21,11 @@ const Index: FC<Props> = ({ visible, data }) => {
<ListGroupItem className="py-3 px-0" key={item.answer_id}>
<h6 className="mb-2">
<a
href={`/questions/${item.question_id}/${item.answer_id}`}
href={pathFactory.answerLanding({
questionId: item.question_id,
questionTitle: item.question_info?.title,
answerId: item.answer_id,
})}
className="text-break">
{item.question_info?.title}
</a>

View File

@ -1,6 +1,7 @@
import { FC, memo } from 'react';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { pathFactory } from '@/router/pathFactory';
import { FormatTime } from '@/components';
interface Props {
@ -19,11 +20,15 @@ const Index: FC<Props> = ({ visible, data }) => {
<ListGroupItem className="py-3 px-0" key={item.comment_id}>
<a
className="text-break"
href={`/questions/${
href={
item.object_type === 'question'
? item.object_id
: `${item.question_id}/${item.object_id}`
}`}>
? pathFactory.questionLanding(item.object_id, item.title)
: pathFactory.answerLanding({
questionId: item.question_id,
questionTitle: item.title,
answerId: item.object_id,
})
}>
{item.title}
</a>
<div

View File

@ -3,6 +3,7 @@ import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon, FormatTime, Tag, BaseUserCard } from '@/components';
import { pathFactory } from '@/router/pathFactory';
interface Props {
visible: boolean;
@ -15,6 +16,7 @@ const Index: FC<Props> = ({ visible, tabName, data }) => {
if (!visible) {
return null;
}
return (
<ListGroup variant="flush">
{data.map((item) => {
@ -25,9 +27,10 @@ const Index: FC<Props> = ({ visible, tabName, data }) => {
<h6 className="mb-2">
<a
className="text-break"
href={`/questions/${
tabName === 'questions' ? item.question_id : item.id
}`}>
href={pathFactory.questionLanding(
tabName === 'questions' ? item.question_id : item.id,
item.title,
)}>
{item.title}
{tabName === 'questions' && item.status === 'closed'
? ` [${t('closed', { keyPrefix: 'question' })}]`

View File

@ -3,6 +3,7 @@ import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { FormatTime } from '@/components';
import { pathFactory } from '@/router/pathFactory';
interface Props {
visible: boolean;
@ -30,11 +31,15 @@ const Index: FC<Props> = ({ visible, data }) => {
<div>
<a
className="text-break"
href={`/questions/${
href={
item.object_type === 'question'
? item.object_id
: `${item.question_id}/${item.object_id}`
}`}>
? pathFactory.questionLanding(item.object_id, item.title)
: pathFactory.answerLanding({
questionId: item.question_id,
questionTitle: item.title,
answerId: item.object_id,
})
}>
{item.title}
</a>
<div className="d-flex align-items-center fs-14 text-secondary">

View File

@ -2,6 +2,7 @@ import { FC, memo } from 'react';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import { Icon } from '@/components';
interface Props {
@ -10,7 +11,6 @@ interface Props {
}
const Index: FC<Props> = ({ data, type }) => {
const { t } = useTranslation('translation', { keyPrefix: 'personal' });
return (
<ListGroup variant="flush" className="mb-4">
{data?.map((item) => {
@ -19,11 +19,15 @@ const Index: FC<Props> = ({ data, type }) => {
className="p-0 border-0 mb-2"
key={type === 'answer' ? item.answer_id : item.question_id}>
<a
href={`/questions/${
href={
type === 'answer'
? `${item.question_id}/${item.answer_id}`
: item.question_id
}`}>
? pathFactory.answerLanding({
questionId: item.question_id,
questionTitle: item.question_info?.title,
answerId: item.answer_id,
})
: pathFactory.questionLanding(item.question_id, item.title)
}>
{type === 'answer' ? item.question_info.title : item.title}
</a>
<div className="d-inline-block text-secondary ms-3 fs-14">

View File

@ -1,6 +1,7 @@
import { FC, memo } from 'react';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { pathFactory } from '@/router/pathFactory';
import { FormatTime } from '@/components';
interface Props {
@ -12,6 +13,7 @@ const Index: FC<Props> = ({ visible, data }) => {
if (!visible || !data?.length) {
return null;
}
return (
<ListGroup variant="flush">
{data.map((item) => {
@ -25,11 +27,15 @@ const Index: FC<Props> = ({ visible, data }) => {
<div>
<a
className="text-break"
href={`/questions/${
href={
item.object_type === 'question'
? item.question_id
: `${item.question_id}/${item.answer_id}`
}`}>
? pathFactory.questionLanding(item.question_id, item.title)
: pathFactory.answerLanding({
questionId: item.question_id,
questionTitle: item.title,
answerId: item.answer_id,
})
}>
{item.title}
</a>
<div className="d-flex align-items-center fs-14 text-secondary">

View File

@ -1,22 +1,53 @@
import type * as Type from '@/common/interface';
import urlcat from 'urlcat';
const tagLanding = (tag: Type.Tag) => {
let slugName = tag.slug_name || '';
import Pattern from '@/common/pattern';
import { siteInfoStore } from '@/stores';
const tagLanding = (slugName: string) => {
if (!slugName) {
return '/tags';
}
slugName = slugName.toLowerCase();
return `/tags/${encodeURIComponent(slugName)}`;
return urlcat('/tags/:slugName', { slugName });
};
const tagInfo = (slugName: string) => {
if (!slugName) {
return '/tags';
}
slugName = slugName.toLowerCase();
return `/tags/${encodeURIComponent(slugName)}/info`;
return urlcat('/tags/:slugName/info', { slugName });
};
const tagEdit = (tagId: string) => {
return `/tags/${tagId}/edit`;
return urlcat('/tags/:tagId/edit', { tagId });
};
const questionLanding = (question_id: string) => {
return `/questions/${question_id}`;
const questionLanding = (questionId: string, title: string = '') => {
const { siteInfo } = siteInfoStore.getState();
if (siteInfo.permalink === 1) {
title = title.toLowerCase();
title = title.trim().replace(/\s+/g, '-');
title = title.replace(Pattern.emoji, '');
if (title) {
return urlcat('/questions/:questionId/:slugPermalink', {
questionId,
slugPermalink: title,
});
}
}
return urlcat('/questions/:questionId', { questionId });
};
const answerLanding = (question_id: string, answer_id: string) => {
return `/questions/${question_id}/${answer_id}`;
const answerLanding = (params: {
questionId: string;
questionTitle?: string;
answerId: string;
}) => {
const questionLandingUrl = questionLanding(
params.questionId,
params.questionTitle,
);
return urlcat(`${questionLandingUrl}/:answerId`, {
answerId: params.answerId,
});
};
export const pathFactory = {

View File

@ -33,14 +33,6 @@ const routes: RouteNode[] = [
path: 'questions',
page: 'pages/Questions',
},
{
path: 'questions/:qid',
page: 'pages/Questions/Detail',
},
{
path: 'questions/:qid/:aid',
page: 'pages/Questions/Detail',
},
{
path: 'questions/ask',
page: 'pages/Questions/Ask',
@ -48,6 +40,18 @@ const routes: RouteNode[] = [
return guard.activated();
},
},
{
path: 'questions/:qid',
page: 'pages/Questions/Detail',
},
{
path: 'questions/:qid/:slugPermalink',
page: 'pages/Questions/Detail',
},
{
path: 'questions/:qid/:slugPermalink/:aid',
page: 'pages/Questions/Detail',
},
{
path: 'posts/:qid/edit',
page: 'pages/Questions/Ask',
@ -269,6 +273,10 @@ const routes: RouteNode[] = [
path: 'write',
page: 'pages/Admin/Write',
},
{
path: 'seo',
page: 'pages/Admin/Seo',
},
],
},
// for review

View File

@ -101,3 +101,11 @@ export const getLegalSetting = () => {
export const putLegalSetting = (params: Type.AdminSettingsLegal) => {
return request.put('/answer/admin/api/siteinfo/legal', params);
};
export const getSeoSetting = () => {
return request.get<Type.AdminSettingsSeo>('/answer/admin/api/siteinfo/seo');
};
export const putSeoSetting = (params: Type.AdminSettingsSeo) => {
return request.put('/answer/admin/api/siteinfo/seo', params);
};

View File

@ -248,3 +248,7 @@ export const changeEmailVerify = (params: { code: string }) => {
export const getAppSettings = () => {
return request.get<Type.SiteSettings>('/answer/api/v1/siteinfo');
};
export const reopenQuestion = (params: { question_id: string }) => {
return request.put('/answer/api/v1/question/reopen', params);
};

27
ui/src/stores/headInfo.ts Normal file
View File

@ -0,0 +1,27 @@
import create from 'zustand';
import { HeadInfo } from '@/common/interface';
interface HeadInfoType {
headInfo: HeadInfo;
update: (params: HeadInfo) => void;
}
const headInfo = create<HeadInfoType>((set) => ({
headInfo: {
title: '',
description: '',
keywords: '',
},
update: (params) =>
set((state) => {
return {
headInfo: {
...state.headInfo,
...params,
},
};
}),
}));
export default headInfo;

View File

@ -4,6 +4,7 @@ import globalStore from './global';
import siteInfoStore from './siteInfo';
import interfaceStore from './interface';
import brandingStore from './branding';
import headInfoStore from './headInfo';
export {
toastStore,
@ -12,4 +13,5 @@ export {
siteInfoStore,
interfaceStore,
brandingStore,
headInfoStore,
};

View File

@ -14,11 +14,16 @@ const siteInfo = create<SiteInfoType>((set) => ({
short_description: '',
site_url: '',
contact_email: '',
permalink: 1,
},
update: (params) =>
set(() => {
set((_) => {
const o = { ..._.siteInfo, ...params };
if (o.permalink !== 1 && o.permalink !== 2) {
o.permalink = 1;
}
return {
siteInfo: params,
siteInfo: o,
};
}),
}));