This is an automated email from the ASF dual-hosted git repository. linkinstar pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/incubator-answer.git
commit bc20a71a527a503d07fa191f4f2cd8b1ff52704c Author: shuai <[email protected]> AuthorDate: Fri Nov 22 10:30:39 2024 +0800 feat: The editor has added support for uploading attachments, and the management background has added file upload configuration information. --- i18n/en_US.yaml | 22 +++- ui/src/common/interface.ts | 10 +- ui/src/components/Editor/ToolBars/file.tsx | 130 +++++++++++++++++++++++ ui/src/components/Editor/ToolBars/image.tsx | 86 ++++++++++++--- ui/src/components/Editor/ToolBars/index.ts | 2 + ui/src/components/Editor/index.tsx | 2 + ui/src/components/Editor/toolItem.tsx | 1 + ui/src/components/QueryGroup/index.tsx | 156 +++++++++++++++------------- ui/src/components/QuestionList/index.tsx | 5 +- ui/src/pages/Admin/Write/index.tsx | 151 ++++++++++++++++++++++++++- ui/src/pages/Tags/Detail/index.tsx | 2 +- ui/src/stores/writeSetting.ts | 5 + ui/src/utils/guard.ts | 7 +- 13 files changed, 482 insertions(+), 97 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 775d7456..776207c2 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: diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 903c5c28..29967e0f 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 00000000..30544d3e --- /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<HTMLInputElement>(null); + const [editor, setEditor] = useState<Editor>(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 ( + <ToolItem {...item} onClick={addLink}> + <input + type="file" + className="d-none" + accept={authorized_attachment_extensions.join(',.').toLocaleLowerCase()} + ref={fileInputRef} + onChange={onUpload} + /> + </ToolItem> + ); +}; + +export default memo(Image); diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx index 4f72e4e1..fa660944 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<Editor>(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(``); + 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 ``; + 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/*" /> <Form.Control.Feedback type="invalid"> diff --git a/ui/src/components/Editor/ToolBars/index.ts b/ui/src/components/Editor/ToolBars/index.ts index ce04587d..05912bc6 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 ead37653..45919b2c 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<EditorRef, Props> = ( <LinkItem /> <BlockQuote /> <Image editorInstance={editor} /> + <File editorInstance={editor} /> <Table /> <div className="toolbar-divider" /> <OL /> diff --git a/ui/src/components/Editor/toolItem.tsx b/ui/src/components/Editor/toolItem.tsx index 0c4ca2f1..e7b21867 100644 --- a/ui/src/components/Editor/toolItem.tsx +++ b/ui/src/components/Editor/toolItem.tsx @@ -93,6 +93,7 @@ const ToolItem: FC<IProps> = (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 9b37c6e9..7f6a0f3e 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<Props> = ({ data = [], currentSort = '', @@ -45,6 +45,7 @@ const Index: FC<Props> = ({ className = '', pathname = '', wrapClassName = '', + maxBtnCount = 3, }) => { const [searchParams, setUrlSearchParams] = useSearchParams(); const navigate = useNavigate(); @@ -71,79 +72,94 @@ const Index: FC<Props> = ({ } } }; - - 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 ( - <ButtonGroup size="sm" className={wrapClassName}> - {data.map((btn, index) => { - const key = typeof btn === 'string' ? btn : btn.sort; - const name = typeof btn === 'string' ? btn : btn.name; - return ( - <Button - key={key} - variant="outline-secondary" - active={currentSort === name} - className={classNames( - 'text-capitalize fit-content', - data.length > MAX_BUTTON_COUNT && - index > MAX_BUTTON_COUNT - 2 && - 'd-none d-md-block', - className, - )} - style={ - data.length > MAX_BUTTON_COUNT && index === data.length - 1 - ? { - borderTopRightRadius: '0.25rem', - borderBottomRightRadius: '0.25rem', + <> + <ButtonGroup + size="sm" + className={classNames('d-none d-sm-block', wrapClassName)}> + {normalBtnData.map((btn) => { + const key = typeof btn === 'string' ? btn : btn.sort; + const name = typeof btn === 'string' ? btn : btn.name; + return ( + <Button + key={key} + variant="outline-secondary" + active={currentSort === name} + className={classNames('text-capitalize fit-content', className)} + href={ + pathname + ? `${REACT_BASE_PATH}${pathname}${handleParams(key)}` + : handleParams(key) + } + onClick={(evt) => handleClick(evt, key)}> + {t(name)} + </Button> + ); + })} + {moreBtnData.length > 0 && ( + <DropdownButton + size="sm" + variant={currentBtn ? 'secondary' : 'outline-secondary'} + as={ButtonGroup} + title={currentBtn ? t(currentSort) : t('more')}> + {moreBtnData.map((btn) => { + const key = typeof btn === 'string' ? btn : btn.sort; + const name = typeof btn === 'string' ? btn : btn.name; + return ( + <Dropdown.Item + as="a" + key={key} + active={currentSort === name} + className={classNames('text-capitalize', className)} + href={ + pathname + ? `${REACT_BASE_PATH}${pathname}${handleParams(key)}` + : handleParams(key) } - : {} - } - href={ - pathname - ? `${REACT_BASE_PATH}${pathname}${handleParams(key)}` - : handleParams(key) - } - onClick={(evt) => handleClick(evt, key)}> - {t(name)} - </Button> - ); - })} - {data.length > MAX_BUTTON_COUNT && ( - <DropdownButton - size="sm" - variant={currentBtn ? 'secondary' : 'outline-secondary'} - className="d-block d-md-none" - as={ButtonGroup} - title={currentBtn ? t(currentSort) : t('more')}> - {filteredData.map((btn) => { - const key = typeof btn === 'string' ? btn : btn.sort; - const name = typeof btn === 'string' ? btn : btn.name; - return ( - <Dropdown.Item - as="a" - key={key} - active={currentSort === name} - className={classNames( - 'text-capitalize', - 'd-block d-md-none', - className, - )} - href={ - pathname - ? `${REACT_BASE_PATH}${pathname}${handleParams(key)}` - : handleParams(key) - } - onClick={(evt) => handleClick(evt, key)}> - {t(name)} - </Dropdown.Item> - ); - })} - </DropdownButton> - )} - </ButtonGroup> + onClick={(evt) => handleClick(evt, key)}> + {t(name)} + </Dropdown.Item> + ); + })} + </DropdownButton> + )} + </ButtonGroup> + <DropdownButton + size="sm" + variant="secondary" + className="d-block d-sm-none" + title={t(currentSort)}> + {data.map((btn) => { + const key = typeof btn === 'string' ? btn : btn.sort; + const name = typeof btn === 'string' ? btn : btn.name; + return ( + <Dropdown.Item + as="a" + key={key} + active={currentSort === name} + className={classNames( + 'text-capitalize', + 'd-block d-sm-none', + className, + )} + href={ + pathname + ? `${REACT_BASE_PATH}${pathname}${handleParams(key)}` + : handleParams(key) + } + onClick={(evt) => handleClick(evt, key)}> + {t(name)} + </Dropdown.Item> + ); + })} + </DropdownButton> + </> ); }; diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index 82baea6a..c849de56 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<Props> = ({ currentSort={curOrder} pathname={source === 'questions' ? '/questions' : ''} i18nKeyPrefix="question" + maxBtnCount={source === 'tag' ? 3 : 4} /> </div> <ListGroup className="rounded-0"> diff --git a/ui/src/pages/Admin/Write/index.tsx b/ui/src/pages/Admin/Write/index.tsx index 442c58f6..fcf99c1d 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 = () => { </Form.Control.Feedback> </Form.Group> + <Form.Group className="mb-3" controlId="image_size"> + <Form.Label>{t('image_size.label')}</Form.Label> + <Form.Control + type="number" + value={formData.max_image_size.value} + onChange={(evt) => { + handleValueChange({ + max_image_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('image_size.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.max_image_size.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="attachment_size"> + <Form.Label>{t('attachment_size.label')}</Form.Label> + <Form.Control + type="number" + value={formData.max_attachment_size.value} + onChange={(evt) => { + handleValueChange({ + max_attachment_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('attachment_size.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.max_attachment_size.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="image_megapixels"> + <Form.Label>{t('image_megapixels.label')}</Form.Label> + <Form.Control + type="number" + value={formData.max_image_megapixel.value} + onChange={(evt) => { + handleValueChange({ + max_image_megapixel: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('image_megapixels.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.max_image_megapixel.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="image_extensions"> + <Form.Label>{t('image_extensions.label')}</Form.Label> + <Form.Control + type="text" + value={formData.authorized_image_extensions.value} + onChange={(evt) => { + handleValueChange({ + authorized_image_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('image_extensions.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.authorized_image_extensions.errorMsg} + </Form.Control.Feedback> + </Form.Group> + + <Form.Group className="mb-3" controlId="attachment_extensions"> + <Form.Label>{t('attachment_extensions.label')}</Form.Label> + <Form.Control + type="text" + value={formData.authorized_attachment_extensions.value} + onChange={(evt) => { + handleValueChange({ + authorized_attachment_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + <Form.Text>{t('attachment_extensions.text')}</Form.Text> + <Form.Control.Feedback type="invalid"> + {formData.authorized_attachment_extensions.errorMsg} + </Form.Control.Feedback> + </Form.Group> + <Form.Group className="mb-3"> <Button type="submit">{t('save', { keyPrefix: 'btns' })}</Button> </Form.Group> diff --git a/ui/src/pages/Tags/Detail/index.tsx b/ui/src/pages/Tags/Detail/index.tsx index 41b19022..8bfd5ee8 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} /> </Col> diff --git a/ui/src/stores/writeSetting.ts b/ui/src/stores/writeSetting.ts index 8e7c1f52..f3a5613c 100644 --- a/ui/src/stores/writeSetting.ts +++ b/ui/src/stores/writeSetting.ts @@ -32,6 +32,11 @@ const Index = create<IProps>((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 5f2e7b52..f3ba4235 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, + }); } };
