diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index eb2edbbe..535fddf8 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1104,6 +1104,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: diff --git a/ui/package.json b/ui/package.json index e82f939b..12bcef86 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 7530de3b..cc92e65d 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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: diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 41f7e723..c3cd2918 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -279,6 +279,7 @@ export interface AdminSettingsGeneral { description: string; site_url: string; contact_email: string; + permalink: boolean; } export interface HeadInfo { diff --git a/ui/src/common/pattern.ts b/ui/src/common/pattern.ts index 406e5e3e..72086a68 100644 --- a/ui/src/common/pattern.ts +++ b/ui/src/common/pattern.ts @@ -1,4 +1,7 @@ +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,}))$/, }; diff --git a/ui/src/pages/Admin/General/index.tsx b/ui/src/pages/Admin/General/index.tsx index a052ab7d..7e7a1bc1 100644 --- a/ui/src/pages/Admin/General/index.tsx +++ b/ui/src/pages/Admin/General/index.tsx @@ -48,6 +48,13 @@ const General: FC = () => { title: t('contact_email.label'), description: t('contact_email.text'), }, + permalink: { + type: 'boolean', + title: t('permalink.label'), + description: t('permalink.text'), + enum: [true, false], + enumNames: ['/questions/123/post-title', '/questions/123'], + }, }, }; const uiSchema: UISchema = { @@ -63,7 +70,7 @@ const General: FC = () => { } if ( !url || - /^https?:$/.test(url.protocol) === false || + !/^https?:$/.test(url.protocol) || url.pathname !== '/' || url.search !== '' || url.hash !== '' @@ -86,6 +93,9 @@ const General: FC = () => { }, }, }, + permalink: { + 'ui:widget': 'select', + }, }; const [formData, setFormData] = useState( initFormData(schema), @@ -100,6 +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, }; updateGeneralSetting(reqParams) diff --git a/ui/src/pages/Questions/Detail/index.tsx b/ui/src/pages/Questions/Detail/index.tsx index 36546cb3..2cdc40c0 100644 --- a/ui/src/pages/Questions/Detail/index.tsx +++ b/ui/src/pages/Questions/Detail/index.tsx @@ -28,7 +28,6 @@ const Index = () => { const navigate = useNavigate(); const { qid = '', aid = '' } = useParams(); const [urlSearch] = useSearchParams(); - const page = Number(urlSearch.get('page') || 0); const order = urlSearch.get('order') || ''; const [question, setQuestion] = useState(null); diff --git a/ui/src/pages/Review/index.tsx b/ui/src/pages/Review/index.tsx index 132d433d..faa88516 100644 --- a/ui/src/pages/Review/index.tsx +++ b/ui/src/pages/Review/index.tsx @@ -100,7 +100,7 @@ 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'); @@ -108,6 +108,7 @@ const Index: FC = () => { itemLink = pathFactory.answerLanding( // @ts-ignore unreviewed_info.content.question_id, + info.title, unreviewed_info.object_id, ); itemTitle = info?.title; diff --git a/ui/src/router/pathFactory.ts b/ui/src/router/pathFactory.ts index 6d2725ce..e58f7e9a 100644 --- a/ui/src/router/pathFactory.ts +++ b/ui/src/router/pathFactory.ts @@ -1,22 +1,41 @@ +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 || ''; slugName = slugName.toLowerCase(); - return `/tags/${encodeURIComponent(slugName)}`; + return urlcat('/tags/:slugName', { slugName }); }; const tagInfo = (slugName: string) => { 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) { + title = title.toLowerCase(); + title = title.trim().replace(/\s+/g, '-'); + title = title.replace(Pattern.emoji, ''); + return urlcat('/questions/:questionId/:title', { questionId, title }); + } + + return urlcat('/questions/:questionId', { questionId }); }; -const answerLanding = (question_id: string, answer_id: string) => { - return `/questions/${question_id}/${answer_id}`; +const answerLanding = ( + questionId: string, + questionTitle: string = '', + answerId: string, +) => { + const questionLandingUrl = questionLanding(questionId, questionTitle); + return urlcat(`${questionLandingUrl}/:answerId`, { + answerId, + }); }; export const pathFactory = { diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 2780db2f..e281f558 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -1,5 +1,6 @@ import { LoaderFunctionArgs, RouteObject } from 'react-router-dom'; +import { siteInfoStore } from '@/stores'; import { guard } from '@/utils'; import type { TGuardResult } from '@/utils/guard'; @@ -15,7 +16,7 @@ export interface RouteNode extends RouteObject { */ guard?: (args: LoaderFunctionArgs) => Promise; } - +const { siteInfo } = siteInfoStore.getState(); const routes: RouteNode[] = [ { path: '/', @@ -34,11 +35,13 @@ const routes: RouteNode[] = [ page: 'pages/Questions', }, { - path: 'questions/:qid', + path: siteInfo.permalink ? 'questions/:qid/:title' : 'questions/:qid', page: 'pages/Questions/Detail', }, { - path: 'questions/:qid/:aid', + path: siteInfo.permalink + ? 'questions/:qid/:title/:aid' + : 'questions/:qid/:aid', page: 'pages/Questions/Detail', }, { diff --git a/ui/src/stores/siteInfo.ts b/ui/src/stores/siteInfo.ts index 14f3a273..7d5055ab 100644 --- a/ui/src/stores/siteInfo.ts +++ b/ui/src/stores/siteInfo.ts @@ -14,11 +14,13 @@ const siteInfo = create((set) => ({ short_description: '', site_url: '', contact_email: '', + permalink: true, }, update: (params) => - set(() => { + set((_) => { + const o = { ..._.siteInfo, ...params }; return { - siteInfo: params, + siteInfo: o, }; }), }));