diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index cc30f6eac..776207c29 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -915,7 +915,7 @@ ui: msg: empty: File cannot be empty. only_image: Only image files are allowed. - max_size: File size cannot exceed 4 MB. + max_size: File size cannot exceed {{size}} MB. desc: label: Description tab_url: Image URL @@ -957,6 +957,10 @@ ui: text: Table heading: Heading cell: Cell + file: + text: Attach files + not_supported: "Don’t support that file type. Try again with {{file_type}}." + max_size: "Attach files size cannot exceed {{size}} MB." close_modal: title: I am closing this post as... btn_cancel: Cancel @@ -1557,6 +1561,7 @@ ui: newest: Newest active: Active hot: Hot + frequent: Frequent recommend: Recommend score: Score unanswered: Unanswered @@ -2051,6 +2056,21 @@ ui: reserved_tags: label: Reserved tags text: "Reserved tags can only be used by moderator." + image_size: + label: Max image size (MB) + text: "The maximum image upload size." + attachment_size: + label: Max attachment size (MB) + text: "The maximum attachment files upload size." + image_megapixels: + label: Max image megapixels + text: "Maximum number of megapixels allowed for an image." + image_extensions: + label: Authorized image extensions + text: "A list of file extensions allowed for image display, separate with commas." + attachment_extensions: + label: Authorized attachment extensions + text: "A list of file extensions allowed for upload, separate with commas. WARNING: Allowing uploads may cause security issues." seo: page_title: SEO permalink: @@ -2252,6 +2272,7 @@ ui: discard_confirm: Are you sure you want to discard your draft? messages: post_deleted: This post has been deleted. + post_cancel_deleted: This post has been undeleted. post_pin: This post has been pinned. post_unpin: This post has been unpinned. post_hide_list: This post has been hidden from list. @@ -2260,3 +2281,15 @@ ui: post_list: This post has been listed. post_unlist: This post has been unlisted. post_pending: Your post is awaiting review. This is a preview, it will be visible after it has been approved. + post_closed: This post has been closed. + answer_deleted: This answer has been deleted. + answer_cancel_deleted: This answer has been undeleted. + change_user_role: This user's role has been changed. + user_inactive: This user is already inactive. + user_normal: This user is already normal. + user_suspended: This user has been suspended. + user_deleted: This user has been deleted. + badge_activated: This badge has been activated. + badge_inactivated: This badge has been inactivated. + + diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 903c5c284..29967e0f8 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -173,7 +173,7 @@ export interface UserInfoRes extends UserInfoBase { [prop: string]: any; } -export type UploadType = 'post' | 'avatar' | 'branding'; +export type UploadType = 'post' | 'avatar' | 'branding' | 'post_attachment'; export interface UploadReq { file: FormData; } @@ -301,7 +301,8 @@ export type QuestionOrderBy = | 'active' | 'hot' | 'score' - | 'unanswered'; + | 'unanswered' + | 'frequent'; export interface QueryQuestionsReq extends Paging { order: QuestionOrderBy; @@ -439,6 +440,11 @@ export interface AdminSettingsWrite { recommend_tags?: Tag[]; required_tag?: boolean; reserved_tags?: Tag[]; + max_image_size?: number; + max_attachment_size?: number; + max_image_megapixel?: number; + authorized_image_extensions?: string[]; + authorized_attachment_extensions?: string[]; } export interface AdminSettingsSeo { diff --git a/ui/src/components/Editor/ToolBars/file.tsx b/ui/src/components/Editor/ToolBars/file.tsx new file mode 100644 index 000000000..30544d3e5 --- /dev/null +++ b/ui/src/components/Editor/ToolBars/file.tsx @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, memo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Modal as AnswerModal } from '@/components'; +import ToolItem from '../toolItem'; +import { IEditorContext, Editor } from '../types'; +import { uploadImage } from '@/services'; +import { writeSettingStore } from '@/stores'; + +let context: IEditorContext; +const Image = ({ editorInstance }) => { + const { t } = useTranslation('translation', { keyPrefix: 'editor' }); + const { max_attachment_size = 8, authorized_attachment_extensions = [] } = + writeSettingStore((state) => state.write); + const fileInputRef = useRef(null); + const [editor, setEditor] = useState(editorInstance); + + const item = { + label: 'paperclip', + tip: `${t('file.text')}`, + }; + + const addLink = (ctx) => { + context = ctx; + setEditor(context.editor); + fileInputRef.current?.click?.(); + }; + + const verifyFileSize = (files: FileList) => { + if (files.length === 0) { + return false; + } + const unSupportFiles = Array.from(files).filter((file) => { + const fileName = file.name.toLowerCase(); + return !authorized_attachment_extensions.find((v) => + fileName.endsWith(v), + ); + }); + + if (unSupportFiles.length > 0) { + AnswerModal.confirm({ + content: t('file.not_supported', { + file_type: authorized_attachment_extensions.join(', '), + }), + showCancel: false, + }); + return false; + } + + const attachmentOverSizeFiles = Array.from(files).filter( + (file) => file.size / 1024 / 1024 > max_attachment_size, + ); + if (attachmentOverSizeFiles.length > 0) { + AnswerModal.confirm({ + content: t('file.max_size', { size: max_attachment_size }), + showCancel: false, + }); + return false; + } + + return true; + }; + + const onUpload = async (e) => { + if (!editor) { + return; + } + const files = e.target?.files || []; + const bool = verifyFileSize(files); + + if (!bool) { + return; + } + const fileName = files[0].name; + const loadingText = `![${t('image.uploading')} ${fileName}...]()`; + const startPos = editor.getCursor(); + + const endPos = { ...startPos, ch: startPos.ch + loadingText.length }; + editor.replaceSelection(loadingText); + editor.setReadOnly(true); + + uploadImage({ file: e.target.files[0], type: 'post_attachment' }) + .then((url) => { + const text = `[${fileName}](${url})`; + editor.replaceRange('', startPos, endPos); + editor.replaceSelection(text); + }) + .finally(() => { + editor.setReadOnly(false); + editor.focus(); + }); + }; + + if (!authorized_attachment_extensions.length) { + return null; + } + + return ( + + + + ); +}; + +export default memo(Image); diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx index 4f72e4e1a..fa6609443 100644 --- a/ui/src/components/Editor/ToolBars/image.tsx +++ b/ui/src/components/Editor/ToolBars/image.tsx @@ -25,11 +25,18 @@ import { Modal as AnswerModal } from '@/components'; import ToolItem from '../toolItem'; import { IEditorContext, Editor } from '../types'; import { uploadImage } from '@/services'; +import { writeSettingStore } from '@/stores'; let context: IEditorContext; const Image = ({ editorInstance }) => { const [editor, setEditor] = useState(editorInstance); const { t } = useTranslation('translation', { keyPrefix: 'editor' }); + const { + max_image_size = 4, + max_attachment_size = 8, + authorized_image_extensions = [], + authorized_attachment_extensions = [], + } = writeSettingStore((state) => state.write); const loadingText = `![${t('image.uploading')}...]()`; @@ -52,41 +59,85 @@ const Image = ({ editorInstance }) => { isInvalid: false, errorMsg: '', }); + const verifyImageSize = (files: FileList) => { if (files.length === 0) { return false; } - const filteredFiles = Array.from(files).filter( - (file) => file.type.indexOf('image') === -1, - ); - if (filteredFiles.length > 0) { + /** + * When allowing attachments to be uploaded, verification logic for attachment information has been added. In order to avoid abnormal judgment caused by the order of drag and drop upload, the drag and drop upload verification of attachments and the drag and drop upload of images are put together. + * + */ + const canUploadAttachment = authorized_attachment_extensions.length > 0; + const allowedAllType = [ + ...authorized_image_extensions, + ...authorized_attachment_extensions, + ]; + const unSupportFiles = Array.from(files).filter((file) => { + const fileName = file.name.toLowerCase(); + return canUploadAttachment + ? !allowedAllType.find((v) => fileName.endsWith(v)) + : file.type.indexOf('image') === -1; + }); + + if (unSupportFiles.length > 0) { AnswerModal.confirm({ - content: t('image.form_image.fields.file.msg.only_image'), + content: canUploadAttachment + ? t('file.not_supported', { file_type: allowedAllType.join(', ') }) + : t('image.form_image.fields.file.msg.only_image'), + showCancel: false, }); return false; } - const filteredImages = Array.from(files).filter( - (file) => file.size / 1024 / 1024 > 4, - ); - if (filteredImages.length > 0) { + const otherFiles = Array.from(files).filter((file) => { + return file.type.indexOf('image') === -1; + }); + + if (canUploadAttachment && otherFiles.length > 0) { + const attachmentOverSizeFiles = otherFiles.filter( + (file) => file.size / 1024 / 1024 > max_attachment_size, + ); + if (attachmentOverSizeFiles.length > 0) { + AnswerModal.confirm({ + content: t('file.max_size', { size: max_attachment_size }), + showCancel: false, + }); + return false; + } + } + + const imageFiles = Array.from(files).filter( + (file) => file.type.indexOf('image') > -1, + ); + const oversizedImages = imageFiles.filter( + (file) => file.size / 1024 / 1024 > max_image_size, + ); + if (oversizedImages.length > 0) { AnswerModal.confirm({ - content: t('image.form_image.fields.file.msg.max_size'), + content: t('image.form_image.fields.file.msg.max_size', { + size: max_image_size, + }), + showCancel: false, }); return false; } + return true; }; + const upload = ( files: FileList, - ): Promise<{ url: string; name: string }[]> => { + ): Promise<{ url: string; name: string; type: string }[]> => { const promises = Array.from(files).map(async (file) => { - const url = await uploadImage({ file, type: 'post' }); + const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment'; + const url = await uploadImage({ file, type }); return { name: file.name, url, + type, }; }); @@ -103,7 +154,6 @@ const Image = ({ editorInstance }) => { } const drop = async (e) => { const fileList = e.dataTransfer.files; - const bool = verifyImageSize(fileList); if (!bool) { @@ -122,9 +172,9 @@ const Image = ({ editorInstance }) => { const text: string[] = []; if (Array.isArray(urls)) { - urls.forEach(({ name, url }) => { + urls.forEach(({ name, url, type }) => { if (name && url) { - text.push(`![${name}](${url})`); + text.push(`${type === 'post' ? '!' : ''}[${name}](${url})`); } }); } @@ -150,8 +200,8 @@ const Image = ({ editorInstance }) => { editor.replaceSelection(loadingText); editor.setReadOnly(true); const urls = await upload(clipboard.files); - const text = urls.map(({ name, url }) => { - return `![${name}](${url})`; + const text = urls.map(({ name, url, type }) => { + return `${type === 'post' ? '!' : ''}[${name}](${url})`; }); editor.replaceRange(text.join('\n'), startPos, endPos); @@ -252,6 +302,7 @@ const Image = ({ editorInstance }) => { uploadImage({ file: e.target.files[0], type: 'post' }).then((url) => { setLink({ ...link, value: url }); + setImageName({ ...imageName, value: files[0].name }); }); }; @@ -283,6 +334,7 @@ const Image = ({ editorInstance }) => { type="file" onChange={onUpload} isInvalid={currentTab === 'localImage' && link.isInvalid} + accept="image/*" /> diff --git a/ui/src/components/Editor/ToolBars/index.ts b/ui/src/components/Editor/ToolBars/index.ts index ce04587da..05912bc6e 100644 --- a/ui/src/components/Editor/ToolBars/index.ts +++ b/ui/src/components/Editor/ToolBars/index.ts @@ -32,6 +32,7 @@ import BlockQuote from './blockquote'; import Image from './image'; import Help from './help'; import Chart from './chart'; +import File from './file'; export { Table, @@ -49,4 +50,5 @@ export { Image, Help, Chart, + File, }; diff --git a/ui/src/components/Editor/index.tsx b/ui/src/components/Editor/index.tsx index ead37653d..45919b2c9 100644 --- a/ui/src/components/Editor/index.tsx +++ b/ui/src/components/Editor/index.tsx @@ -45,6 +45,7 @@ import { Outdent, Table, UL, + File, } from './ToolBars'; import { htmlRender, useEditor } from './utils'; import Viewer from './Viewer'; @@ -130,6 +131,7 @@ const MDEditor: ForwardRefRenderFunction = (
+
    diff --git a/ui/src/components/Editor/toolItem.tsx b/ui/src/components/Editor/toolItem.tsx index 0c4ca2f10..e7b218670 100644 --- a/ui/src/components/Editor/toolItem.tsx +++ b/ui/src/components/Editor/toolItem.tsx @@ -93,6 +93,7 @@ const ToolItem: FC = (props) => { disabled={disable} tabIndex={-1} onClick={(e) => { + console.log('onClick', e); e.preventDefault(); onClick?.({ editor, diff --git a/ui/src/components/QueryGroup/index.tsx b/ui/src/components/QueryGroup/index.tsx index 9b37c6e9c..7f6a0f3ea 100644 --- a/ui/src/components/QueryGroup/index.tsx +++ b/ui/src/components/QueryGroup/index.tsx @@ -35,8 +35,8 @@ interface Props { className?: string; pathname?: string; wrapClassName?: string; + maxBtnCount?: number; } -const MAX_BUTTON_COUNT = 3; const Index: FC = ({ data = [], currentSort = '', @@ -45,6 +45,7 @@ const Index: FC = ({ className = '', pathname = '', wrapClassName = '', + maxBtnCount = 3, }) => { const [searchParams, setUrlSearchParams] = useSearchParams(); const navigate = useNavigate(); @@ -71,79 +72,94 @@ const Index: FC = ({ } } }; - - const filteredData = data.filter((_, index) => index > MAX_BUTTON_COUNT - 2); - const currentBtn = filteredData.find((btn) => { + const moreBtnData = data.length > 4 ? data.slice(maxBtnCount) : []; + const normalBtnData = data.length > 4 ? data.slice(0, maxBtnCount) : data; + const currentBtn = moreBtnData.find((btn) => { return (typeof btn === 'string' ? btn : btn.name) === currentSort; }); + return ( - - {data.map((btn, index) => { - const key = typeof btn === 'string' ? btn : btn.sort; - const name = typeof btn === 'string' ? btn : btn.name; - return ( - + ); + })} + {moreBtnData.length > 0 && ( + + {moreBtnData.map((btn) => { + const key = typeof btn === 'string' ? btn : btn.sort; + const name = typeof btn === 'string' ? btn : btn.name; + return ( + handleClick(evt, key)}> - {t(name)} - - ); - })} - {data.length > MAX_BUTTON_COUNT && ( - - {filteredData.map((btn) => { - const key = typeof btn === 'string' ? btn : btn.sort; - const name = typeof btn === 'string' ? btn : btn.name; - return ( - handleClick(evt, key)}> - {t(name)} - - ); - })} - - )} - + onClick={(evt) => handleClick(evt, key)}> + {t(name)} + + ); + })} + + )} + + + {data.map((btn) => { + const key = typeof btn === 'string' ? btn : btn.sort; + const name = typeof btn === 'string' ? btn : btn.name; + return ( + handleClick(evt, key)}> + {t(name)} + + ); + })} + + ); }; diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index 82baea6a9..c849de56e 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -40,10 +40,10 @@ import { useSkeletonControl } from '@/hooks'; export const QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [ 'newest', 'active', - 'hot', - 'score', 'unanswered', 'recommend', + 'frequent', + 'score', ]; interface Props { source: 'questions' | 'tag' | 'linked'; @@ -83,6 +83,7 @@ const QuestionList: FC = ({ currentSort={curOrder} pathname={source === 'questions' ? '/questions' : ''} i18nKeyPrefix="question" + maxBtnCount={source === 'tag' ? 3 : 4} />
diff --git a/ui/src/pages/Admin/Answers/components/Action/index.tsx b/ui/src/pages/Admin/Answers/components/Action/index.tsx index 7dc3bb33b..81e21813d 100644 --- a/ui/src/pages/Admin/Answers/components/Action/index.tsx +++ b/ui/src/pages/Admin/Answers/components/Action/index.tsx @@ -23,6 +23,7 @@ import { Link } from 'react-router-dom'; import { Icon, Modal } from '@/components'; import { changeAnswerStatus } from '@/services'; +import { toastStore } from '@/stores'; const AnswerActions = ({ itemData, curFilter, refreshList }) => { const { t } = useTranslation('translation', { keyPrefix: 'delete' }); @@ -37,6 +38,10 @@ const AnswerActions = ({ itemData, curFilter, refreshList }) => { confirmText: t('delete', { keyPrefix: 'btns' }), onConfirm: () => { changeAnswerStatus(itemData.id, 'deleted').then(() => { + toastStore.getState().show({ + msg: t('answer_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); refreshList(); }); }, @@ -52,6 +57,10 @@ const AnswerActions = ({ itemData, curFilter, refreshList }) => { confirmText: t('undelete', { keyPrefix: 'btns' }), onConfirm: () => { changeAnswerStatus(itemData.id, 'available').then(() => { + toastStore.getState().show({ + msg: t('answer_cancel_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); refreshList(); }); }, diff --git a/ui/src/pages/Admin/Badges/index.tsx b/ui/src/pages/Admin/Badges/index.tsx index 99bafb432..63b8f348d 100644 --- a/ui/src/pages/Admin/Badges/index.tsx +++ b/ui/src/pages/Admin/Badges/index.tsx @@ -27,6 +27,7 @@ import classNames from 'classnames'; import { Empty, Icon, Pagination, QueryGroup } from '@/components'; import * as Type from '@/common/interface'; import { useQueryBadges, updateBadgeStatus } from '@/services/admin/badges'; +import { useToast } from '@/hooks'; import Action from './components/Action'; @@ -46,6 +47,7 @@ const Badges: FC = () => { const curPage = Number(urlSearchParams.get('page') || '1'); const curFilter = urlSearchParams.get('filter') || BadgeFilterKeys[0]; const curQuery = urlSearchParams.get('query') || ''; + const Toast = useToast(); const { data, isLoading, mutate } = useQueryBadges({ page: curPage, @@ -62,6 +64,13 @@ const Badges: FC = () => { const handleBadgeStatus = (badgeId, status) => { updateBadgeStatus({ id: badgeId, status }).then(() => { + Toast.onShow({ + msg: + status === 'inactive' + ? t('badge_inactivated', { keyPrefix: 'messages' }) + : t('badge_activated', { keyPrefix: 'messages' }), + variant: 'success', + }); mutate(); }); }; diff --git a/ui/src/pages/Admin/Questions/components/Action/index.tsx b/ui/src/pages/Admin/Questions/components/Action/index.tsx index 0dc1fefa3..560d6e698 100644 --- a/ui/src/pages/Admin/Questions/components/Action/index.tsx +++ b/ui/src/pages/Admin/Questions/components/Action/index.tsx @@ -28,11 +28,19 @@ import { reopenQuestion, } from '@/services'; import { useReportModal, useToast } from '@/hooks'; +import { toastStore } from '@/stores'; const AnswerActions = ({ itemData, refreshList, curFilter, show, pin }) => { const { t } = useTranslation('translation', { keyPrefix: 'delete' }); - const closeModal = useReportModal(refreshList); const toast = useToast(); + const closeCallback = () => { + toastStore.getState().show({ + msg: t('post_closed', { keyPrefix: 'messages' }), + variant: 'success', + }); + refreshList(); + }; + const closeModal = useReportModal(closeCallback); const handleAction = (type) => { if (type === 'delete') { @@ -47,6 +55,10 @@ const AnswerActions = ({ itemData, refreshList, curFilter, show, pin }) => { confirmText: t('delete', { keyPrefix: 'btns' }), onConfirm: () => { changeQuestionStatus(itemData.id, 'deleted').then(() => { + toastStore.getState().show({ + msg: t('post_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); refreshList(); }); }, @@ -62,6 +74,10 @@ const AnswerActions = ({ itemData, refreshList, curFilter, show, pin }) => { confirmText: t('undelete', { keyPrefix: 'btns' }), onConfirm: () => { changeQuestionStatus(itemData.id, 'available').then(() => { + toastStore.getState().show({ + msg: t('post_cancel_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); refreshList(); }); }, @@ -86,7 +102,7 @@ const AnswerActions = ({ itemData, refreshList, curFilter, show, pin }) => { reopenQuestion({ question_id: itemData.id, }).then(() => { - toast.onShow({ + toastStore.getState().show({ msg: t('post_reopen', { keyPrefix: 'messages' }), variant: 'success', }); diff --git a/ui/src/pages/Admin/Users/components/Action/index.tsx b/ui/src/pages/Admin/Users/components/Action/index.tsx index fa79c24df..9f0847a21 100644 --- a/ui/src/pages/Admin/Users/components/Action/index.tsx +++ b/ui/src/pages/Admin/Users/components/Action/index.tsx @@ -33,6 +33,7 @@ import { changeUserStatus, updateUserProfile, } from '@/services'; +import { toastStore } from '@/stores'; interface Props { showActionPassword?: boolean; @@ -57,7 +58,13 @@ const UserOperation = ({ const Toast = useToast(); const changeUserRoleModal = useChangeUserRoleModal({ - callback: refreshUsers, + callback: () => { + Toast.onShow({ + msg: t('change_user_role', { keyPrefix: 'messages' }), + variant: 'success', + }); + refreshUsers?.(); + }, }); const changePasswordModal = useChangePasswordModal({ onConfirm: (rd) => { @@ -107,6 +114,10 @@ const UserOperation = ({ user_id: userData.user_id, status: statusType, }).then(() => { + toastStore.getState().show({ + msg: t(`user_${statusType}`, { keyPrefix: 'messages' }), + variant: 'success', + }); refreshUsers?.(); // onClose(); }); diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index cfabd20a6..40152200d 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -33,6 +33,7 @@ import { } from '@/components'; import * as Type from '@/common/interface'; import { useUserModal } from '@/hooks'; +import { toastStore, loggedUserInfoStore, userCenterStore } from '@/stores'; import { useQueryUsers, addUsers, @@ -40,7 +41,6 @@ import { AdminUcAgent, changeUserStatus, } from '@/services'; -import { loggedUserInfoStore, userCenterStore } from '@/stores'; import { formatCount } from '@/utils'; import DeleteUserModal from './components/DeleteUserModal'; @@ -139,6 +139,10 @@ const Users: FC = () => { status: 'deleted', remove_all_content: val, }).then(() => { + toastStore.getState().show({ + msg: t('user_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); changeDeleteUserModalState({ show: false, userId: '', diff --git a/ui/src/pages/Admin/Write/index.tsx b/ui/src/pages/Admin/Write/index.tsx index 442c58f61..fcf99c1de 100644 --- a/ui/src/pages/Admin/Write/index.tsx +++ b/ui/src/pages/Admin/Write/index.tsx @@ -52,6 +52,31 @@ const initFormData = { errorMsg: '', isInvalid: false, }, + max_image_size: { + value: 4, + errorMsg: '', + isInvalid: false, + }, + max_attachment_size: { + value: 8, + errorMsg: '', + isInvalid: false, + }, + max_image_megapixel: { + value: 40, + errorMsg: '', + isInvalid: false, + }, + authorized_image_extensions: { + value: 'jpg, jpeg, png, gif, webp', + errorMsg: '', + isInvalid: false, + }, + authorized_attachment_extensions: { + value: '', + errorMsg: '', + isInvalid: false, + }, }; const Index: FC = () => { @@ -111,6 +136,18 @@ const Index: FC = () => { reserved_tags: formData.reserved_tags.value, required_tag: formData.required_tag.value, restrict_answer: formData.restrict_answer.value, + max_image_size: Number(formData.max_image_size.value), + max_attachment_size: Number(formData.max_attachment_size.value), + max_image_megapixel: Number(formData.max_image_megapixel.value), + authorized_image_extensions: formData.authorized_image_extensions.value + .split(',') + ?.map((item) => item.trim().toLowerCase()), + authorized_attachment_extensions: + formData.authorized_attachment_extensions.value.length > 0 + ? formData.authorized_attachment_extensions.value + .split(',') + ?.map((item) => item.trim().toLowerCase()) + : [], }; postRequireAndReservedTag(reqParams) .then(() => { @@ -120,7 +157,7 @@ const Index: FC = () => { }); writeSettingStore .getState() - .update({ restrict_answer: reqParams.restrict_answer }); + .update({ restrict_answer: reqParams.restrict_answer, ...reqParams }); }) .catch((err) => { if (err.isError) { @@ -142,6 +179,13 @@ const Index: FC = () => { if (Array.isArray(res.reserved_tags)) { formData.reserved_tags.value = res.reserved_tags; } + formData.max_image_size.value = res.max_image_size; + formData.max_attachment_size.value = res.max_attachment_size; + formData.max_image_megapixel.value = res.max_image_megapixel; + formData.authorized_image_extensions.value = + res.authorized_image_extensions?.join(', ').toLowerCase(); + formData.authorized_attachment_extensions.value = + res.authorized_attachment_extensions?.join(', ').toLowerCase(); setFormData({ ...formData }); }); }; @@ -243,6 +287,111 @@ const Index: FC = () => { + + {t('image_size.label')} + { + handleValueChange({ + max_image_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('image_size.text')} + + {formData.max_image_size.errorMsg} + + + + + {t('attachment_size.label')} + { + handleValueChange({ + max_attachment_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('attachment_size.text')} + + {formData.max_attachment_size.errorMsg} + + + + + {t('image_megapixels.label')} + { + handleValueChange({ + max_image_megapixel: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('image_megapixels.text')} + + {formData.max_image_megapixel.errorMsg} + + + + + {t('image_extensions.label')} + { + handleValueChange({ + authorized_image_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('image_extensions.text')} + + {formData.authorized_image_extensions.errorMsg} + + + + + {t('attachment_extensions.label')} + { + handleValueChange({ + authorized_attachment_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('attachment_extensions.text')} + + {formData.authorized_attachment_extensions.errorMsg} + + + diff --git a/ui/src/pages/Tags/Detail/index.tsx b/ui/src/pages/Tags/Detail/index.tsx index 41b190226..8bfd5ee8c 100644 --- a/ui/src/pages/Tags/Detail/index.tsx +++ b/ui/src/pages/Tags/Detail/index.tsx @@ -186,7 +186,7 @@ const Index: FC = () => { source="tag" data={listData} order={curOrder} - orderList={QUESTION_ORDER_KEYS.slice(0, 5)} + orderList={QUESTION_ORDER_KEYS.filter((k) => k !== 'recommend')} isLoading={listLoading} /> diff --git a/ui/src/stores/writeSetting.ts b/ui/src/stores/writeSetting.ts index 8e7c1f525..f3a5613c5 100644 --- a/ui/src/stores/writeSetting.ts +++ b/ui/src/stores/writeSetting.ts @@ -32,6 +32,11 @@ const Index = create((set) => ({ recommend_tags: [], required_tag: false, reserved_tags: [], + max_image_size: 4, + max_attachment_size: 8, + max_image_megapixel: 40, + authorized_image_extensions: [], + authorized_attachment_extensions: [], }, update: (params) => set((state) => { diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index 5f2e7b525..f3ba42359 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -408,9 +408,10 @@ export const initAppSettingsStore = async () => { customizeStore.getState().update(appSettings.custom_css_html); themeSettingStore.getState().update(appSettings.theme); seoSettingStore.getState().update(appSettings.site_seo); - writeSettingStore - .getState() - .update({ restrict_answer: appSettings.site_write.restrict_answer }); + writeSettingStore.getState().update({ + restrict_answer: appSettings.site_write.restrict_answer, + ...appSettings.site_write, + }); } };