feat(permalink): permalink and permalink general setting done

This commit is contained in:
haitao(lj) 2022-12-05 16:42:08 +08:00
parent 1a12be4fd1
commit ceb8f099bf
27 changed files with 184 additions and 81 deletions

View File

@ -279,7 +279,12 @@ export interface AdminSettingsGeneral {
description: string;
site_url: string;
contact_email: string;
permalink: boolean;
/**
* 0: not set
* 1with title
* 2: no title
*/
permalink: number;
}
export interface HeadInfo {

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

@ -19,6 +19,7 @@ import { useEditStatusModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useAnswerSearch, changeAnswerStatus } from '@/services';
import { escapeRemove } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
import '../index.scss';
@ -129,7 +130,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,6 +14,7 @@ import { useReportModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useFlagSearch } from '@/services';
import { escapeRemove } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
import '../index.scss';
@ -101,7 +102,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

@ -49,11 +49,12 @@ const General: FC = () => {
description: t('contact_email.text'),
},
permalink: {
type: 'boolean',
type: 'number',
title: t('permalink.label'),
description: t('permalink.text'),
enum: [true, false],
enum: [1, 2],
enumNames: ['/questions/123/post-title', '/questions/123'],
default: 1,
},
},
};
@ -100,7 +101,6 @@ const General: FC = () => {
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
);
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
@ -110,7 +110,7 @@ const General: FC = () => {
short_description: formData.short_description.value,
site_url: formData.site_url.value,
contact_email: formData.contact_email.value,
permalink: formData.permalink.value,
permalink: Number(formData.permalink.value),
};
updateGeneralSetting(reqParams)
@ -133,10 +133,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

@ -18,6 +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 { pathFactory } from '@/router/pathFactory';
import '../index.scss';
@ -138,7 +139,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

@ -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(() => {
navigate(`/questions/${qid}`);
navigate(pathFactory.questionLanding(qid, params.title));
})
.catch((err) => {
if (err.isError) {
@ -260,7 +261,7 @@ const Ask = () => {
html: editorRef2.current.getHtml(),
})
.then(() => {
navigate(`/questions/${id}`);
navigate(pathFactory.questionLanding(id, params.title));
})
.catch((err) => {
if (err.isError) {
@ -269,7 +270,7 @@ const Ask = () => {
}
});
} else {
navigate(`/questions/${id}`);
navigate(pathFactory.questionLanding(id, params.title));
}
}
}

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

@ -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,10 +111,15 @@ const Ask = () => {
edit_summary: formData.description.value,
};
modifyAnswer(params).then(() => {
navigate(`/questions/${qid}/${aid}`);
navigate(
pathFactory.answerLanding({
questionId: qid,
questionTitle: data?.question?.title,
answerId: aid,
}),
);
});
};
const handleSelectedRevision = (e) => {
const index = e.target.value;
const revision = revisions[index];
@ -136,7 +142,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

@ -105,18 +105,18 @@ const Index: FC = () => {
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,
info.title,
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}

View File

@ -91,6 +91,7 @@ const TagIntroduction = () => {
keyPrefix: 'page_title',
})}`;
}
return (
<>
<PageTitle title={pageTitle} />
@ -99,7 +100,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,15 +1,19 @@
import urlcat from 'urlcat';
import type * as Type from '@/common/interface';
import Pattern from '@/common/pattern';
import { siteInfoStore } from '@/stores';
const tagLanding = (tag: Type.Tag) => {
let slugName = tag.main_tag_slug_name || tag.slug_name || '';
const tagLanding = (slugName: string) => {
if (!slugName) {
return '/tags';
}
slugName = slugName.toLowerCase();
return urlcat('/tags/:slugName', { slugName });
};
const tagInfo = (slugName: string) => {
if (!slugName) {
return '/tags';
}
slugName = slugName.toLowerCase();
return urlcat('/tags/:slugName/info', { slugName });
};
@ -18,25 +22,31 @@ const tagEdit = (tagId: string) => {
};
const questionLanding = (questionId: string, title: string = '') => {
const { siteInfo } = siteInfoStore.getState();
if (siteInfo.permalink) {
if (siteInfo.permalink === 1) {
title = title.toLowerCase();
title = title.trim().replace(/\s+/g, '-');
title = title.replace(Pattern.emoji, '');
if (title) {
return urlcat('/questions/:questionId/:title', { questionId, title });
return urlcat('/questions/:questionId/:slugPermalink', {
questionId,
slugPermalink: title,
});
}
}
return urlcat('/questions/:questionId', { questionId });
};
const answerLanding = (
questionId: string,
questionTitle: string = '',
answerId: string,
) => {
const questionLandingUrl = questionLanding(questionId, questionTitle);
const answerLanding = (params: {
questionId: string;
questionTitle?: string;
answerId: string;
}) => {
const questionLandingUrl = questionLanding(
params.questionId,
params.questionTitle,
);
return urlcat(`${questionLandingUrl}/:answerId`, {
answerId,
answerId: params.answerId,
});
};

View File

@ -33,6 +33,17 @@ const routes: RouteNode[] = [
path: 'questions',
page: 'pages/Questions',
},
{
path: 'questions/ask',
page: 'pages/Questions/Ask',
guard: async () => {
return guard.activated();
},
},
{
path: 'questions/:qid',
page: 'pages/Questions/Detail',
},
{
path: 'questions/:qid/:slugPermalink',
page: 'pages/Questions/Detail',
@ -41,13 +52,6 @@ const routes: RouteNode[] = [
path: 'questions/:qid/:slugPermalink/:aid',
page: 'pages/Questions/Detail',
},
{
path: 'questions/ask',
page: 'pages/Questions/Ask',
guard: async () => {
return guard.activated();
},
},
{
path: 'posts/:qid/edit',
page: 'pages/Questions/Ask',

View File

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