This is an automated email from the ASF dual-hosted git repository.

shuai pushed a commit to branch feat/1.4.2/ui
in repository https://gitbox.apache.org/repos/asf/incubator-answer.git

commit ab36105cc9398535a8635b78de2d6e172ada2b82
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 cea0c12f..50da2157 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
@@ -2049,6 +2054,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 8a933471..f9f0dc46 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(`![${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/*"
                   />
 
                   <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,
+    });
   }
 };
 

Reply via email to