This is an automated email from the ASF dual-hosted git repository.
shuai pushed a commit to branch test
in repository https://gitbox.apache.org/repos/asf/incubator-answer.git
The following commit(s) were added to refs/heads/test by this push:
new 7606940a Feat/1.3.0/UI (#840)
7606940a is described below
commit 7606940a9068fe2b3bca87e10ab3e3371ae54905
Author: dashuai <[email protected]>
AuthorDate: Thu Mar 14 10:06:41 2024 +0800
Feat/1.3.0/UI (#840)
---
i18n/en_US.yaml | 36 +-
i18n/zh_CN.yaml | 13 +-
internal/controller/template_controller.go | 19 +-
ui/config-overrides.js | 96 ++++++
ui/package.json | 4 +-
ui/pnpm-lock.yaml | 100 ++++--
ui/src/common/constants.ts | 17 +-
ui/src/common/interface.ts | 87 ++++-
.../components/Comment/components/Form/index.tsx | 7 +-
ui/src/components/Comment/index.tsx | 2 +-
ui/src/components/SchemaForm/index.tsx | 2 +-
ui/src/hooks/usePageUsers/index.tsx | 2 +-
ui/src/hooks/useReportModal/index.tsx | 28 +-
ui/src/i18n/init.ts | 8 +-
.../Admin/Answers/components/Action/index.tsx | 12 +
ui/src/pages/Admin/Answers/index.tsx | 24 +-
.../Dashboard/components/AnswerLinks/index.tsx | 16 +-
.../Dashboard/components/HealthStatus/index.tsx | 4 +-
.../Dashboard/components/Statistics/index.tsx | 2 +-
ui/src/pages/Admin/Flags/index.tsx | 186 ----------
.../Admin/Questions/components/Action/index.tsx | 12 +
ui/src/pages/Admin/Questions/index.tsx | 13 +-
ui/src/pages/Admin/index.tsx | 2 +-
.../Ask/components/SearchQuestion/index.tsx | 6 +-
ui/src/pages/Questions/Ask/index.tsx | 3 +-
.../Review/components/ApproveDropdown/index.tsx | 202 +++++++++++
.../Review/components/EditPostModal/index.scss | 4 +
.../Review/components/EditPostModal/index.tsx | 375 +++++++++++++++++++++
.../pages/Review/components/FlagContent/index.tsx | 210 ++++++++++++
.../Review/components/QueuedContent/index.tsx | 203 +++++++++++
.../pages/Review/components/ReviewType/index.tsx | 40 +++
.../Review/components/SuggestContent/index.tsx | 238 +++++++++++++
ui/src/pages/Review/components/index.ts | 15 +
ui/src/pages/Review/index.tsx | 274 ++++-----------
ui/src/pages/Review/utils/generateData.ts | 58 ++++
.../components/Achievements/index.tsx | 2 +-
.../Users/Notifications/components/Inbox/index.tsx | 2 +-
ui/src/pages/Users/Settings/Interface/index.tsx | 3 +-
ui/src/router/pathFactory.ts | 2 +-
ui/src/router/routes.ts | 5 -
ui/src/services/client/index.ts | 1 +
ui/src/services/client/review.ts | 52 +++
ui/src/services/client/revision.ts | 6 -
ui/src/utils/common.ts | 49 +--
ui/src/utils/saveDraft.ts | 2 +-
ui/template/header.html | 4 +-
46 files changed, 1921 insertions(+), 527 deletions(-)
diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index 18312e5e..61af80e1 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -1169,6 +1169,15 @@ ui:
system_setting: System setting
default: Default
reset: Reset
+ tag: Tag
+ post_lowercase: post
+ filter: Filter
+ ignore: Ignore
+ submit: Submit
+ normal: Normal
+ closed: Closed
+ deleted: Deleted
+ pending: Pending
search:
title: Search Results
keywords: Keywords
@@ -1444,6 +1453,7 @@ ui:
github: GitHub
blog: Blog
contact: Contact
+ forum: Forum
documents: Documents
feedback: Feedback
support: Support
@@ -1546,9 +1556,6 @@ ui:
content: A suspended user can't log in.
questions:
page_title: Questions
- normal: Normal
- closed: Closed
- deleted: Deleted
unlisted: Unlisted
post: Post
votes: Votes
@@ -1557,12 +1564,11 @@ ui:
status: Status
action: Action
change: Change
+ pending: Pending
filter:
placeholder: "Filter by title, question:id"
answers:
page_title: Answers
- normal: Normal
- deleted: Deleted
post: Post
votes: Votes
created: Created
@@ -1676,8 +1682,8 @@ ui:
page_title: Write
restrict_answer:
title: Restrict answer
- label: Each user can only write one answer for each question
- text: "They can use the edit link to refine and improve their existing
answer, instead."
+ label: Each user can only write one answer for the same question
+ text: "Turn off to allow users to write multiple answers to the same
question, which may cause answers to be unfocused."
recommend_tags:
label: Recommend tags
text: "Please input tag slug above, one tag per line."
@@ -1811,6 +1817,22 @@ ui:
edit_answer: Edit answer
edit_tag: Edit tag
empty: No review tasks left.
+ approve_revision_tip: Do you approve this revision?
+ approve_flag_tip: Do you approve this flag?
+ approve_post_tip: Do you approve this post?
+ approve_user_tip: Do you approve this user?
+ suggest_edits: Suggested edits
+ flag_post: Flag post
+ flag_user: Flag user
+ queued_post: Queued post
+ queued_user: Queued user
+ filter_label: Type
+ reputation: reputation
+ flag_post_type: Flagged this post as {{ type }}
+ flag_user_type: Flagged this user as {{ type }}
+ edit_post: Edit post
+ list_post: List post
+ unlist_post: Unlist post
timeline:
undeleted: undeleted
deleted: deleted
diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml
index 01bc07b9..cdabc86f 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -1133,6 +1133,15 @@ ui:
system_setting: System setting
default: Default
reset: Reset
+ tag: Tag
+ post_lowercase: 帖子
+ filter: Filter
+ ignore: 忽略
+ submit: 提交
+ normal: 正常
+ closed: 已关闭
+ deleted: 已删除
+ pending: Pending
search:
title: 搜索结果
keywords: 关键词
@@ -1631,8 +1640,8 @@ ui:
page_title: 编辑
restrict_answer:
title: 限制一个回答
- label: 每个用户对于每个问题只能有一个回答
- text: "用户可以使用编辑按钮优化已有的回答"
+ label: 每个用户只能为同一问题写一个回答
+ text: "关闭以允许用户对同一问题编写多个回答,这可能会导致回答不集中。"
recommend_tags:
label: 推荐标签
text: "请在上方输入标签固定链接,每行一个标签。"
diff --git a/internal/controller/template_controller.go
b/internal/controller/template_controller.go
index 6b4c4bbe..b128b91a 100644
--- a/internal/controller/template_controller.go
+++ b/internal/controller/template_controller.go
@@ -22,7 +22,6 @@ package controller
import (
"encoding/json"
"fmt"
- "github.com/apache/incubator-answer/internal/entity"
"html/template"
"net/http"
"regexp"
@@ -32,6 +31,7 @@ import (
"github.com/apache/incubator-answer/internal/base/constant"
"github.com/apache/incubator-answer/internal/base/handler"
templaterender
"github.com/apache/incubator-answer/internal/controller/template_render"
+ "github.com/apache/incubator-answer/internal/entity"
"github.com/apache/incubator-answer/internal/schema"
"github.com/apache/incubator-answer/internal/service/siteinfo_common"
"github.com/apache/incubator-answer/pkg/checker"
@@ -47,7 +47,7 @@ import (
var SiteUrl = ""
type TemplateController struct {
- scriptPath string
+ scriptPath []string
cssPath string
templateRenderController *templaterender.TemplateRenderController
siteInfoService siteinfo_common.SiteInfoCommonService
@@ -66,18 +66,21 @@ func NewTemplateController(
siteInfoService: siteInfoService,
}
}
-func GetStyle() (script, css string) {
+func GetStyle() (script []string, css string) {
file, err := ui.Build.ReadFile("build/index.html")
if err != nil {
return
}
- scriptRegexp := regexp.MustCompile(`<script defer="defer"
src="(.*)"></script>`)
- scriptData := scriptRegexp.FindStringSubmatch(string(file))
+ scriptRegexp := regexp.MustCompile(`<script defer="defer"
src="([^"]*)"></script>`)
+ scriptData := scriptRegexp.FindAllStringSubmatch(string(file), -1)
+ for _, s := range scriptData {
+ if len(s) == 2 {
+ script = append(script, s[1])
+ }
+ }
+
cssRegexp := regexp.MustCompile(`<link href="(.*)" rel="stylesheet">`)
cssListData := cssRegexp.FindStringSubmatch(string(file))
- if len(scriptData) == 2 {
- script = scriptData[1]
- }
if len(cssListData) == 2 {
css = cssListData[1]
}
diff --git a/ui/config-overrides.js b/ui/config-overrides.js
index 293d6bc1..0df95a9b 100644
--- a/ui/config-overrides.js
+++ b/ui/config-overrides.js
@@ -20,6 +20,7 @@
const {
addWebpackModuleRule,
addWebpackAlias,
+ setWebpackOptimizationSplitChunks,
} = require("customize-cra");
const path = require("path");
@@ -37,6 +38,101 @@ module.exports = {
use: "yaml-loader"
})(config);
+ setWebpackOptimizationSplitChunks({
+ maxInitialRequests: 20,
+ minSize: 20 * 1024,
+ minChunks: 2,
+ cacheGroups: {
+ automaticNamePrefix: 'chunk',
+ components: {
+ test: /[\\/]components[\\/]/,
+ name: 'components',
+ priority: 14,
+ reuseExistingChunk: true,
+ minChunks: 1,
+ chunks: 'initial',
+ },
+ i18next: {
+ name: 'i18next',
+ test: /[\/]node_modules[\/](i18next)[\/]/,
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ priority: 12,
+ reuseExistingChunk: true,
+ minChunks: 1,
+ chunks: 'initial',
+ },
+ reactBootstrap: {
+ name: 'react-bootstrap',
+ test: /[\/]node_modules[\/](react-bootstrap)[\/]/,
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ priority: 11,
+ minChunks: 1,
+ chunks: 'initial',
+ reuseExistingChunk: true,
+ },
+ lodash: {
+ name: 'lodash',
+ test: /[\/]node_modules[\/](lodash)[\/]/,
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ priority: 10,
+ reuseExistingChunk: true,
+ minChunks: 1,
+ chunks: 'initial',
+ },
+ codemirror: {
+ name: 'codemirror',
+ test: /[\/]node_modules[\/](codemirror)[\/]/,
+ priority: 9,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
+ nextShare: {
+ name: 'next-share',
+ test: /[\/]node_modules[\/](next-share)[\/]/,
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ priority: 8,
+ reuseExistingChunk: true,
+ minChunks: 1,
+ chunks: 'initial',
+ },
+ marked: {
+ name: 'marked',
+ test: /[\/]node_modules[\/](marked)[\/]/,
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ priority: 7,
+ reuseExistingChunk: true,
+ minChunks: 1,
+ chunks: 'initial',
+ },
+ reactDom: {
+ name: 'react-dom',
+ test: /[\/]node_modules[\/](react-dom)[\/]/,
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ priority: 7,
+ reuseExistingChunk: true,
+ chunks: 'all',
+ enforce: true,
+ },
+ nodesAsync: {
+ name: 'chunk-nodesAsync',
+ test: /[\/]node_modules[\/]/,
+ priority: 2,
+ minChunks: 2,
+ chunks: 'async', // 仅打包异步引用的依赖
+ reuseExistingChunk: true, // 重复使用已经存在的块
+ },
+ nodesInitial: {
+ name: 'chunk-nodesInitial',
+ filename: 'static/js/[name].[contenthash:8].chunk.js',
+ test: /[\/]node_modules[\/]/,
+ priority: 1,
+ minChunks: 1,
+ chunks: 'initial',
+ reuseExistingChunk: true,
+ },
+ },
+ })(config);
+
// add i18n dir to ModuleScopePlugin allowedPaths
const moduleScopePlugin = config.resolve.plugins.find(_ =>
_.constructor.name === "ModuleScopePlugin");
if (moduleScopePlugin) {
diff --git a/ui/package.json b/ui/package.json
index ac7e4411..63a1f85d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -12,7 +12,8 @@
"prepare": "pnpm build:packages",
"pre-commit": "lint-staged",
"build:packages": "pnpm -r --filter=./src/plugins/* run build",
- "clean": "rm -rf node_modules && rm -rf src/plugins/**/node_modules"
+ "clean": "rm -rf node_modules && rm -rf src/plugins/**/node_modules",
+ "analyze": "source-map-explorer 'build/static/js/*.js'"
},
"dependencies": {
"axios": "^0.27.2",
@@ -82,6 +83,7 @@
"react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1",
"sass": "^1.54.4",
+ "source-map-explorer": "^2.5.3",
"typescript": "^4.9.5",
"yaml-loader": "^0.8.0"
},
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 77f3c9b1..83bdc9e2 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -1,20 +1,3 @@
-# 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.
-
lockfileVersion: '6.0'
settings:
@@ -221,6 +204,9 @@ importers:
sass:
specifier: ^1.54.4
version: 1.54.9
+ source-map-explorer:
+ specifier: ^2.5.3
+ version: 2.5.3
typescript:
specifier: ^4.9.5
version: 4.9.5
@@ -2223,7 +2209,7 @@ packages:
chalk: 4.1.2
emittery: 0.8.1
exit: 0.1.2
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-changed-files: 27.5.1
jest-config: 27.5.1([email protected])
jest-haste-map: 27.5.1
@@ -2295,7 +2281,7 @@ packages:
collect-v8-coverage: 1.0.1
exit: 0.1.2
glob: 7.2.3
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
istanbul-lib-coverage: 3.2.0
istanbul-lib-instrument: 5.2.0
istanbul-lib-report: 3.0.0
@@ -2324,7 +2310,7 @@ packages:
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
callsites: 3.1.0
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
source-map: 0.6.1
/@jest/[email protected]:
@@ -2350,7 +2336,7 @@ packages:
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
'@jest/test-result': 27.5.1
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-haste-map: 27.5.1
jest-runtime: 27.5.1
transitivePeerDependencies:
@@ -4550,6 +4536,12 @@ packages:
dependencies:
node-int64: 0.4.0
+ /[email protected]:
+ resolution: {integrity:
sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==}
+ engines: {node: '>= 0.4.0'}
+ hasBin: true
+ dev: true
+
/[email protected]:
resolution: {integrity:
sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -5914,7 +5906,7 @@ packages:
resolution: {integrity:
sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==}
engines: {node: '>=10.13.0'}
dependencies:
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
tapable: 2.2.1
/[email protected]:
@@ -7899,7 +7891,7 @@ packages:
'@jest/types': 27.5.1
chalk: 4.1.2
exit: 0.1.2
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
import-local: 3.1.0
jest-config: 27.5.1([email protected])
jest-util: 27.5.1
@@ -7930,7 +7922,7 @@ packages:
ci-info: 3.4.0
deepmerge: 4.2.2
glob: 7.2.3
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-circus: 27.5.1
jest-environment-jsdom: 27.5.1
jest-environment-node: 27.5.1
@@ -8034,7 +8026,7 @@ packages:
'@types/node': 16.11.59
anymatch: 3.1.2
fb-watchman: 2.0.1
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-regex-util: 27.5.1
jest-serializer: 27.5.1
jest-util: 27.5.1
@@ -8102,7 +8094,7 @@ packages:
'@jest/types': 27.5.1
'@types/stack-utils': 2.0.1
chalk: 4.1.2
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
micromatch: 4.0.5
pretty-format: 27.5.1
slash: 3.0.0
@@ -8185,7 +8177,7 @@ packages:
'@types/node': 16.11.59
chalk: 4.1.2
emittery: 0.8.1
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-docblock: 27.5.1
jest-environment-jsdom: 27.5.1
jest-environment-node: 27.5.1
@@ -8220,7 +8212,7 @@ packages:
collect-v8-coverage: 1.0.1
execa: 5.1.1
glob: 7.2.3
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-haste-map: 27.5.1
jest-message-util: 27.5.1
jest-mock: 27.5.1
@@ -8238,7 +8230,7 @@ packages:
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
'@types/node': 16.11.59
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
/[email protected]:
resolution: {integrity:
sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==}
@@ -8256,7 +8248,7 @@ packages:
babel-preset-current-node-syntax: 1.0.1(@babel/[email protected])
chalk: 4.1.2
expect: 27.5.1
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
jest-diff: 27.5.1
jest-get-type: 27.5.1
jest-haste-map: 27.5.1
@@ -8277,7 +8269,7 @@ packages:
'@types/node': 16.11.59
chalk: 4.1.2
ci-info: 3.4.0
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
picomatch: 2.3.1
/[email protected]:
@@ -8483,7 +8475,7 @@ packages:
dependencies:
universalify: 2.0.0
optionalDependencies:
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
/[email protected]:
resolution: {integrity:
sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==}
@@ -9153,6 +9145,14 @@ packages:
mimic-fn: 4.0.0
dev: true
+ /[email protected]:
+ resolution: {integrity:
sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
+ engines: {node: '>=8'}
+ dependencies:
+ is-docker: 2.2.1
+ is-wsl: 2.2.0
+ dev: true
+
/[email protected]:
resolution: {integrity:
sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==}
engines: {node: '>=12'}
@@ -10792,6 +10792,13 @@ packages:
resolution: {integrity:
sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}
dev: true
+ /[email protected]:
+ resolution: {integrity:
sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
+ hasBin: true
+ dependencies:
+ glob: 7.2.3
+ dev: true
+
/[email protected]:
resolution: {integrity:
sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
hasBin: true
@@ -11151,6 +11158,25 @@ packages:
/[email protected]:
resolution: {integrity:
sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
+ /[email protected]:
+ resolution: {integrity:
sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==}
+ engines: {node: '>=12'}
+ hasBin: true
+ dependencies:
+ btoa: 1.2.1
+ chalk: 4.1.2
+ convert-source-map: 1.8.0
+ ejs: 3.1.8
+ escape-html: 1.0.3
+ glob: 7.2.3
+ gzip-size: 6.0.0
+ lodash: 4.17.21
+ open: 7.4.2
+ source-map: 0.7.4
+ temp: 0.9.4
+ yargs: 16.2.0
+ dev: true
+
/[email protected]:
resolution: {integrity:
sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@@ -11593,6 +11619,14 @@ packages:
resolution: {integrity:
sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
+ /[email protected]:
+ resolution: {integrity:
sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
+ engines: {node: '>=6.0.0'}
+ dependencies:
+ mkdirp: 0.5.6
+ rimraf: 2.6.3
+ dev: true
+
/[email protected]:
resolution: {integrity:
sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==}
engines: {node: '>=10'}
@@ -12184,7 +12218,7 @@ packages:
engines: {node: '>=10.13.0'}
dependencies:
glob-to-regexp: 0.4.1
- graceful-fs: 4.2.10
+ graceful-fs: 4.2.11
/[email protected]:
resolution: {integrity:
sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==}
diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts
index ec7f1ba6..92944f83 100644
--- a/ui/src/common/constants.ts
+++ b/ui/src/common/constants.ts
@@ -55,6 +55,11 @@ export const ADMIN_LIST_STATUS = {
variant: 'text-bg-danger',
name: 'deleted',
},
+ // pending
+ 11: {
+ variant: 'text-bg-warning',
+ name: 'pending',
+ },
normal: {
variant: 'text-bg-success',
name: 'normal',
@@ -67,9 +72,13 @@ export const ADMIN_LIST_STATUS = {
variant: 'text-bg-danger',
name: 'deleted',
},
- unlisted: {
+ pending: {
+ variant: 'text-bg-warning',
+ name: 'pending',
+ },
+ unlist: {
variant: 'text-bg-secondary',
- name: 'unlisted',
+ name: 'unlist',
},
};
@@ -85,10 +94,6 @@ export const ADMIN_NAV_MENUS = [
{
name: 'users',
},
- {
- name: 'flags',
- // badgeContent: 5,
- },
{
name: 'customize',
children: [
diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts
index 65117ae1..86c87798 100644
--- a/ui/src/common/interface.ts
+++ b/ui/src/common/interface.ts
@@ -56,6 +56,7 @@ export interface TagBase {
export interface Tag extends TagBase {
main_tag_slug_name?: string;
parsed_text?: string;
+ tag_id?: string;
}
export interface SynonymsTag extends Tag {
@@ -299,9 +300,13 @@ export interface QueryQuestionsReq extends Paging {
in_days?: number;
}
-export type AdminQuestionStatus = 'available' | 'closed' | 'deleted';
+export type AdminQuestionStatus =
+ | 'available'
+ | 'pending'
+ | 'closed'
+ | 'deleted';
-export type AdminContentsFilterBy = 'normal' | 'closed' | 'deleted';
+export type AdminContentsFilterBy = 'normal' | 'pending' | 'closed' |
'deleted';
export interface AdminContentsReq extends Paging {
status: AdminContentsFilterBy;
@@ -563,7 +568,7 @@ export interface TimelineRes {
timeline: TimelineItem[];
}
-export interface ReviewItem {
+export interface SuggestReviewItem {
type: 'question' | 'answer' | 'tag';
info: {
url_title?: string;
@@ -585,9 +590,57 @@ export interface ReviewItem {
content: Tag | QuestionDetailRes | AnswerItem;
};
}
-export interface ReviewResp {
+export interface SuggestReviewResp {
+ count: number;
+ list: SuggestReviewItem[];
+}
+
+export interface ReasonItem {
+ content_type: string;
+ description: string;
+ name: string;
+ placeholder: string;
+ reason_type: number;
+}
+
+export interface BaseReviewItem {
+ object_type: 'question' | 'answer' | 'comment' | 'user';
+ object_id: string;
+ object_show_status: number;
+ object_status: number;
+ tags: Tag[];
+ title: string;
+ original_text: string;
+ author_user_info: UserInfoBase;
+ created_at: number;
+ submit_at: number;
+ comment_id: string;
+ question_id: string;
+ answer_id: string;
+ answer_count: number;
+ answer_accepted?: boolean;
+ flag_id: string;
+}
+
+export interface FlagReviewItem extends BaseReviewItem {
+ reason: ReasonItem;
+ submitter_user: UserInfoBase;
+}
+
+export interface FlagReviewResp {
count: number;
- list: ReviewItem[];
+ list: FlagReviewItem[];
+}
+
+export interface QueuedReviewItem extends BaseReviewItem {
+ review_id: number;
+ reason: string;
+ submitter_display_name: string;
+}
+
+export interface QueuedReviewResp {
+ count: number;
+ list: QueuedReviewItem[];
}
export interface UserRoleItem {
@@ -639,3 +692,27 @@ export interface UserPluginsConfigRes {
name: string;
slug_name: string;
}
+
+export interface ReviewTypeItem {
+ label: string;
+ name: string;
+ todo_amount: number;
+}
+
+export interface PutFlagReviewParams {
+ operation_type:
+ | 'edit_post'
+ | 'close_post'
+ | 'delete_post'
+ | 'unlist_post'
+ | 'ignore_report';
+ flag_id: string;
+ close_msg?: string;
+ close_type?: number;
+ title?: string;
+ content?: string;
+ tags?: Tag[];
+ // mention_username_list?: any;
+ captcha_code?: any;
+ captcha_id?: any;
+}
diff --git a/ui/src/components/Comment/components/Form/index.tsx
b/ui/src/components/Comment/components/Form/index.tsx
index 8809778c..05ae5b2d 100644
--- a/ui/src/components/Comment/components/Form/index.tsx
+++ b/ui/src/components/Comment/components/Form/index.tsx
@@ -25,6 +25,7 @@ import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers, usePromptWithUnload } from '@/hooks';
+import { parseEditMentionUser } from '@/utils';
const Index = ({
className = '',
@@ -78,7 +79,11 @@ const Index = ({
<Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
- <TextArea size="sm" value={value} onChange={handleChange} />
+ <TextArea
+ size="sm"
+ value={type === 'edit' ? parseEditMentionUser(value) : value}
+ onChange={handleChange}
+ />
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
</div>
diff --git a/ui/src/components/Comment/index.tsx
b/ui/src/components/Comment/index.tsx
index be2d5553..81345e7e 100644
--- a/ui/src/components/Comment/index.tsx
+++ b/ui/src/components/Comment/index.tsx
@@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
-import { unionBy } from 'lodash';
+import unionBy from 'lodash/unionBy';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
diff --git a/ui/src/components/SchemaForm/index.tsx
b/ui/src/components/SchemaForm/index.tsx
index 2bb91e31..d0afb692 100644
--- a/ui/src/components/SchemaForm/index.tsx
+++ b/ui/src/components/SchemaForm/index.tsx
@@ -26,7 +26,7 @@ import React, {
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
-import { isEmpty } from 'lodash';
+import isEmpty from 'lodash/isEmpty';
import classnames from 'classnames';
import type * as Type from '@/common/interface';
diff --git a/ui/src/hooks/usePageUsers/index.tsx
b/ui/src/hooks/usePageUsers/index.tsx
index 9db172da..c0b3836e 100644
--- a/ui/src/hooks/usePageUsers/index.tsx
+++ b/ui/src/hooks/usePageUsers/index.tsx
@@ -19,7 +19,7 @@
import { useState } from 'react';
-import { uniqBy } from 'lodash';
+import uniqBy from 'lodash/uniqBy';
import * as Types from '@/common/interface';
diff --git a/ui/src/hooks/useReportModal/index.tsx
b/ui/src/hooks/useReportModal/index.tsx
index 9ad66513..88cd2442 100644
--- a/ui/src/hooks/useReportModal/index.tsx
+++ b/ui/src/hooks/useReportModal/index.tsx
@@ -25,7 +25,13 @@ import ReactDOM from 'react-dom/client';
import { useToast, useCaptchaModal } from '@/hooks';
import type * as Type from '@/common/interface';
-import { reportList, postReport, closeQuestion, putReport } from '@/services';
+import {
+ reportList,
+ postReport,
+ closeQuestion,
+ putReport,
+ putFlagReviewAction,
+} from '@/services';
interface Params {
isBackend?: boolean;
@@ -33,6 +39,7 @@ interface Params {
id: string;
title?: string;
action: Type.ReportAction;
+ source?: string;
}
const useReportModal = (callback?: () => void) => {
@@ -63,7 +70,12 @@ const useReportModal = (callback?: () => void) => {
rootRef.current.root = ReactDOM.createRoot(div);
}, []);
const getList = ({ type, action, isBackend }: Params) => {
- reportList({ type, action, isBackend }).then((res) => {
+ // @ts-ignore
+ reportList({
+ type,
+ action,
+ isBackend,
+ }).then((res) => {
setList(res);
setShow(true);
});
@@ -113,6 +125,18 @@ const useReportModal = (callback?: () => void) => {
return;
}
if (params.type === 'question' && params.action === 'close') {
+ if (params?.source === 'review') {
+ putFlagReviewAction({
+ flag_id: params.id,
+ operation_type: 'close_post',
+ close_type: reportType.type,
+ close_msg: content.value,
+ }).then(() => {
+ onClose();
+ asyncCallback();
+ });
+ return;
+ }
closeQuestion({
id: params.id,
close_type: reportType.type,
diff --git a/ui/src/i18n/init.ts b/ui/src/i18n/init.ts
index 03f775cb..66d9576e 100644
--- a/ui/src/i18n/init.ts
+++ b/ui/src/i18n/init.ts
@@ -21,7 +21,7 @@ import { initReactI18next } from 'react-i18next';
import i18next from 'i18next';
import en_US from '@i18n/en_US.yaml';
-import zh_CN from '@i18n/zh_CN.yaml';
+// import zh_CN from '@i18n/zh_CN.yaml';
import { DEFAULT_LANG, LANG_RESOURCE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
@@ -34,9 +34,9 @@ const initResources = {
en_US: {
translation: en_US.ui,
},
- zh_CN: {
- translation: zh_CN.ui,
- },
+ // zh_CN: {
+ // translation: zh_CN.ui,
+ // },
};
const storageLang = Storage.get(LANG_RESOURCE_STORAGE_KEY);
diff --git a/ui/src/pages/Admin/Answers/components/Action/index.tsx
b/ui/src/pages/Admin/Answers/components/Action/index.tsx
index 7f34f219..c4308cd2 100644
--- a/ui/src/pages/Admin/Answers/components/Action/index.tsx
+++ b/ui/src/pages/Admin/Answers/components/Action/index.tsx
@@ -19,6 +19,7 @@
import { Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
import { Icon, Modal } from '@/components';
import { changeAnswerStatus } from '@/services';
@@ -58,6 +59,17 @@ const AnswerActions = ({ itemData, curFilter, refreshList })
=> {
}
};
+ if (curFilter === 'pending') {
+ return (
+ <Link
+ to={`/review?type=queued_post&objectId=${itemData.id}`}
+ className="btn btn-link p-0"
+ title={t('review', { keyPrefix: 'header.nav' })}>
+ <Icon name="three-dots-vertical" />
+ </Link>
+ );
+ }
+
return (
<Dropdown>
<Dropdown.Toggle variant="link" className="no-toggle p-0">
diff --git a/ui/src/pages/Admin/Answers/index.tsx
b/ui/src/pages/Admin/Answers/index.tsx
index 869bec9a..a653f7f5 100644
--- a/ui/src/pages/Admin/Answers/index.tsx
+++ b/ui/src/pages/Admin/Answers/index.tsx
@@ -40,7 +40,11 @@ import { pathFactory } from '@/router/pathFactory';
import AnswerAction from './components/Action';
-const answerFilterItems: Type.AdminContentsFilterBy[] = ['normal', 'deleted'];
+const answerFilterItems: Type.AdminContentsFilterBy[] = [
+ 'normal',
+ 'pending',
+ 'deleted',
+];
const Answers: FC = () => {
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
@@ -77,7 +81,7 @@ const Answers: FC = () => {
data={answerFilterItems}
currentSort={curFilter}
sortKey="status"
- i18nKeyPrefix="admin.answers"
+ i18nKeyPrefix="btns"
/>
<Form.Control
@@ -118,13 +122,13 @@ const Answers: FC = () => {
className="text-break text-wrap"
rel="noreferrer">
{li.question_info.title}
+ {li.accepted === 2 && (
+ <Icon
+ name="check-circle-fill"
+ className="ms-2 text-success"
+ />
+ )}
</a>
- {li.accepted === 2 && (
- <Icon
- name="check-circle-fill"
- className="ms-2 text-success"
- />
- )}
</Stack>
<div
className="text-truncate-2 small"
@@ -150,7 +154,9 @@ const Answers: FC = () => {
'badge',
ADMIN_LIST_STATUS[curFilter]?.variant,
)}>
- {t(ADMIN_LIST_STATUS[curFilter]?.name)}
+ {t(ADMIN_LIST_STATUS[curFilter]?.name, {
+ keyPrefix: 'btns',
+ })}
</span>
</td>
<td className="text-end">
diff --git a/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx
b/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx
index b2a04ffa..39a803b0 100644
--- a/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx
+++ b/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx
@@ -45,16 +45,16 @@ const AnswerLinks = () => {
</a>
</Col>
<Col xs={6}>
- <a href="https://meta.answer.dev" target="_blank" rel="noreferrer">
+ <a
+ href="https://answer.apache.org/community"
+ target="_blank"
+ rel="noreferrer">
{t('support')}
</a>
</Col>
<Col xs={6}>
- <a
- href="https://github.com/apache/incubator-answer"
- target="_blank"
- rel="noreferrer">
- {t('github')}
+ <a href="https://meta.answer.dev" target="_blank" rel="noreferrer">
+ {t('forum')}
</a>
</Col>
<Col xs={6}>
@@ -67,10 +67,10 @@ const AnswerLinks = () => {
</Col>
<Col xs={6}>
<a
- href="https://answer.apache.org/contact"
+ href="https://github.com/apache/incubator-answer"
target="_blank"
rel="noreferrer">
- {t('contact')}
+ {t('github')}
</a>
</Col>
</Row>
diff --git a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx
b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx
index 64122669..36690198 100644
--- a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx
+++ b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx
@@ -93,7 +93,9 @@ const HealthStatus: FC<IProps> = ({ data }) => {
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('timezone')}</span>
- <strong>{data.time_zone.split('/')?.[1]}</strong>
+ <strong>
+ {data.time_zone.split('/')?.[1].replaceAll('_', ' ')}
+ </strong>
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('smtp')}</span>
diff --git a/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx
b/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx
index 85262724..d0f5f410 100644
--- a/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx
+++ b/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx
@@ -58,7 +58,7 @@ const Statistics: FC<IProps> = ({ data }) => {
<Col xs={6}>
<span className="text-secondary me-1">{t('flags')}</span>
<strong>
- <Link to="/admin/flags" className="ms-2">
+ <Link to="/review?type=flagged_post" className="ms-2">
{data.report_count}
</Link>
</strong>
diff --git a/ui/src/pages/Admin/Flags/index.tsx
b/ui/src/pages/Admin/Flags/index.tsx
deleted file mode 100644
index 73f32113..00000000
--- a/ui/src/pages/Admin/Flags/index.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * 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 React, { FC } from 'react';
-import { Button, Form, Table, Stack } from 'react-bootstrap';
-import { useSearchParams } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-
-import {
- FormatTime,
- BaseUserCard,
- Empty,
- Pagination,
- QueryGroup,
-} from '@/components';
-import { useReportModal } from '@/hooks';
-import * as Type from '@/common/interface';
-import { useFlagSearch } from '@/services';
-import { escapeRemove } from '@/utils';
-import { pathFactory } from '@/router/pathFactory';
-
-const flagFilterKeys: Type.FlagStatus[] = ['pending', 'completed'];
-const flagTypeKeys: Type.FlagType[] = ['all', 'question', 'answer', 'comment'];
-
-const Flags: FC = () => {
- const { t } = useTranslation('translation', { keyPrefix: 'admin.flags' });
- const [urlSearchParams, setUrlSearchParams] = useSearchParams();
- const curFilter = urlSearchParams.get('status') || flagFilterKeys[0];
- const curType = urlSearchParams.get('type') || flagTypeKeys[0];
- const PAGE_SIZE = 20;
- const curPage = Number(urlSearchParams.get('page')) || 1;
- const {
- data: listData,
- isLoading,
- mutate: refreshList,
- } = useFlagSearch({
- page_size: PAGE_SIZE,
- page: curPage,
- status: curFilter as Type.FlagStatus,
- object_type: curType as Type.FlagType,
- });
- const reportModal = useReportModal(refreshList);
-
- const count = listData?.count || 0;
-
- const onTypeChange = (evt) => {
- urlSearchParams.set('type', evt.target.value);
- setUrlSearchParams(urlSearchParams);
- };
-
- const handleReview = ({ id, object_type }) => {
- reportModal.onShow({
- id,
- type: object_type,
- isBackend: true,
- action: 'review',
- });
- };
-
- return (
- <>
- <h3 className="mb-4">{t('title')}</h3>
- <div className="d-flex justify-content-between align-items-center mb-3">
- <QueryGroup
- data={flagFilterKeys}
- currentSort={curFilter}
- sortKey="status"
- i18nKeyPrefix="admin.flags"
- />
-
- <Form.Select
- value={curType}
- onChange={onTypeChange}
- size="sm"
- style={{ width: '12.25rem' }}>
- {flagTypeKeys.map((li) => {
- return (
- <option value={li} key={li}>
- {t(li, { keyPrefix: 'btns' })}
- </option>
- );
- })}
- </Form.Select>
- </div>
- <Table>
- <thead>
- <tr>
- <th>{t('flagged')}</th>
- <th style={{ width: '20%' }}>{t('created')}</th>
- {curFilter !== 'completed' ? (
- <th style={{ width: '20%' }}>{t('action')}</th>
- ) : null}
- </tr>
- </thead>
- <tbody className="align-middle">
- {listData?.list?.map((li) => {
- return (
- <tr key={li.id}>
- <td>
- <Stack>
- <small className="text-secondary">
- {t('flagged_type', {
- type: t(li.object_type, { keyPrefix: 'btns' }),
- })}
- </small>
- <BaseUserCard
- data={li.reported_user}
- className="mt-2 small"
- />
- <a
- href={pathFactory.questionLanding(
- li.question_id,
- li.url_title,
- )}
- target="_blank"
- className="text-wrap text-break mt-2"
- rel="noreferrer">
- {li.title}
- </a>
- <small className="text-break text-wrap word">
- {escapeRemove(li.excerpt)}
- </small>
- </Stack>
- </td>
- <td>
- <Stack>
- <FormatTime
- time={li.created_at}
- className="small text-secondary"
- />
- <BaseUserCard
- data={li.report_user}
- className="mt-2 mb-2 small"
- />
- {li.flagged_reason ? (
- <small>{li.flagged_content}</small>
- ) : (
- <small>
- {li.reason?.name}
- <br />
- <span className="text-secondary">{li.content}</span>
- </small>
- )}
- </Stack>
- </td>
- {curFilter !== 'completed' ? (
- <td>
- <Button variant="link" onClick={() => handleReview(li)}>
- {t('review')}
- </Button>
- </td>
- ) : null}
- </tr>
- );
- })}
- </tbody>
- </Table>
- {Number(count) <= 0 && !isLoading && <Empty />}
- <div className="mt-4 mb-2 d-flex justify-content-center">
- <Pagination
- currentPage={curPage}
- totalSize={count}
- pageSize={PAGE_SIZE}
- />
- </div>
- </>
- );
-};
-
-export default Flags;
diff --git a/ui/src/pages/Admin/Questions/components/Action/index.tsx
b/ui/src/pages/Admin/Questions/components/Action/index.tsx
index a1f7e2a2..f5d2b41f 100644
--- a/ui/src/pages/Admin/Questions/components/Action/index.tsx
+++ b/ui/src/pages/Admin/Questions/components/Action/index.tsx
@@ -19,6 +19,7 @@
import { Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
import { Icon, Modal } from '@/components';
import {
@@ -119,6 +120,17 @@ const AnswerActions = ({ itemData, refreshList, curFilter,
show, pin }) => {
}
};
+ if (curFilter === 'pending') {
+ return (
+ <Link
+ to={`/review?type=queued_post&objectId=${itemData.id}`}
+ className="btn btn-link p-0"
+ title={t('review', { keyPrefix: 'header.nav' })}>
+ <Icon name="three-dots-vertical" />
+ </Link>
+ );
+ }
+
return (
<Dropdown>
<Dropdown.Toggle variant="link" className="no-toggle p-0">
diff --git a/ui/src/pages/Admin/Questions/index.tsx
b/ui/src/pages/Admin/Questions/index.tsx
index 27565fa6..0488a08b 100644
--- a/ui/src/pages/Admin/Questions/index.tsx
+++ b/ui/src/pages/Admin/Questions/index.tsx
@@ -41,6 +41,7 @@ import Action from './components/Action';
const questionFilterItems: Type.AdminContentsFilterBy[] = [
'normal',
+ 'pending',
'closed',
'deleted',
];
@@ -78,7 +79,7 @@ const Questions: FC = () => {
data={questionFilterItems}
currentSort={curFilter}
sortKey="status"
- i18nKeyPrefix="admin.questions"
+ i18nKeyPrefix="btns"
/>
<Form.Control
@@ -147,15 +148,19 @@ const Questions: FC = () => {
'mb-1',
ADMIN_LIST_STATUS[curFilter]?.variant,
)}>
- {t(ADMIN_LIST_STATUS[curFilter]?.name)}
+ {t(ADMIN_LIST_STATUS[curFilter]?.name, {
+ keyPrefix: 'btns',
+ })}
</span>
{li.show === 2 && (
<span
className={classNames(
'badge',
- ADMIN_LIST_STATUS.unlisted.variant,
+ ADMIN_LIST_STATUS.unlist.variant,
)}>
- {t(ADMIN_LIST_STATUS.unlisted.name)}
+ {t(ADMIN_LIST_STATUS.unlist.name, {
+ keyPrefix: 'btns',
+ })}
</span>
)}
</td>
diff --git a/ui/src/pages/Admin/index.tsx b/ui/src/pages/Admin/index.tsx
index 2ce145f9..b527088f 100644
--- a/ui/src/pages/Admin/index.tsx
+++ b/ui/src/pages/Admin/index.tsx
@@ -22,7 +22,7 @@ import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Outlet, useMatch } from 'react-router-dom';
-import { cloneDeep } from 'lodash';
+import cloneDeep from 'lodash/cloneDeep';
import { usePageTags } from '@/hooks';
import { AccordionNav } from '@/components';
diff --git a/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx
b/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx
index bda9112a..2f79305b 100644
--- a/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx
+++ b/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx
@@ -46,7 +46,7 @@ const SearchQuestion = ({ similarQuestions }) => {
<ListGroup.Item
action
as="a"
- className="link-dark text-wrap text-break"
+ className="link-dark text-wrap text-break grid gap-0
row-gap-3"
key={item.id}
href={pathFactory.questionLanding(item.id, item.url_title)}
target="_blank">
@@ -56,7 +56,7 @@ const SearchQuestion = ({ similarQuestions }) => {
: null}
{item.accepted_answer ? (
- <span className="small ms-3 text-success">
+ <span className="small ms-3 text-success d-inline-block">
<Icon type="bi" name="check-circle-fill" />
<span className="ms-1">
{t('x_answers', {
@@ -67,7 +67,7 @@ const SearchQuestion = ({ similarQuestions }) => {
</span>
) : (
item.answer_count > 0 && (
- <span className="small ms-3 text-secondary">
+ <span className="small ms-3 text-secondary
d-inline-block">
<Icon type="bi" name="chat-square-text-fill" />
<span className="ms-1">
{t('x_answers', {
diff --git a/ui/src/pages/Questions/Ask/index.tsx
b/ui/src/pages/Questions/Ask/index.tsx
index 165e061a..9a1ff14a 100644
--- a/ui/src/pages/Questions/Ask/index.tsx
+++ b/ui/src/pages/Questions/Ask/index.tsx
@@ -24,7 +24,8 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
-import { isEqual, debounce } from 'lodash';
+import isEqual from 'lodash/isEqual';
+import debounce from 'lodash/debounce';
import { usePageTags, usePromptWithUnload, useCaptchaModal } from '@/hooks';
import { Editor, EditorRef, TagSelector } from '@/components';
diff --git a/ui/src/pages/Review/components/ApproveDropdown/index.tsx
b/ui/src/pages/Review/components/ApproveDropdown/index.tsx
new file mode 100644
index 00000000..05f92296
--- /dev/null
+++ b/ui/src/pages/Review/components/ApproveDropdown/index.tsx
@@ -0,0 +1,202 @@
+import { FC, useState } from 'react';
+import { Dropdown, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { Modal } from '@/components';
+import { putFlagReviewAction } from '@/services';
+import { useCaptchaModal, useReportModal, useToast } from '@/hooks';
+import type * as Type from '@/common/interface';
+import EditPostModal from '../EditPostModal';
+
+interface IProps {
+ itemData: Type.FlagReviewItem | null;
+ curFilter: string;
+ objectType: Type.FlagReviewItem['object_type'] | '';
+ approveCallback: () => void;
+}
+
+const Index: FC<IProps> = ({
+ itemData,
+ objectType,
+ curFilter,
+ approveCallback,
+}) => {
+ const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [showEditPostModal, setShowEditPostModal] = useState(false);
+ const closeModal = useReportModal(approveCallback);
+ const toast = useToast();
+ const dCaptcha = useCaptchaModal('delete');
+
+ const handleEditPostModalState = () => {
+ setShowEditPostModal(!showEditPostModal);
+ };
+
+ const handleDelete = () => {
+ let content = '';
+
+ setIsLoading(true);
+
+ if (objectType === 'question') {
+ content =
+ Number(itemData?.answer_count) > 0
+ ? t('question', { keyPrefix: 'delete' })
+ : t('other', { keyPrefix: 'delete' });
+ }
+ if (objectType === 'answer') {
+ content = itemData?.answer_accepted
+ ? t('answer_accepted', { keyPrefix: 'delete' })
+ : t('other', { keyPrefix: 'delete' });
+ }
+ if (objectType === 'comment') {
+ content = t('other', { keyPrefix: 'delete' });
+ }
+ Modal.confirm({
+ title: t('title', { keyPrefix: 'delete' }),
+ content,
+ cancelBtnVariant: 'link',
+ confirmBtnVariant: 'danger',
+ confirmText: t('delete', { keyPrefix: 'btns' }),
+ onConfirm: () => {
+ dCaptcha.check(() => {
+ const req: Type.PutFlagReviewParams = {
+ operation_type: 'delete_post',
+ flag_id: String(itemData?.flag_id),
+ captcha_code: undefined,
+ captcha_id: undefined,
+ };
+ dCaptcha.resolveCaptchaReq(req);
+
+ delete req.captcha_code;
+ delete req.captcha_id;
+
+ putFlagReviewAction(req)
+ .then(async () => {
+ await dCaptcha.close();
+ let msg = '';
+ if (objectType === 'question') {
+ msg = t('post_deleted', { keyPrefix: 'messages' });
+ }
+ if (objectType === 'answer') {
+ msg = t('tip_answer_deleted');
+ }
+ if (objectType === 'answer' || objectType === 'question') {
+ toast.onShow({
+ msg,
+ variant: 'success',
+ });
+ }
+ approveCallback();
+ })
+ .catch((ex) => {
+ if (ex.isError) {
+ dCaptcha.handleCaptchaError(ex.list);
+ }
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ });
+ },
+ });
+ };
+
+ const handleAction = (type) => {
+ if (type === 'delete') {
+ handleDelete();
+ }
+
+ if (type === 'close') {
+ closeModal.onShow({
+ type: 'question',
+ id: itemData?.flag_id || '',
+ action: 'close',
+ source: 'review',
+ });
+ }
+
+ if (type === 'unlist') {
+ const keyPrefix = 'question_detail.unlist';
+ Modal.confirm({
+ title: t('title', { keyPrefix }),
+ content: t('content', { keyPrefix }),
+ cancelBtnVariant: 'link',
+ confirmText: t('confirm_btn', { keyPrefix }),
+ onConfirm: () => {
+ putFlagReviewAction({
+ operation_type: 'unlist_post',
+ flag_id: itemData?.flag_id || '',
+ }).then(() => {
+ toast.onShow({
+ msg: t(`post_${type}`, { keyPrefix: 'messages' }),
+ variant: 'success',
+ });
+ approveCallback();
+ });
+ },
+ });
+ }
+ };
+
+ const handleActionEdit = () => {
+ handleEditPostModalState();
+ };
+
+ return (
+ <div>
+ <Dropdown>
+ <Dropdown.Toggle
+ as={Button}
+ disabled={isLoading}
+ variant="outline-primary"
+ id="dropdown-basic">
+ {t('approve', { keyPrefix: 'btns' })}
+ </Dropdown.Toggle>
+
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={() => handleActionEdit()}>
+ {t('edit_post')}
+ </Dropdown.Item>
+ {curFilter === 'normal' && objectType === 'question' && (
+ <Dropdown.Item onClick={() => handleAction('close')}>
+ {t('close', { keyPrefix: 'btns' })}
+ </Dropdown.Item>
+ )}
+ {curFilter !== 'deleted' && (
+ <Dropdown.Item onClick={() => handleAction('delete')}>
+ {t('delete', { keyPrefix: 'btns' })}
+ </Dropdown.Item>
+ )}
+ {objectType === 'question' && (
+ <>
+ <Dropdown.Divider />
+ {itemData?.object_show_status !== 2 && (
+ <Dropdown.Item onClick={() => handleAction('unlist')}>
+ {t('unlist_post')}
+ </Dropdown.Item>
+ )}
+ </>
+ )}
+ </Dropdown.Menu>
+ </Dropdown>
+ <EditPostModal
+ visible={showEditPostModal}
+ handleClose={handleEditPostModalState}
+ objectType={objectType}
+ originalData={{
+ flag_id: itemData?.flag_id || '',
+ id: itemData?.object_id || '',
+ title: itemData?.title || '',
+ content: itemData?.original_text || '',
+ tags: itemData?.tags || [],
+ question_id: itemData?.question_id || '',
+ answer_id: itemData?.answer_id || '',
+ }}
+ callback={approveCallback}
+ />
+ </div>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Review/components/EditPostModal/index.scss
b/ui/src/pages/Review/components/EditPostModal/index.scss
new file mode 100644
index 00000000..550155b2
--- /dev/null
+++ b/ui/src/pages/Review/components/EditPostModal/index.scss
@@ -0,0 +1,4 @@
+.edit-post-modal {
+ max-width: 746px;
+ width: 58.3333% !important;
+}
diff --git a/ui/src/pages/Review/components/EditPostModal/index.tsx
b/ui/src/pages/Review/components/EditPostModal/index.tsx
new file mode 100644
index 00000000..e35a82ca
--- /dev/null
+++ b/ui/src/pages/Review/components/EditPostModal/index.tsx
@@ -0,0 +1,375 @@
+import { FC, useState, useEffect } from 'react';
+import { Modal, Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import classNames from 'classnames';
+
+import { putFlagReviewAction } from '@/services';
+import { useCaptchaModal, usePageUsers } from '@/hooks';
+import { Editor, TagSelector, Mentions, TextArea } from '@/components';
+import {
+ // matchedUsers,
+ parseUserInfo,
+ handleFormError,
+ parseEditMentionUser,
+} from '@/utils';
+import type * as Type from '@/common/interface';
+
+import './index.scss';
+
+interface Props {
+ originalData: {
+ id: string;
+ flag_id: string;
+ question_id?: string;
+ answer_id?: string;
+ title: string;
+ content: string;
+ tags: Type.Tag[];
+ };
+ objectType: Type.FlagReviewItem['object_type'] | '';
+ visible: boolean;
+ handleClose: () => void;
+ callback?: () => void;
+}
+
+interface FormDataItem {
+ title: Type.FormValue<string>;
+ tags: Type.FormValue<Type.Tag[]>;
+ content: Type.FormValue<string>;
+}
+
+const initFormData = {
+ title: {
+ value: '',
+ isInvalid: false,
+ errorMsg: '',
+ },
+ tags: {
+ value: [],
+ isInvalid: false,
+ errorMsg: '',
+ },
+ content: {
+ value: '',
+ isInvalid: false,
+ errorMsg: '',
+ },
+};
+
+const Index: FC<Props> = ({
+ originalData,
+ visible = false,
+ objectType,
+ handleClose,
+ callback,
+}) => {
+ const { t } = useTranslation('translation', { keyPrefix: 'ask' });
+ const [formData, setFormData] = useState<FormDataItem>(initFormData);
+ const [focusEditor, setFocusEditor] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const pageUsers = usePageUsers();
+
+ const editCaptcha = useCaptchaModal('edit');
+
+ const onClose = (bol) => {
+ if (bol) {
+ callback?.();
+ }
+ handleClose();
+ setLoaded(false);
+ };
+
+ const handleInput = (data: Partial<FormDataItem>) => {
+ if (!loaded) {
+ return;
+ }
+ setFormData({
+ ...formData,
+ ...data,
+ });
+ };
+
+ const checkValidated = (): boolean => {
+ let bol = true;
+ const { title, tags, content } = formData;
+ if (objectType === 'question') {
+ if (!title.value) {
+ bol = false;
+ formData.title = {
+ value: title.value,
+ isInvalid: true,
+ errorMsg: t('form.fields.title.msg.empty', {
+ keyPrefix: 'ask',
+ }),
+ };
+ }
+
+ if (!tags.value.length) {
+ bol = false;
+ formData.tags = {
+ value: tags.value,
+ isInvalid: true,
+ errorMsg: t('form.fields.tags.msg.empty', {
+ keyPrefix: 'ask',
+ }),
+ };
+ }
+ }
+
+ if (!content.value || Array.from(content.value.trim()).length < 6) {
+ bol = false;
+ formData.content = {
+ value: content.value,
+ isInvalid: true,
+ errorMsg: t('form.fields.answer.feedback.characters', {
+ keyPrefix: 'edit_answer',
+ }),
+ };
+ } else {
+ formData.content = {
+ value: content.value,
+ isInvalid: false,
+ errorMsg: '',
+ };
+ }
+
+ setFormData({
+ ...formData,
+ });
+ return bol;
+ };
+
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!checkValidated()) {
+ return;
+ }
+
+ const params: Type.PutFlagReviewParams = {
+ title: formData.title.value,
+ content: formData.content.value,
+ tags: formData.tags.value,
+ operation_type: 'edit_post',
+ flag_id: originalData.flag_id,
+ };
+ if (objectType === 'answer') {
+ delete params.title;
+ delete params.tags;
+ }
+ if (objectType === 'comment') {
+ const { value } = formData.content;
+ // const users = matchedUsers(value);
+ // const userNames = unionBy(users.map((user) => user.userName));
+ const commentMarkDown = parseUserInfo(value);
+
+ // params.mention_username_list = userNames;
+ params.content = commentMarkDown;
+
+ delete params.title;
+ delete params.tags;
+ }
+
+ editCaptcha.check(() => {
+ if (objectType === 'question') {
+ const imgCode = editCaptcha.getCaptcha();
+ if (imgCode.verify) {
+ params.captcha_code = imgCode.captcha_code;
+ params.captcha_id = imgCode.captcha_id;
+ }
+ }
+ putFlagReviewAction(params)
+ .then(async () => {
+ await editCaptcha.close();
+ onClose(true);
+ })
+ .catch((err) => {
+ if (err.isError) {
+ editCaptcha.handleCaptchaError(err.list);
+ const data = handleFormError(err, formData);
+ setFormData({ ...data });
+ }
+ });
+ });
+ };
+
+ const handleSelected = (val) => {
+ if (!loaded) {
+ return;
+ }
+ setFormData({
+ ...formData,
+ content: {
+ value: val,
+ errorMsg: '',
+ isInvalid: false,
+ },
+ });
+ };
+
+ useEffect(() => {
+ if (!visible) {
+ return;
+ }
+
+ formData.title.value = originalData.title;
+ formData.content.value = originalData.content;
+ formData.tags.value = originalData.tags.map((item) => {
+ return {
+ ...item,
+ parsed_text: '',
+ original_text: '',
+ };
+ });
+ setFormData({ ...formData });
+ setLoaded(true);
+ }, [visible]);
+
+ return (
+ <Modal
+ show={visible}
+ onHide={() => onClose(false)}
+ className="w-100"
+ dialogClassName="edit-post-modal">
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {t('edit_post', { keyPrefix: 'page_review' })}
+ </Modal.Title>
+ </Modal.Header>
+ <Form noValidate onSubmit={handleSubmit}>
+ <Modal.Body>
+ {objectType === 'question' && (
+ <Form.Group controlId="title" className="mb-3">
+ <Form.Label>{t('form.fields.title.label')}</Form.Label>
+ <Form.Control
+ type="text"
+ value={formData.title.value}
+ isInvalid={formData.title.isInvalid}
+ onChange={(e) => {
+ handleInput({
+ title: {
+ value: e.target.value,
+ isInvalid: false,
+ errorMsg: '',
+ },
+ });
+ }}
+ placeholder={t('form.fields.title.placeholder')}
+ autoFocus
+ contentEditable
+ />
+
+ <Form.Control.Feedback type="invalid">
+ {formData.title.errorMsg}
+ </Form.Control.Feedback>
+ </Form.Group>
+ )}
+
+ {objectType !== 'comment' && (
+ <Form.Group controlId="body">
+ <Form.Label>
+ {objectType === 'question'
+ ? t('form.fields.body.label')
+ : t('form.fields.answer.label')}
+ </Form.Label>
+ <Form.Control
+ defaultValue={formData.content.value}
+ isInvalid={formData.content.isInvalid}
+ hidden
+ />
+ <Editor
+ value={formData.content.value}
+ onChange={(value) => {
+ handleInput({
+ content: { value, errorMsg: '', isInvalid: false },
+ });
+ }}
+ className={classNames(
+ 'form-control p-0',
+ focusEditor ? 'focus' : '',
+ )}
+ onFocus={() => {
+ setFocusEditor(true);
+ }}
+ onBlur={() => {
+ setFocusEditor(false);
+ }}
+ />
+ <Form.Control.Feedback type="invalid">
+ {formData.content.errorMsg}
+ </Form.Control.Feedback>
+ </Form.Group>
+ )}
+
+ {objectType === 'question' && (
+ <Form.Group controlId="tags" className="my-3">
+ <Form.Label>{t('form.fields.tags.label')}</Form.Label>
+ <Form.Control
+ defaultValue={JSON.stringify(formData.tags.value)}
+ isInvalid={formData.tags.isInvalid}
+ hidden
+ />
+ <TagSelector
+ value={formData.tags.value}
+ onChange={(value) => {
+ handleInput({
+ tags: { value, errorMsg: '', isInvalid: false },
+ });
+ }}
+ showRequiredTag
+ maxTagLength={5}
+ />
+ <Form.Control.Feedback type="invalid">
+ {formData.tags.errorMsg}
+ </Form.Control.Feedback>
+ </Form.Group>
+ )}
+
+ {objectType === 'comment' && (
+ <div className="w-100">
+ <div
+ className={classNames('custom-form-control', {
+ 'is-invalid': formData.content.isInvalid,
+ })}>
+ <Form.Label>Comment</Form.Label>
+ <Mentions
+ pageUsers={pageUsers.getUsers()}
+ onSelected={handleSelected}>
+ <TextArea
+ size="sm"
+ rows={4}
+ value={parseEditMentionUser(formData.content.value)}
+ onChange={(e) => {
+ handleInput({
+ content: {
+ value: e.target.value,
+ errorMsg: '',
+ isInvalid: false,
+ },
+ });
+ }}
+ />
+ </Mentions>
+ </div>
+ <Form.Control.Feedback type="invalid">
+ {formData.content.errorMsg}
+ </Form.Control.Feedback>
+ </div>
+ )}
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={() => onClose(false)}>
+ {t('close', { keyPrefix: 'btns' })}
+ </Button>
+ <Button variant="primary" type="submit">
+ {t('submit', { keyPrefix: 'btns' })}
+ </Button>
+ </Modal.Footer>
+ </Form>
+ </Modal>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Review/components/FlagContent/index.tsx
b/ui/src/pages/Review/components/FlagContent/index.tsx
new file mode 100644
index 00000000..11056c89
--- /dev/null
+++ b/ui/src/pages/Review/components/FlagContent/index.tsx
@@ -0,0 +1,210 @@
+import { FC, useEffect, useState } from 'react';
+import { Card, Alert, Stack, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+import classNames from 'classnames';
+
+import { getFlagReviewPostList, putFlagReviewAction } from '@/services';
+import { BaseUserCard, Tag, FormatTime } from '@/components';
+import { scrollToDocTop } from '@/utils';
+import type * as Type from '@/common/interface';
+import { ADMIN_LIST_STATUS } from '@/common/constants';
+import ApproveDropdown from '../ApproveDropdown';
+import generateData from '../../utils/generateData';
+
+interface IProps {
+ refreshCount: () => void;
+}
+
+const Index: FC<IProps> = ({ refreshCount }) => {
+ const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
+ const [noTasks, setNoTasks] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [reviewResp, setReviewResp] = useState<Type.FlagReviewResp>();
+ const flagItemData = reviewResp?.list[0] as Type.FlagReviewItem;
+
+ // console.log('reviewResp', reviewResp);
+
+ const resolveNextOne = (resp, pageNumber) => {
+ const { count, list = [] } = resp;
+ // auto rollback
+ if (!list.length && count && page !== 1) {
+ pageNumber = 1;
+ setPage(pageNumber);
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ queryNextOne(pageNumber);
+ return;
+ }
+ if (pageNumber !== page) {
+ setPage(pageNumber);
+ }
+ setReviewResp(resp);
+ if (!list.length) {
+ setNoTasks(true);
+ }
+ setTimeout(() => {
+ scrollToDocTop();
+ }, 150);
+ };
+
+ const queryNextOne = (pageNumber) => {
+ getFlagReviewPostList(pageNumber)
+ .then((resp) => {
+ resolveNextOne(resp, pageNumber);
+ })
+ .catch((ex) => {
+ console.error('review next error: ', ex);
+ });
+ };
+
+ useEffect(() => {
+ queryNextOne(page);
+ }, []);
+
+ const handlingApprove = () => {
+ if (!flagItemData) {
+ return;
+ }
+ refreshCount();
+ queryNextOne(page);
+ };
+
+ const handleIgnore = () => {
+ setIsLoading(true);
+ putFlagReviewAction({
+ operation_type: 'ignore_report',
+ flag_id: String(flagItemData?.flag_id),
+ })
+ .then(() => {
+ refreshCount();
+ queryNextOne(page);
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ };
+
+ const {
+ object_type,
+ submitter_user,
+ author_user_info,
+ object_status,
+ reason,
+ } = flagItemData || {
+ object_type: '',
+ submitter_user: null,
+ author_user_info: null,
+ reason: null,
+ object_status: 0,
+ };
+
+ const { itemLink, itemId, itemTimePrefix } = generateData(flagItemData);
+
+ if (noTasks) return null;
+ return (
+ <Card>
+ <Card.Header>
+ {object_type !== 'user' ? t('flag_post') : t('flag_user')}
+ </Card.Header>
+ <Card.Body className="p-0">
+ <Alert variant="info" className="border-0 rounded-0 mb-0">
+ <Stack
+ direction="horizontal"
+ gap={1}
+ className="align-items-center mb-2">
+ <BaseUserCard data={submitter_user} avatarSize="24" />
+ {flagItemData?.submit_at && (
+ <FormatTime
+ time={flagItemData.submit_at}
+ className="small text-secondary"
+ preFix={t('proposed')}
+ />
+ )}
+ </Stack>
+ <Stack className="align-items-start">
+ <p className="mb-0">
+ {object_type !== 'user'
+ ? t('flag_post_type', { type: reason?.name })
+ : t('flag_user_type', { type: reason?.name })}
+ </p>
+ </Stack>
+ </Alert>
+ <div className="p-3">
+ <small className="d-block text-secondary mb-4">
+ <span>{t(object_type, { keyPrefix: 'btns' })} </span>
+ <Link to={itemLink} target="_blank" className="link-secondary">
+ #{itemId}
+ </Link>
+ </small>
+ {object_type === 'question' && (
+ <>
+ <h5 className="mb-3">{flagItemData?.title}</h5>
+ <div className="mb-4">
+ {flagItemData?.tags?.map((item) => {
+ return (
+ <Tag key={item.slug_name} className="me-1" data={item} />
+ );
+ })}
+ </div>
+ </>
+ )}
+ <div className="small font-monospace">
+ {flagItemData?.original_text}
+ </div>
+ <div className="d-flex flex-wrap align-items-center
justify-content-between mt-4">
+ <div>
+ <span
+ className={classNames(
+ 'badge',
+ ADMIN_LIST_STATUS[object_status]?.variant,
+ )}>
+ {t(ADMIN_LIST_STATUS[object_status]?.name, {
+ keyPrefix: 'btns',
+ })}
+ </span>
+ {flagItemData?.object_show_status === 2 && (
+ <span
+ className={classNames(
+ 'ms-1 badge',
+ ADMIN_LIST_STATUS.unlist.variant,
+ )}>
+ {t(ADMIN_LIST_STATUS.unlist.name, { keyPrefix: 'btns' })}
+ </span>
+ )}
+ </div>
+ <div className="d-flex align-items-center small">
+ <BaseUserCard data={author_user_info} avatarSize="24" />
+ <FormatTime
+ time={Number(flagItemData?.created_at)}
+ className="text-secondary ms-1 flex-shrink-0"
+ preFix={t(itemTimePrefix, { keyPrefix: 'question_detail' })}
+ />
+ </div>
+ </div>
+ </div>
+ </Card.Body>
+
+ <Card.Footer className="p-3">
+ <p>{t('approve_flag_tip')}</p>
+ <Stack direction="horizontal" gap={2}>
+ <ApproveDropdown
+ objectType={object_type}
+ itemData={flagItemData}
+ curFilter={ADMIN_LIST_STATUS[object_status]?.name}
+ approveCallback={handlingApprove}
+ />
+ <Button
+ variant="outline-primary"
+ disabled={isLoading}
+ onClick={handleIgnore}>
+ {t('ignore', { keyPrefix: 'btns' })}
+ </Button>
+ </Stack>
+ </Card.Footer>
+ </Card>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Review/components/QueuedContent/index.tsx
b/ui/src/pages/Review/components/QueuedContent/index.tsx
new file mode 100644
index 00000000..bd22d297
--- /dev/null
+++ b/ui/src/pages/Review/components/QueuedContent/index.tsx
@@ -0,0 +1,203 @@
+import { FC, useEffect, useState } from 'react';
+import { Card, Alert, Stack, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { Link, useSearchParams } from 'react-router-dom';
+
+import classNames from 'classnames';
+
+import { getPendingReviewPostList, putPendingReviewAction } from '@/services';
+import { BaseUserCard, Tag, FormatTime, Icon } from '@/components';
+import { scrollToDocTop } from '@/utils';
+import type * as Type from '@/common/interface';
+import { ADMIN_LIST_STATUS } from '@/common/constants';
+import generateData from '../../utils/generateData';
+
+interface IProps {
+ refreshCount: () => void;
+}
+
+const Index: FC<IProps> = ({ refreshCount }) => {
+ const [urlSearch, setUrlSearchParams] = useSearchParams();
+ const objectId = urlSearch.get('objectId') || '';
+ const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
+ const [noTasks, setNoTasks] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [reviewResp, setReviewResp] = useState<Type.QuestionDetailRes>();
+ const flagItemData = reviewResp?.list[0] as Type.QueuedReviewItem;
+
+ // console.log('pendingResp', reviewResp);
+
+ const resolveNextOne = (resp, pageNumber) => {
+ const { count, list = [] } = resp;
+ // auto rollback
+ if (!list.length && count && page !== 1) {
+ pageNumber = 1;
+ setPage(pageNumber);
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ queryNextOne(pageNumber, '');
+ return;
+ }
+ if (pageNumber !== page) {
+ setPage(pageNumber);
+ }
+ setReviewResp(resp);
+ if (!list.length) {
+ setNoTasks(true);
+ }
+ setTimeout(() => {
+ scrollToDocTop();
+ }, 150);
+ };
+
+ const queryNextOne = (pageNumber, id) => {
+ getPendingReviewPostList(pageNumber, id).then((resp) => {
+ resolveNextOne(resp, pageNumber);
+ });
+ };
+
+ useEffect(() => {
+ queryNextOne(page, objectId);
+ }, []);
+
+ const handleAction = (type: 'approve' | 'reject') => {
+ if (!flagItemData) {
+ return;
+ }
+ setIsLoading(true);
+ putPendingReviewAction({
+ status: type,
+ review_id: flagItemData?.review_id,
+ })
+ .then(() => {
+ refreshCount();
+ queryNextOne(page, '');
+ if (objectId) {
+ urlSearch.delete('objectId');
+ setUrlSearchParams(urlSearch);
+ }
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ };
+
+ const { object_type, author_user_info, object_status, reason } =
+ flagItemData || {
+ object_type: '',
+ author_user_info: null,
+ reason: null,
+ object_status: 0,
+ };
+
+ const { itemLink, itemId, itemTimePrefix } = generateData(flagItemData);
+
+ if (noTasks) return null;
+ return (
+ <Card>
+ <Card.Header>
+ {object_type !== 'user' ? t('queued_post') : t('queued_post_user')}
+ </Card.Header>
+ <Card.Body className="p-0">
+ <Alert variant="info" className="border-0 rounded-0 mb-0">
+ <Stack
+ direction="horizontal"
+ gap={1}
+ className="align-items-center mb-2">
+ <div className="small d-flex align-items-center">
+ <Icon type="bi" name="plugin" size="24px" className="me-1" />
+ <span>{flagItemData?.submitter_display_name}</span>
+ </div>
+ {flagItemData?.submit_at && (
+ <FormatTime
+ time={flagItemData.submit_at}
+ className="small text-secondary"
+ preFix={t('proposed')}
+ />
+ )}
+ </Stack>
+ <Stack className="align-items-start">
+ <p className="mb-0">{reason}</p>
+ </Stack>
+ </Alert>
+ <div className="p-3">
+ <small className="d-block text-secondary mb-4">
+ <span>{t(object_type, { keyPrefix: 'btns' })} </span>
+ <Link to={itemLink} target="_blank" className="link-secondary">
+ #{itemId}
+ </Link>
+ </small>
+ {object_type === 'question' && (
+ <>
+ <h5 className="mb-3">{flagItemData?.title}</h5>
+ <div className="mb-4">
+ {flagItemData?.tags?.map((item) => {
+ return (
+ <Tag key={item.slug_name} className="me-1" data={item} />
+ );
+ })}
+ </div>
+ </>
+ )}
+ <div className="small font-monospace">
+ {flagItemData?.original_text}
+ </div>
+ <div className="d-flex flex-wrap align-items-center
justify-content-between mt-4">
+ <div>
+ <span
+ className={classNames(
+ 'badge',
+ ADMIN_LIST_STATUS[object_status]?.variant,
+ )}>
+ {t(ADMIN_LIST_STATUS[object_status]?.name, {
+ keyPrefix: 'btns',
+ })}
+ </span>
+ {flagItemData?.object_show_status === 2 && (
+ <span
+ className={classNames(
+ 'ms-1 badge',
+ ADMIN_LIST_STATUS.unlist.variant,
+ )}>
+ {t(ADMIN_LIST_STATUS.unlist.name, { keyPrefix: 'btns' })}
+ </span>
+ )}
+ </div>
+ <div className="d-flex align-items-center small">
+ <BaseUserCard data={author_user_info} avatarSize="24" />
+ <FormatTime
+ time={Number(flagItemData?.created_at)}
+ className="text-secondary ms-1 flex-shrink-0"
+ preFix={t(itemTimePrefix, { keyPrefix: 'question_detail' })}
+ />
+ </div>
+ </div>
+ </div>
+ </Card.Body>
+
+ <Card.Footer className="p-3">
+ <p>
+ {object_type !== 'user'
+ ? t('approve_post_tip')
+ : t('approve_user_tip')}
+ </p>
+ <Stack direction="horizontal" gap={2}>
+ <Button
+ variant="outline-primary"
+ disabled={isLoading}
+ onClick={() => handleAction('approve')}>
+ {t('approve', { keyPrefix: 'btns' })}
+ </Button>
+ <Button
+ variant="outline-primary"
+ disabled={isLoading}
+ onClick={() => handleAction('reject')}>
+ {t('reject', { keyPrefix: 'btns' })}
+ </Button>
+ </Stack>
+ </Card.Footer>
+ </Card>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Review/components/ReviewType/index.tsx
b/ui/src/pages/Review/components/ReviewType/index.tsx
new file mode 100644
index 00000000..16a392e2
--- /dev/null
+++ b/ui/src/pages/Review/components/ReviewType/index.tsx
@@ -0,0 +1,40 @@
+import { FC } from 'react';
+import { Card, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import * as Type from '@/common/interface';
+
+interface IProps {
+ list: Type.ReviewTypeItem[] | undefined;
+ checked: string;
+ callback: (type: string) => void;
+}
+
+const Index: FC<IProps> = ({ list, checked, callback }) => {
+ const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
+ return (
+ <Card>
+ <Card.Header>{t('filter', { keyPrefix: 'btns' })}</Card.Header>
+ <Card.Body>
+ <Form.Group>
+ <Form.Label>{t('filter_label')}</Form.Label>
+ {list?.map((item) => {
+ return (
+ <Form.Check
+ key={item.name}
+ type="radio"
+ id={item.name}
+ disabled={item.todo_amount <= 0}
+ label={`${item.label} (${item.todo_amount})`}
+ checked={checked === item.name}
+ onChange={() => callback(item.name)}
+ />
+ );
+ })}
+ </Form.Group>
+ </Card.Body>
+ </Card>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Review/components/SuggestContent/index.tsx
b/ui/src/pages/Review/components/SuggestContent/index.tsx
new file mode 100644
index 00000000..8b618a91
--- /dev/null
+++ b/ui/src/pages/Review/components/SuggestContent/index.tsx
@@ -0,0 +1,238 @@
+/*
+ * 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 { FC, useEffect, useState } from 'react';
+import { Alert, Stack, Button, Card } from 'react-bootstrap';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import { BaseUserCard, FormatTime, DiffContent } from '@/components';
+import { getSuggestReviewList, revisionAudit } from '@/services';
+import { pathFactory } from '@/router/pathFactory';
+import { scrollToDocTop } from '@/utils';
+import type * as Type from '@/common/interface';
+
+interface IProps {
+ refreshCount: () => void;
+}
+
+const Index: FC<IProps> = ({ refreshCount }) => {
+ const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
+ const [isLoading, setIsLoading] = useState(false);
+ const [noTasks, setNoTasks] = useState(false);
+ const [page, setPage] = useState(1);
+ const [reviewResp, setReviewResp] = useState<Type.SuggestReviewResp>();
+ const ro = reviewResp?.list[0];
+ const { info, type, unreviewed_info } = ro || {
+ info: null,
+ type: '',
+ unreviewed_info: null,
+ };
+ const resolveNextOne = (resp, pageNumber) => {
+ const { count, list = [] } = resp;
+ // auto rollback
+ if (!list.length && count && page !== 1) {
+ pageNumber = 1;
+ setPage(pageNumber);
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ queryNextOne(pageNumber);
+ return;
+ }
+ if (pageNumber !== page) {
+ setPage(pageNumber);
+ }
+ setReviewResp(resp);
+ if (!list.length) {
+ setNoTasks(true);
+ }
+ setTimeout(() => {
+ scrollToDocTop();
+ }, 150);
+ };
+ const queryNextOne = (pageNumber) => {
+ getSuggestReviewList(pageNumber).then((resp) => {
+ resolveNextOne(resp, pageNumber);
+ });
+ };
+ const reviewInfo = unreviewed_info?.content;
+
+ const handlingApprove = () => {
+ if (!unreviewed_info) {
+ return;
+ }
+ setIsLoading(true);
+ revisionAudit(unreviewed_info.id, 'approve')
+ .then(() => {
+ refreshCount();
+ queryNextOne(page);
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ };
+
+ const handlingReject = () => {
+ if (!unreviewed_info) {
+ return;
+ }
+ setIsLoading(true);
+ revisionAudit(unreviewed_info.id, 'reject')
+ .then(() => {
+ refreshCount();
+ queryNextOne(page);
+ })
+ .catch((ex) => {
+ console.error('revisionAudit reject error: ', ex);
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ };
+
+ let itemLink = '';
+ let itemId = '';
+ let editSummary = unreviewed_info?.reason;
+ const editor = unreviewed_info?.user_info;
+ const editTime = unreviewed_info?.create_at;
+ if (type === 'question') {
+ itemLink = pathFactory.questionLanding(info?.object_id, info?.url_title);
+ itemId = info?.object_id;
+ editSummary ||= t('edit_question');
+ } else if (type === 'answer') {
+ itemLink = pathFactory.answerLanding({
+ // @ts-ignore
+ questionId: unreviewed_info.content.question_id,
+ slugTitle: info?.url_title,
+ answerId: unreviewed_info.object_id,
+ });
+ itemId = unreviewed_info.object_id;
+ editSummary ||= t('edit_answer');
+ } else if (type === 'tag') {
+ const tagInfo = unreviewed_info.content as Type.Tag;
+ itemLink = pathFactory.tagLanding(tagInfo.slug_name);
+ itemId = tagInfo?.tag_id || tagInfo.slug_name;
+ editSummary ||= t('edit_tag');
+ }
+ useEffect(() => {
+ queryNextOne(page);
+ }, []);
+
+ if (noTasks) return null;
+
+ let newData: Record<string, any> = {};
+ let oldData: Record<string, any> = {};
+ let diffOpts: Partial<{
+ showTitle: boolean;
+ showTagUrlSlug: boolean;
+ }> = {
+ showTitle: true,
+ showTagUrlSlug: true,
+ };
+ if (type === 'question' && info && reviewInfo && 'content' in reviewInfo) {
+ newData = {
+ title: reviewInfo.title,
+ original_text: reviewInfo.content,
+ tags: reviewInfo.tags,
+ };
+ oldData = {
+ title: info.title,
+ original_text: info.content,
+ tags: info.tags,
+ };
+ }
+ if (type === 'answer' && info && reviewInfo && 'content' in reviewInfo) {
+ newData = {
+ original_text: reviewInfo.content,
+ };
+ oldData = {
+ original_text: info.content,
+ };
+ }
+
+ if (type === 'tag' && info && reviewInfo) {
+ newData = {
+ original_text: reviewInfo.original_text,
+ };
+ oldData = {
+ original_text: info.content,
+ };
+ diffOpts = { showTitle: false, showTagUrlSlug: false };
+ }
+
+ return (
+ <Card>
+ <Card.Header>{t('suggest_edits')}</Card.Header>
+ <Card.Body className="p-0">
+ <Alert variant="info" className="border-0 rounded-0 mb-0">
+ <Stack
+ direction="horizontal"
+ gap={1}
+ className="align-items-center mb-2">
+ <BaseUserCard data={editor} avatarSize="24" />
+ {editTime && (
+ <FormatTime
+ time={editTime}
+ className="small text-secondary"
+ preFix={t('proposed')}
+ />
+ )}
+ </Stack>
+ <Stack className="align-items-start">
+ <p className="mb-0">{editSummary}</p>
+ </Stack>
+ </Alert>
+ <div className="p-3">
+ <small className="d-block text-secondary mb-4">
+ <span>{t(type, { keyPrefix: 'btns' })} </span>
+ <Link to={itemLink} target="_blank" className="link-secondary">
+ #{itemId}
+ </Link>
+ </small>
+
+ <DiffContent
+ className="mt-2"
+ objectType={type}
+ newData={newData}
+ oldData={oldData}
+ opts={diffOpts}
+ />
+ </div>
+ </Card.Body>
+ <Card.Footer className="p-3">
+ <p>{t('approve_revision_tip')}</p>
+ <Stack direction="horizontal" gap={2}>
+ <Button
+ variant="outline-primary"
+ disabled={isLoading}
+ onClick={handlingApprove}>
+ {t('approve', { keyPrefix: 'btns' })}
+ </Button>
+ <Button
+ variant="outline-primary"
+ disabled={isLoading}
+ onClick={handlingReject}>
+ {t('reject', { keyPrefix: 'btns' })}
+ </Button>
+ </Stack>
+ </Card.Footer>
+ </Card>
+ );
+};
+
+export default Index;
diff --git a/ui/src/pages/Review/components/index.ts
b/ui/src/pages/Review/components/index.ts
new file mode 100644
index 00000000..0e02a69b
--- /dev/null
+++ b/ui/src/pages/Review/components/index.ts
@@ -0,0 +1,15 @@
+import ReviewType from './ReviewType';
+import ApproveDropdown from './ApproveDropdown';
+import EditPostModal from './EditPostModal';
+import SuggestContent from './SuggestContent';
+import FlagContent from './FlagContent';
+import QueuedContent from './QueuedContent';
+
+export {
+ ReviewType,
+ ApproveDropdown,
+ EditPostModal,
+ FlagContent,
+ SuggestContent,
+ QueuedContent,
+};
diff --git a/ui/src/pages/Review/index.tsx b/ui/src/pages/Review/index.tsx
index 927e95a1..4febbb9e 100644
--- a/ui/src/pages/Review/index.tsx
+++ b/ui/src/pages/Review/index.tsx
@@ -18,235 +18,103 @@
*/
import { FC, useEffect, useState } from 'react';
-import { Row, Col, Alert, Stack, Button } from 'react-bootstrap';
-import { Link } from 'react-router-dom';
+import { Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
+import { useSearchParams } from 'react-router-dom';
import { usePageTags } from '@/hooks';
-import { BaseUserCard, FormatTime, Empty, DiffContent } from '@/components';
-import { getReviewList, revisionAudit } from '@/services';
-import { pathFactory } from '@/router/pathFactory';
-import { scrollToDocTop } from '@/utils';
+import { Empty } from '@/components';
+import { getReviewType } from '@/services';
import type * as Type from '@/common/interface';
+import {
+ ReviewType,
+ FlagContent,
+ SuggestContent,
+ QueuedContent,
+} from './components';
+
const Index: FC = () => {
+ const [urlSearch, setUrlSearchParams] = useSearchParams();
+ const searchType = urlSearch.get('type');
const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
- const [isLoading, setIsLoading] = useState(false);
- const [noTasks, setNoTasks] = useState(false);
- const [page, setPage] = useState(1);
- const [reviewResp, setReviewResp] = useState<Type.ReviewResp>();
- const ro = reviewResp?.list[0];
- const { info, type, unreviewed_info } = ro || {
- info: null,
- type: '',
- unreviewed_info: null,
- };
- const resolveNextOne = (resp, pageNumber) => {
- const { count, list = [] } = resp;
- // auto rollback
- if (!list.length && count && page !== 1) {
- pageNumber = 1;
- setPage(pageNumber);
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- queryNextOne(pageNumber);
- return;
- }
- if (pageNumber !== page) {
- setPage(pageNumber);
- }
- setReviewResp(resp);
- if (!list.length) {
- setNoTasks(true);
- }
- setTimeout(() => {
- scrollToDocTop();
- }, 150);
- };
- const queryNextOne = (pageNumber) => {
- getReviewList(pageNumber)
+ const [reviewTypeList, setReviewTypeList] =
useState<Type.ReviewTypeItem[]>();
+ const [currentReviewType, setCurrentReviewType] = useState('');
+ const [isEmpty, setIsEmpty] = useState(false);
+
+ const fetchReviewType = (changeReviewType: boolean) => {
+ getReviewType()
.then((resp) => {
- resolveNextOne(resp, pageNumber);
+ if (searchType) {
+ const filterData = resp.find((item) => item.name === searchType);
+ if (Number(filterData?.todo_amount) > 0) {
+ setCurrentReviewType(filterData?.name || '');
+ } else {
+ setIsEmpty(true);
+ }
+ } else {
+ const filterData = resp.filter((item) => item.todo_amount > 0);
+ if (filterData.length > 0) {
+ if (changeReviewType) {
+ setCurrentReviewType(filterData[0].name);
+ } else {
+ const currentTypeItem = resp.find(
+ (item) => item.name === currentReviewType,
+ );
+ if (currentTypeItem?.todo_amount === 0) {
+ setCurrentReviewType(filterData[0].name);
+ }
+ }
+ } else {
+ setIsEmpty(true);
+ }
+ }
+ setReviewTypeList(resp);
})
.catch((ex) => {
- console.error('review next error: ', ex);
+ console.error('getReviewType error: ', ex);
});
};
- const reviewInfo = unreviewed_info?.content;
- const handlingSkip = () => {
- queryNextOne(page + 1);
- };
- const handlingApprove = () => {
- if (!unreviewed_info) {
- return;
- }
- setIsLoading(true);
- revisionAudit(unreviewed_info.id, 'approve')
- .then(() => {
- queryNextOne(page);
- })
- .catch((ex) => {
- console.error('revisionAudit approve error: ', ex);
- })
- .finally(() => {
- setIsLoading(false);
- });
- };
- const handlingReject = () => {
- if (!unreviewed_info) {
- return;
- }
- setIsLoading(true);
- revisionAudit(unreviewed_info.id, 'reject')
- .then(() => {
- queryNextOne(page);
- })
- .catch((ex) => {
- console.error('revisionAudit reject error: ', ex);
- })
- .finally(() => {
- setIsLoading(false);
- });
+
+ const handleTypeChange = (name) => {
+ urlSearch.delete('type');
+ setUrlSearchParams(urlSearch);
+ setCurrentReviewType(name);
};
- let itemLink = '';
- let itemTitle = '';
- let editBadge = '';
- let editSummary = unreviewed_info?.reason;
- const editor = unreviewed_info?.user_info;
- const editTime = unreviewed_info?.create_at;
- if (type === 'question') {
- itemLink = pathFactory.questionLanding(info?.object_id, info?.url_title);
- itemTitle = info?.title;
- editBadge = t('question_edit');
- editSummary ||= t('edit_question');
- } else if (type === 'answer') {
- itemLink = pathFactory.answerLanding({
- // @ts-ignore
- questionId: unreviewed_info.content.question_id,
- slugTitle: info?.url_title,
- answerId: unreviewed_info.object_id,
- });
- itemTitle = info?.title;
- editBadge = t('answer_edit');
- editSummary ||= t('edit_answer');
- } else if (type === 'tag') {
- const tagInfo = unreviewed_info.content as Type.Tag;
- itemLink = pathFactory.tagLanding(tagInfo.slug_name);
- itemTitle = tagInfo.display_name;
- editBadge = t('tag_edit');
- editSummary ||= t('edit_tag');
- }
useEffect(() => {
- queryNextOne(page);
+ fetchReviewType(true);
}, []);
+
usePageTags({
title: t('review'),
});
+
return (
<Row className="pt-4 mb-5">
<h3 className="mb-4">{t('review')}</h3>
<Col className="page-main flex-auto">
- {!noTasks && ro && (
- <>
- <Alert variant="secondary">
- <Stack className="align-items-start">
- <span className="badge text-bg-secondary mb-2">
- {editBadge}
- </span>
- <Link to={itemLink} target="_blank">
- {itemTitle}
- </Link>
- <p className="mb-0">
- {t('edit_summary')}: {editSummary}
- </p>
- </Stack>
- <Stack
- direction="horizontal"
- gap={1}
- className="align-items-baseline mt-2">
- <BaseUserCard data={editor} avatarSize="24" />
- {editTime && (
- <FormatTime
- time={editTime}
- className="small text-secondary"
- preFix={t('proposed')}
- />
- )}
- </Stack>
- </Alert>
- {type === 'question' &&
- info &&
- reviewInfo &&
- 'content' in reviewInfo && (
- <DiffContent
- className="mt-2"
- objectType={type}
- oldData={{
- title: info.title,
- original_text: info.content,
- tags: info.tags,
- }}
- newData={{
- title: reviewInfo.title,
- original_text: reviewInfo.content,
- tags: reviewInfo.tags,
- }}
- />
- )}
- {type === 'answer' &&
- info &&
- reviewInfo &&
- 'content' in reviewInfo && (
- <DiffContent
- className="mt-2"
- objectType={type}
- newData={{
- original_text: reviewInfo.content,
- }}
- oldData={{
- original_text: info.content,
- }}
- />
- )}
- {type === 'tag' && info && reviewInfo && (
- <DiffContent
- className="mt-2"
- objectType={type}
- newData={{
- original_text: reviewInfo.original_text,
- }}
- oldData={{
- original_text: info.content,
- }}
- opts={{ showTitle: false, showTagUrlSlug: false }}
- />
- )}
- <Stack direction="horizontal" gap={2} className="mt-4">
- <Button
- variant="outline-primary"
- disabled={isLoading}
- onClick={handlingApprove}>
- {t('approve', { keyPrefix: 'btns' })}
- </Button>
- <Button
- variant="outline-primary"
- disabled={isLoading}
- onClick={handlingReject}>
- {t('reject', { keyPrefix: 'btns' })}
- </Button>
- <Button
- variant="outline-primary"
- disabled={isLoading}
- onClick={handlingSkip}>
- {t('skip', { keyPrefix: 'btns' })}
- </Button>
- </Stack>
- </>
+ {currentReviewType === 'suggested_post_edit' && (
+ <SuggestContent refreshCount={() => fetchReviewType(false)} />
)}
- {noTasks && <Empty>{t('empty')}</Empty>}
+
+ {currentReviewType === 'flagged_post' && (
+ <FlagContent refreshCount={() => fetchReviewType(false)} />
+ )}
+
+ {currentReviewType === 'queued_post' && (
+ <QueuedContent refreshCount={() => fetchReviewType(false)} />
+ )}
+ {isEmpty && <Empty>{t('empty')}</Empty>}
</Col>
- <Col className="page-right-side mt-4 mt-xl-0" />
+ <Col className="page-right-side mt-4 mt-xl-0">
+ <ReviewType
+ list={reviewTypeList}
+ checked={currentReviewType}
+ callback={handleTypeChange}
+ />
+ </Col>
</Row>
);
};
diff --git a/ui/src/pages/Review/utils/generateData.ts
b/ui/src/pages/Review/utils/generateData.ts
new file mode 100644
index 00000000..42d80726
--- /dev/null
+++ b/ui/src/pages/Review/utils/generateData.ts
@@ -0,0 +1,58 @@
+import { pathFactory } from '@/router/pathFactory';
+
+export default (data: any) => {
+ if (!data?.object_id) {
+ return {
+ itemLink: '',
+ itemId: '',
+ itemTimePrefix: '',
+ };
+ }
+
+ const {
+ object_type = '',
+ object_id = '',
+ question_id = '',
+ answer_id = '',
+ comment_id = '',
+ title = '',
+ } = data;
+ let itemLink = '';
+ let itemId = '';
+ let itemTimePrefix = '';
+
+ if (object_type === 'question') {
+ itemLink = pathFactory.questionLanding(String(object_id), title);
+ itemId = String(question_id);
+ itemTimePrefix = 'asked';
+ } else if (object_type === 'answer') {
+ itemLink = pathFactory.answerLanding({
+ // @ts-ignore
+ questionId: question_id,
+ slugTitle: title,
+ answerId: String(object_id),
+ });
+ itemId = String(object_id);
+ itemTimePrefix = 'answered';
+ } else if (object_type === 'comment') {
+ if (question_id && answer_id) {
+ itemLink = `${pathFactory.answerLanding({
+ questionId: question_id,
+ answerId: answer_id,
+ })}?commentId=${comment_id}`;
+ } else {
+ itemLink = `${pathFactory.questionLanding(
+ String(question_id),
+ title,
+ )}?commentId=${comment_id}`;
+ }
+ itemId = String(comment_id);
+ itemTimePrefix = 'commented';
+ }
+
+ return {
+ itemLink,
+ itemId,
+ itemTimePrefix,
+ };
+};
diff --git a/ui/src/pages/Users/Notifications/components/Achievements/index.tsx
b/ui/src/pages/Users/Notifications/components/Achievements/index.tsx
index d8a96ab3..5f2e8f58 100644
--- a/ui/src/pages/Users/Notifications/components/Achievements/index.tsx
+++ b/ui/src/pages/Users/Notifications/components/Achievements/index.tsx
@@ -21,7 +21,7 @@ import { ListGroup } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
-import { isEmpty } from 'lodash';
+import isEmpty from 'lodash/isEmpty';
import { Empty } from '@/components';
diff --git a/ui/src/pages/Users/Notifications/components/Inbox/index.tsx
b/ui/src/pages/Users/Notifications/components/Inbox/index.tsx
index 6e6e5c2f..64a2c34b 100644
--- a/ui/src/pages/Users/Notifications/components/Inbox/index.tsx
+++ b/ui/src/pages/Users/Notifications/components/Inbox/index.tsx
@@ -22,7 +22,7 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
-import { isEmpty } from 'lodash';
+import isEmpty from 'lodash/isEmpty';
import { FormatTime, Empty } from '@/components';
diff --git a/ui/src/pages/Users/Settings/Interface/index.tsx
b/ui/src/pages/Users/Settings/Interface/index.tsx
index 575995ed..046bccd6 100644
--- a/ui/src/pages/Users/Settings/Interface/index.tsx
+++ b/ui/src/pages/Users/Settings/Interface/index.tsx
@@ -71,6 +71,7 @@ const Index = () => {
},
},
};
+
const uiSchema: UISchema = {
language: {
'ui:widget': 'select',
@@ -86,7 +87,7 @@ const Index = () => {
...formData,
language: {
...formData.language,
- value: res[0].value,
+ value: loggedUserInfo.language || res[0].value,
},
});
setLangs(res);
diff --git a/ui/src/router/pathFactory.ts b/ui/src/router/pathFactory.ts
index ec326acf..ea353604 100644
--- a/ui/src/router/pathFactory.ts
+++ b/ui/src/router/pathFactory.ts
@@ -41,7 +41,7 @@ const questionLanding = (questionId: string, slugTitle:
string = '') => {
}
// @ts-ignore
if (/[13]/.test(seo.permalink) && slugTitle) {
- return `/questions/${questionId}/${slugTitle}`;
+ return `/questions/${questionId}/${encodeURIComponent(slugTitle)}`;
}
return `/questions/${questionId}`;
diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts
index bab8b34b..5c420e36 100644
--- a/ui/src/router/routes.ts
+++ b/ui/src/router/routes.ts
@@ -219,7 +219,6 @@ const routes: RouteNode[] = [
},
],
},
-
{
path: 'users/login',
page: 'pages/Users/Login',
@@ -333,10 +332,6 @@ const routes: RouteNode[] = [
path: 'answers',
page: 'pages/Admin/Answers',
},
- {
- path: 'flags',
- page: 'pages/Admin/Flags',
- },
{
path: 'themes',
page: 'pages/Admin/Themes',
diff --git a/ui/src/services/client/index.ts b/ui/src/services/client/index.ts
index 89d38a95..0d718423 100644
--- a/ui/src/services/client/index.ts
+++ b/ui/src/services/client/index.ts
@@ -29,3 +29,4 @@ export * from './timeline';
export * from './revision';
export * from './user';
export * from './Oauth';
+export * from './review';
diff --git a/ui/src/services/client/review.ts b/ui/src/services/client/review.ts
new file mode 100644
index 00000000..de289453
--- /dev/null
+++ b/ui/src/services/client/review.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 useSWR from 'swr';
+
+import request from '@/utils/request';
+import * as Type from '@/common/interface';
+
+export const getSuggestReviewList = (page: number) => {
+ const apiUrl = `/answer/api/v1/revisions/unreviewed?page=${page}`;
+ return request.get<Type.SuggestReviewResp>(apiUrl);
+};
+
+export const getReviewType = () => {
+ return request.get<Type.ReviewTypeItem[]>('/answer/api/v1/reviewing/type');
+};
+
+export const getFlagReviewPostList = (page: number) => {
+ const apiUrl = `/answer/api/v1/report/unreviewed/post?page=${page}`;
+ return request.get<Type.FlagReviewResp>(apiUrl);
+};
+
+export const putFlagReviewAction = (params: Type.PutFlagReviewParams) => {
+ return request.put('/answer/api/v1/report/review', params);
+};
+
+export const getPendingReviewPostList = (page: number, objectId?: string) => {
+ const apiUrl =
`/answer/api/v1/review/pending/post/page?page=${page}&object_id=${objectId}`;
+ return request.get<Type.QueuedReviewResp>(apiUrl);
+};
+
+export const putPendingReviewAction = (params: {
+ review_id: number;
+ status: 'approve' | 'reject';
+}) => {
+ return request.put('/answer/api/v1/review/pending/post', params);
+};
diff --git a/ui/src/services/client/revision.ts
b/ui/src/services/client/revision.ts
index b71d7f24..489730e4 100644
--- a/ui/src/services/client/revision.ts
+++ b/ui/src/services/client/revision.ts
@@ -18,7 +18,6 @@
*/
import request from '@/utils/request';
-import * as Type from '@/common/interface';
export const editCheck = (id: string, passingError: boolean = false) => {
const apiUrl = `/answer/api/v1/revisions/edit/check?id=${id}`;
@@ -34,8 +33,3 @@ export const revisionAudit = (id: string, operation:
'approve' | 'reject') => {
operation,
});
};
-
-export const getReviewList = (page: number) => {
- const apiUrl = `/answer/api/v1/revisions/unreviewed?page=${page}`;
- return request.get<Type.ReviewResp>(apiUrl);
-};
diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts
index 07dc3400..13644cd0 100644
--- a/ui/src/utils/common.ts
+++ b/ui/src/utils/common.ts
@@ -108,6 +108,11 @@ function parseUserInfo(markdown) {
return markdown.replace(globalReg, '[@$1](/u/$1)');
}
+function parseEditMentionUser(markdown) {
+ const globalReg = /\[@([^\]]+)\]\([^)]+\)/g;
+ return markdown.replace(globalReg, '@$1');
+}
+
function formatUptime(value) {
const t = i18next.t.bind(i18next);
const second = parseInt(value, 10);
@@ -170,8 +175,6 @@ function escapeHtml(str: string) {
return str.replace(/[&<>"'`]/g, (tag) => tagsToReplace[tag] || tag);
}
-const Diff = require('diff');
-
function diffText(newText: string, oldText?: string): string {
if (!newText) {
return '';
@@ -180,28 +183,31 @@ function diffText(newText: string, oldText?: string):
string {
if (typeof oldText !== 'string') {
return escapeHtml(newText);
}
- const diff = Diff.diffChars(escapeHtml(oldText), escapeHtml(newText));
- const result = diff.map((part) => {
- if (part.added) {
- if (part.value.replace(/\n/g, '').length <= 0) {
- return `<span class="review-text-add d-block">${part.value.replace(
- /\n/g,
- '↵\n',
- )}</span>`;
+ let result = [];
+ import('diff').then((Diff) => {
+ const diff = Diff.diffChars(escapeHtml(oldText), escapeHtml(newText));
+ result = diff.map((part) => {
+ if (part.added) {
+ if (part.value.replace(/\n/g, '').length <= 0) {
+ return `<span class="review-text-add d-block">${part.value.replace(
+ /\n/g,
+ '↵\n',
+ )}</span>`;
+ }
+ return `<span class="review-text-add">${part.value}</span>`;
}
- return `<span class="review-text-add">${part.value}</span>`;
- }
- if (part.removed) {
- if (part.value.replace(/\n/g, '').length <= 0) {
- return `<span class="review-text-delete text-decoration-none
d-block">${part.value.replace(
- /\n/g,
- '↵\n',
- )}</span>`;
+ if (part.removed) {
+ if (part.value.replace(/\n/g, '').length <= 0) {
+ return `<span class="review-text-delete text-decoration-none
d-block">${part.value.replace(
+ /\n/g,
+ '↵\n',
+ )}</span>`;
+ }
+ return `<span class="review-text-delete">${part.value}</span>`;
}
- return `<span class="review-text-delete">${part.value}</span>`;
- }
- return part.value;
+ return part.value;
+ });
});
return result.join('');
@@ -273,6 +279,7 @@ export {
bgFadeOut,
matchedUsers,
parseUserInfo,
+ parseEditMentionUser,
formatUptime,
escapeRemove,
handleFormError,
diff --git a/ui/src/utils/saveDraft.ts b/ui/src/utils/saveDraft.ts
index e2dd7c17..6d7f1814 100644
--- a/ui/src/utils/saveDraft.ts
+++ b/ui/src/utils/saveDraft.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { debounce } from 'lodash';
+import debounce from 'lodash/debounce';
import {
DRAFT_QUESTION_STORAGE_KEY,
diff --git a/ui/template/header.html b/ui/template/header.html
index 85425674..75c696f9 100644
--- a/ui/template/header.html
+++ b/ui/template/header.html
@@ -54,7 +54,9 @@
href="{{.siteinfo.Branding.SquareIcon}}"
data-rh="true"
/>
- <script defer="defer" src="{{.scriptPath}}"></script>
+ {{range $path := .scriptPath}}
+ <script defer="defer" src="{{$path}}"></script>
+ {{end}}
{{if $.siteinfo.JsonLD }}{{ .siteinfo.JsonLD | templateHTML}}{{end}}
</head>