diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 69a39c12..8e4cc014 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -270,6 +270,7 @@ ui: tag: Tag tags: Tags tag_wiki: tag wiki + create_tag: Create Tag edit_tag: Edit Tag ask_a_question: Add Question edit_question: Edit Question @@ -433,7 +434,7 @@ ui: range: Display name up to 35 characters. slug_name: label: URL Slug - desc: 'Must use the character set "a-z", "0-9", "+ # - ."' + desc: URL slug up to 35 characters. msg: empty: URL slug cannot be empty. range: URL slug up to 35 characters. @@ -442,6 +443,7 @@ ui: label: Description btn_cancel: Cancel btn_submit: Submit + btn_post: Post new tag tag_info: created_at: Created edited_at: Edited diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 1797813f..05103835 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -24,13 +24,13 @@ export interface ReportParams { export interface TagBase { display_name: string; slug_name: string; - recommend: boolean; - reserved: boolean; + original_text?: string; + recommend?: boolean; + reserved?: boolean; } export interface Tag extends TagBase { main_tag_slug_name?: string; - original_text?: string; parsed_text?: string; } @@ -101,6 +101,11 @@ export interface ModifyUserReq { website: string; } +enum RoleId { + User = 1, + Admin = 2, + Moderator = 3, +} export interface UserInfoBase { id?: string; avatar: any; @@ -114,7 +119,7 @@ export interface UserInfoBase { */ status?: string; /** roles */ - is_admin?: boolean; + role_id: RoleId; } export interface UserInfoRes extends UserInfoBase { @@ -127,7 +132,6 @@ export interface UserInfoRes extends UserInfoBase { */ mail_status: number; language: string; - is_admin: boolean; e_mail?: string; [prop: string]: any; } diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index 7e9403f5..5efa87c8 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -5,6 +5,7 @@ import { useNavigate, useMatch } from 'react-router-dom'; import classNames from 'classnames'; +import { floppyNavigation } from '@/utils'; import { Icon } from '@/components'; import './index.css'; @@ -101,10 +102,12 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { const [openKey, setOpenKey] = useState(getOpenKey()); const menuClick = (evt, menu, href, isLeaf) => { - evt.preventDefault(); evt.stopPropagation(); if (isLeaf) { - navigate(href); + if (floppyNavigation.shouldProcessLinkClick(evt)) { + evt.preventDefault(); + navigate(href); + } } else { setOpenKey(openKey === menu.name ? '' : menu.name); } diff --git a/ui/src/components/Header/components/NavItems/index.tsx b/ui/src/components/Header/components/NavItems/index.tsx index 4147cbbb..6f3cdf4d 100644 --- a/ui/src/components/Header/components/NavItems/index.tsx +++ b/ui/src/components/Header/components/NavItems/index.tsx @@ -63,7 +63,7 @@ const Index: FC = ({ redDot, userInfo, logOut }) => { onClick={handleLinkClick}> {t('header.nav.setting')} - {userInfo?.is_admin ? ( + {userInfo?.role_id === 2 ? ( {t('header.nav.admin')} diff --git a/ui/src/pages/Tags/Create/index.tsx b/ui/src/pages/Tags/Create/index.tsx new file mode 100644 index 00000000..b1bf1768 --- /dev/null +++ b/ui/src/pages/Tags/Create/index.tsx @@ -0,0 +1,220 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Container, Row, Col, Form, Button, Card } from 'react-bootstrap'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import classNames from 'classnames'; + +import { usePageTags, usePromptWithUnload } from '@/hooks'; +import { Editor, EditorRef } from '@/components'; +import { loggedUserInfoStore } from '@/stores'; +import type * as Type from '@/common/interface'; +import { createTag } from '@/services'; +import { handleFormError } from '@/utils'; + +interface FormDataItem { + displayName: Type.FormValue; + slugName: Type.FormValue; + description: Type.FormValue; +} +const initFormData = { + displayName: { + value: '', + isInvalid: false, + errorMsg: '', + }, + slugName: { + value: '', + isInvalid: false, + errorMsg: '', + }, + description: { + value: '', + isInvalid: false, + errorMsg: '', + }, +}; + +const Index = () => { + const { role_id = 1 } = loggedUserInfoStore((state) => state.user); + const navigate = useNavigate(); + const { t } = useTranslation('translation', { keyPrefix: 'tag_modal' }); + const [focusType, setForceType] = useState(''); + + const [formData, setFormData] = useState(initFormData); + const [immData] = useState(initFormData); + const [contentChanged, setContentChanged] = useState(false); + + const editorRef = useRef({ + getHtml: () => '', + }); + + usePromptWithUnload({ + when: contentChanged, + }); + + useEffect(() => { + const { displayName, slugName, description } = 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 + ) { + setContentChanged(true); + } else { + setContentChanged(false); + } + }, [ + formData.displayName.value, + formData.slugName.value, + formData.description.value, + ]); + + const handleDescriptionChange = (value: string) => + setFormData({ + ...formData, + description: { ...formData.description, value }, + }); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + setContentChanged(false); + const params = { + display_name: formData.displayName.value, + slug_name: formData.slugName.value, + original_text: formData.description.value, + }; + createTag(params) + .then((res) => { + navigate(`/tags/${res.slug_name}/info`, { + replace: true, + }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData, [ + { from: 'display_name', to: 'displayName' }, + { from: 'slug_name', to: 'slugName' }, + { from: 'original_text', to: 'description' }, + ]); + setFormData({ ...data }); + } + }); + }; + + const handleDisplayNameChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + displayName: { ...formData.displayName, value: e.currentTarget.value }, + }); + }; + + const handleSlugNameChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + slugName: { ...formData.slugName, value: e.currentTarget.value }, + }); + }; + + usePageTags({ + title: t('create_tag', { keyPrefix: 'page_title' }), + }); + return ( + + + +

{t('title')}

+ +
+ + +
+ + {t('form.fields.display_name.label')} + + + + {formData.displayName.errorMsg} + + + + {t('form.fields.slug_name.label')} + + {t('form.fields.slug_name.desc')} + + {formData.slugName.errorMsg} + + + + + {t('form.fields.desc.label')} + { + setForceType('description'); + }} + onBlur={() => { + setForceType(''); + }} + ref={editorRef} + /> + +
+ +
+
+ + + + + {t('title', { keyPrefix: 'how_to_format' })} + + + + +
+
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Tags/Edit/index.tsx b/ui/src/pages/Tags/Edit/index.tsx index 571796dd..96f3191a 100644 --- a/ui/src/pages/Tags/Edit/index.tsx +++ b/ui/src/pages/Tags/Edit/index.tsx @@ -42,7 +42,7 @@ const initFormData = { }; const Index = () => { - const { is_admin = false } = loggedUserInfoStore((state) => state.user); + const { role_id = 1 } = loggedUserInfoStore((state) => state.user); const { tagId } = useParams(); const navigate = useNavigate(); @@ -219,7 +219,7 @@ const Index = () => { @@ -232,7 +232,7 @@ const Index = () => { {t('form.fields.slug_name.info')} diff --git a/ui/src/pages/Tags/index.tsx b/ui/src/pages/Tags/index.tsx index a23aa8a0..c25e167d 100644 --- a/ui/src/pages/Tags/index.tsx +++ b/ui/src/pages/Tags/index.tsx @@ -1,6 +1,14 @@ import { useState } from 'react'; -import { Container, Row, Col, Card, Button, Form } from 'react-bootstrap'; -import { useSearchParams } from 'react-router-dom'; +import { + Container, + Row, + Col, + Card, + Button, + Form, + Stack, +} from 'react-bootstrap'; +import { useSearchParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { usePageTags } from '@/hooks'; @@ -8,6 +16,7 @@ import { Tag, Pagination, QueryGroup, TagsLoader } from '@/components'; import { formatCount } from '@/utils'; import { tryNormalLogged } from '@/utils/guard'; import { useQueryTags, following } from '@/services'; +import { loggedUserInfoStore } from '@/stores'; const sortBtns = ['popular', 'name', 'newest']; @@ -15,6 +24,7 @@ const Tags = () => { const [urlSearch] = useSearchParams(); const { t } = useTranslation('translation', { keyPrefix: 'tags' }); const [searchTag, setSearchTag] = useState(''); + const { role_id } = loggedUserInfoStore((_) => _.user); const page = Number(urlSearch.get('page')) || 1; const sort = urlSearch.get('sort'); @@ -55,17 +65,26 @@ const Tags = () => {

{t('title')}

-
- - - -
+ +
+ + + +
+ {role_id === 2 || role_id === 3 ? ( + + {t('title', { keyPrefix: 'tag_modal' })} + + ) : null} +
{ const { t } = useTranslation('translation', { keyPrefix: 'timeline' }); const { qid = '', aid = '', tid = '' } = useParams(); - const { is_admin } = loggedUserInfoStore((state) => state.user); + const { role_id } = loggedUserInfoStore((state) => state.user); const [showVotes, setShowVotes] = useState(false); const [isLoading, setLoading] = useState(false); const [timelineData, setTimelineData] = useState(); @@ -114,7 +114,7 @@ const Index: FC = () => { data={item} objectInfo={timelineData?.object_info} key={item.activity_id} - isAdmin={is_admin} + isAdmin={role_id === 2} revisionList={revisionList} /> ); diff --git a/ui/src/pages/Users/Personal/components/UserInfo/index.tsx b/ui/src/pages/Users/Personal/components/UserInfo/index.tsx index c31e376f..3ff91bbf 100644 --- a/ui/src/pages/Users/Personal/components/UserInfo/index.tsx +++ b/ui/src/pages/Users/Personal/components/UserInfo/index.tsx @@ -37,7 +37,7 @@ const Index: FC = ({ data }) => { ) : ( {data.display_name} )} - {data?.is_admin && ( + {data?.role_id === 2 && (
{ + return guard.isAdminOrModerator(); + }, + }, { path: 'tags/:tagName', page: 'pages/Tags/Detail', diff --git a/ui/src/services/client/tag.ts b/ui/src/services/client/tag.ts index b8540aee..bce03620 100644 --- a/ui/src/services/client/tag.ts +++ b/ui/src/services/client/tag.ts @@ -63,3 +63,8 @@ export const getTagsBySlugName = (slugNames: string) => { const apiUrl = `/answer/api/v1/tags?tags=${encodeURIComponent(slugNames)}`; return request.get(apiUrl); }; + +export const createTag = (params: Type.TagBase) => { + const apiUrl = '/answer/api/v1/tag'; + return request.post(apiUrl, params); +}; diff --git a/ui/src/stores/userInfo.ts b/ui/src/stores/userInfo.ts index b04ce32f..52976253 100644 --- a/ui/src/stores/userInfo.ts +++ b/ui/src/stores/userInfo.ts @@ -26,7 +26,7 @@ const initUser: UserInfoRes = { status: '', mail_status: 1, language: 'Default', - is_admin: false, + role_id: 1, }; const loggedUserInfoStore = create((set) => ({ diff --git a/ui/src/utils/floppyNavigation.ts b/ui/src/utils/floppyNavigation.ts index 41d8a9ac..b7f3bc89 100644 --- a/ui/src/utils/floppyNavigation.ts +++ b/ui/src/utils/floppyNavigation.ts @@ -43,8 +43,27 @@ const navigateToLogin = (callback?: Function) => { }); }; +/** + * Determine if a Link click event should be handled + */ +const shouldProcessLinkClick = (evt) => { + if (evt.defaultPrevented) { + return false; + } + const { target, nodeName } = evt.currentTarget; + if (nodeName.toLowerCase() !== 'a') { + return false; + } + return ( + evt.button === 0 && + (!target || target === '_self') && + !(evt.metaKey || evt.ctrlKey || evt.shiftKey || evt.altKey) + ); +}; + export const floppyNavigation = { differentCurrent, navigate, navigateToLogin, + shouldProcessLinkClick, }; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index aaa563fc..7eff5ad2 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -24,6 +24,7 @@ type TLoginState = { isForbidden: boolean; isNormal: boolean; isAdmin: boolean; + isModerator: boolean; }; export type TGuardResult = { @@ -40,6 +41,7 @@ export const deriveLoginState = (): TLoginState => { isForbidden: false, isNormal: false, isAdmin: false, + isModerator: false, }; const { user } = loggedUserInfoStore.getState(); if (user.access_token) { @@ -57,9 +59,12 @@ export const deriveLoginState = (): TLoginState => { if (ls.isActivated && !ls.isForbidden) { ls.isNormal = true; } - if (ls.isNormal && user.is_admin === true) { + if (ls.isNormal && user.role_id === 2) { ls.isAdmin = true; } + if (ls.isNormal && user.role_id === 3) { + ls.isModerator = true; + } return ls; }; @@ -174,6 +179,16 @@ export const admin = () => { return gr; }; +export const isAdminOrModerator = () => { + const gr = logged(); + const us = deriveLoginState(); + if (gr.ok && !us.isAdmin && !us.isModerator) { + gr.ok = false; + gr.redirect = RouteAlias.home; + } + return gr; +}; + export const allowNewRegistration = () => { const gr: TGuardResult = { ok: true }; const loginSetting = loginSettingStore.getState().login;