chore: import upstream snapshot with attribution
@@ -0,0 +1,12 @@
|
||||
[production]
|
||||
> 20%
|
||||
not dead
|
||||
not op_mini all
|
||||
|
||||
[development]
|
||||
last 1 chrome version
|
||||
last 1 firefox version
|
||||
last 1 safari version
|
||||
|
||||
[ssr]
|
||||
node 16
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 80
|
||||
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,2 @@
|
||||
PUBLIC_URL
|
||||
REACT_APP_API_URL = http://10.0.20.84:8080/
|
||||
@@ -0,0 +1,6 @@
|
||||
TSC_COMPILE_ON_ERROR=true
|
||||
ESLINT_NO_DEV_ERRORS=true
|
||||
PUBLIC_URL=/
|
||||
REACT_APP_API_URL=/
|
||||
REACT_APP_BASE_URL=
|
||||
REACT_APP_API_BASE_URL=
|
||||
@@ -0,0 +1,10 @@
|
||||
public
|
||||
config-overrides.js
|
||||
commitlint.config.js
|
||||
build
|
||||
.eslintrc.js
|
||||
node_modules/
|
||||
src/types/
|
||||
scripts/
|
||||
src/plugins/**
|
||||
!src/plugins/builtin
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'react-app/jest',
|
||||
'plugin:react/recommended',
|
||||
'airbnb',
|
||||
'airbnb-typescript',
|
||||
'plugin:import/typescript',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
overrides: [],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint', 'prettier'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/function-component-definition': 'off',
|
||||
'react/button-has-type': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/require-default-props': 'off',
|
||||
'arrow-body-style': 'off',
|
||||
"global-require": "off",
|
||||
'react/prop-types': 0,
|
||||
'react/no-danger': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/label-has-associated-control': 'off',
|
||||
'jsx-a11y/tabindex-no-positive': 'off',
|
||||
'jsx-a11y/control-has-associated-label': 'off',
|
||||
'func-names': 'off',
|
||||
'no-alert': 'off',
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'no-debugger': 'off',
|
||||
'max-len': 'off',
|
||||
'import/extensions': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'@typescript-eslint/default-param-last': 'off',
|
||||
'no-nested-ternary': 'off',
|
||||
'class-methods-use-this': 'off',
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
'builtin',
|
||||
'external',
|
||||
['internal', 'parent', 'sibling', 'index'],
|
||||
'unknown',
|
||||
],
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: 'react*',
|
||||
group: 'external',
|
||||
position: 'before',
|
||||
},
|
||||
{
|
||||
pattern: '@/**',
|
||||
group: 'internal',
|
||||
},
|
||||
{
|
||||
pattern: './**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
{
|
||||
pattern: '*.scss',
|
||||
patternOptions: { matchBase: true },
|
||||
group: 'unknown',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
],
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
|
||||
/build/*/*/*
|
||||
/build/*.json
|
||||
/build/*.html
|
||||
/build/*.txt
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env*.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
Thumbs.db
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.log
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
.eslintcache
|
||||
/.vscode/
|
||||
|
||||
/* !/src/plugins
|
||||
/src/plugins/*
|
||||
!/src/plugins/builtin
|
||||
!/src/plugins/Demo
|
||||
/src/plugins/*/*.go
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"src/**/*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"src/**/*.{scss,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"jsxBracketSameLine": true,
|
||||
"printWidth": 80,
|
||||
"endOfLine": "auto",
|
||||
"bracketSameLine": true
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const {
|
||||
addWebpackModuleRule,
|
||||
addWebpackAlias,
|
||||
setWebpackOptimizationSplitChunks,
|
||||
addWebpackPlugin,
|
||||
} = require("customize-cra");
|
||||
const webpack = require('webpack');
|
||||
|
||||
const path = require("path");
|
||||
const i18nPath = path.resolve(__dirname, "../i18n");
|
||||
|
||||
module.exports = {
|
||||
webpack: function(config, env) {
|
||||
addWebpackAlias({
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@i18n": i18nPath,
|
||||
buffer: 'buffer',
|
||||
})(config);
|
||||
|
||||
addWebpackModuleRule({
|
||||
test: /\.ya?ml$/,
|
||||
use: "yaml-loader"
|
||||
})(config);
|
||||
|
||||
addWebpackPlugin(
|
||||
new webpack.ProvidePlugin({
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
})
|
||||
)(config);
|
||||
|
||||
setWebpackOptimizationSplitChunks({
|
||||
maxInitialRequests: 20,
|
||||
minSize: 20 * 1024,
|
||||
minChunks: 2,
|
||||
cacheGroups: {
|
||||
automaticNamePrefix: 'chunk',
|
||||
mix1: {
|
||||
test: (module, chunks) => {
|
||||
return (
|
||||
module.resource &&
|
||||
(module.resource.includes('components') ||
|
||||
/\/node_modules\/react-bootstrap\//.test(module.resource))
|
||||
);
|
||||
},
|
||||
name: 'chunk-mix1',
|
||||
filename: 'static/js/[name].[contenthash:8].chunk.js',
|
||||
priority: 14,
|
||||
reuseExistingChunk: true,
|
||||
minChunks: process.env.NODE_ENV === 'production' ? 1 : 2,
|
||||
chunks: 'initial',
|
||||
},
|
||||
mix2: {
|
||||
name: 'chunk-mix2',
|
||||
test: /[\/]node_modules[\/](i18next|lodash|marked|next-share)[\/]/,
|
||||
filename: 'static/js/[name].[contenthash:8].chunk.js',
|
||||
priority: 13,
|
||||
reuseExistingChunk: true,
|
||||
minChunks: 1,
|
||||
chunks: 'initial',
|
||||
},
|
||||
mix3: {
|
||||
name: 'chunk-mix3',
|
||||
test: /[\/]node_modules[\/](@remix-run|@restart|axios|diff)[\/]/,
|
||||
filename: 'static/js/[name].[contenthash:8].chunk.js',
|
||||
priority: 12,
|
||||
reuseExistingChunk: true,
|
||||
minChunks: 1,
|
||||
chunks: 'initial',
|
||||
},
|
||||
codemirror: {
|
||||
name: 'codemirror',
|
||||
test: /[\/]node_modules[\/](\@codemirror)[\/]/,
|
||||
priority: 10,
|
||||
reuseExistingChunk: true,
|
||||
minChunks: process.env.NODE_ENV === 'production' ? 1 : 2,
|
||||
chunks: 'initial',
|
||||
enforce: true,
|
||||
},
|
||||
lezer: {
|
||||
name: 'lezer',
|
||||
test: /[\/]node_modules[\/](\@lezer)[\/]/,
|
||||
priority: 9,
|
||||
reuseExistingChunk: true,
|
||||
minChunks: process.env.NODE_ENV === 'production' ? 1 : 2,
|
||||
chunks: 'initial',
|
||||
enforce: true,
|
||||
},
|
||||
reactDom: {
|
||||
name: 'react-dom',
|
||||
test: /[\/]node_modules[\/](react-dom)[\/]/,
|
||||
filename: 'static/js/[name].[contenthash:8].chunk.js',
|
||||
priority: 8,
|
||||
reuseExistingChunk: true,
|
||||
chunks: 'all',
|
||||
enforce: 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) {
|
||||
moduleScopePlugin.allowedPaths.push(i18nPath);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
devServer: function(configFunction) {
|
||||
return function(proxy, allowedHost) {
|
||||
const config = configFunction(proxy, allowedHost);
|
||||
config.proxy = [
|
||||
{
|
||||
context: ['/answer', '/installation'],
|
||||
target: process.env.REACT_APP_API_URL,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
{
|
||||
context: ['/custom.css'],
|
||||
target: process.env.REACT_APP_API_URL,
|
||||
}
|
||||
];
|
||||
return config;
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"name": "answer-static",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "/",
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "node ./scripts/env.js && react-app-rewired build",
|
||||
"pre-install": "node ./scripts/importPlugins.js && pnpm install && node ./scripts/preinstall.js ",
|
||||
"prepare": "pnpm build:packages",
|
||||
"build:packages": "pnpm -r --filter=./src/plugins/* run build",
|
||||
"clean": "rm -rf node_modules && rm -rf src/plugins/**/node_modules",
|
||||
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||
"setup-lint": "node scripts/setup-eslint.js && cd .. && husky install",
|
||||
"lint": "eslint . --cache --fix --ext .ts,.tsx",
|
||||
"prettier": "prettier --write \"src/**/*.{ts,tsx,css,scss,md}\"",
|
||||
"lint-staged": "lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.2.4",
|
||||
"@codemirror/language-data": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.26.1",
|
||||
"axios": "^1.7.7",
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootstrap-icons": "^1.10.5",
|
||||
"classnames": "^2.3.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"color": "^4.2.3",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.5",
|
||||
"diff": "^5.1.0",
|
||||
"front-matter": "^4.0.2",
|
||||
"i18next": "^21.9.0",
|
||||
"js-sha256": "0.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^4.0.19",
|
||||
"next-share": "^0.18.1",
|
||||
"qrcode": "^1.5.1",
|
||||
"qs": "^6.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.10.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet-async": "^1.3.0",
|
||||
"react-i18next": "^11.18.3",
|
||||
"react-router-dom": "^7.0.2",
|
||||
"semver": "^7.3.8",
|
||||
"swr": "^1.3.0",
|
||||
"uuid": "13.0.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
"@commitlint/config-conventional": "^17.2.0",
|
||||
"@fullhuman/postcss-purgecss": "^4.1.3",
|
||||
"@testing-library/dom": "^8.17.1",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/lodash": "^4.14.184",
|
||||
"@types/marked": "^4.0.6",
|
||||
"@types/node": "^16.11.47",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"buffer": "6.0.3",
|
||||
"customize-cra": "^1.0.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-standard-with-typescript": "^39.1.1",
|
||||
"eslint-plugin-import": "^2.25.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-promise": "^6.0.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"postcss": "^8.0.0",
|
||||
"prettier": "^3.1.0",
|
||||
"purgecss-webpack-plugin": "^4.1.3",
|
||||
"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"
|
||||
},
|
||||
"packageManager": "pnpm@9.7.0",
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
|
||||
packages:
|
||||
- "src/plugins/**"
|
||||
- "!src/plugins/builtin/**"
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" />
|
||||
<meta name="generator" content="Answer - https://github.com/apache/answer">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root">
|
||||
<div id="spin-mask">
|
||||
<noscript>
|
||||
<style>
|
||||
#spin-mask {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#protect-browser {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
<style>
|
||||
@keyframes _doc-spin {
|
||||
to { transform: rotate(360deg) }
|
||||
}
|
||||
#spin-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: white;
|
||||
z-index: 9999;
|
||||
}
|
||||
#spin-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
#spin-container .spinner {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
vertical-align: -.125em;
|
||||
border: .25rem solid currentColor;
|
||||
border-right-color: transparent;
|
||||
color: rgba(108, 117, 125, .75);
|
||||
border-radius: 50%;
|
||||
animation: 0.75s linear infinite _doc-spin;
|
||||
}
|
||||
|
||||
#protect-browser {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<div id="spin-container">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<div id="protect-browser"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
<script>
|
||||
// disable react devtools
|
||||
if (typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ === 'object') {
|
||||
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {};
|
||||
}
|
||||
/**
|
||||
* @description: Prompt that the browser version is too low
|
||||
*/
|
||||
const defaultList = [
|
||||
{
|
||||
name: 'Edge',
|
||||
version: '100'
|
||||
},
|
||||
{
|
||||
name: 'Firefox',
|
||||
version: '100'
|
||||
},
|
||||
{
|
||||
name: 'Chrome',
|
||||
version: '90'
|
||||
},
|
||||
{
|
||||
name: 'Safari',
|
||||
version: '15'
|
||||
},
|
||||
{
|
||||
name: 'IE',
|
||||
}
|
||||
];
|
||||
function getBrowserTypeAndVersion(){
|
||||
var browser = {
|
||||
name: '',
|
||||
version: ''
|
||||
};
|
||||
var ua = navigator.userAgent.toLowerCase();
|
||||
var s;
|
||||
((ua.indexOf("compatible") > -1 && ua.indexOf("MSIE") > -1) || (ua.indexOf('Trident') > -1 && ua.indexOf("rv:11.0") > -1)) ? browser = { name: 'IE', version: '' } :
|
||||
(s = ua.match(/edge\/([\d\.]+)/)) ? browser = { name: 'Edge', version: s[1] } :
|
||||
(s = ua.match(/firefox\/([\d\.]+)/)) ? browser = { name: 'Firefox', version: s[1] } :
|
||||
(s = ua.match(/chrome\/([\d\.]+)/)) ? browser = { name: 'Chrome', version: s[1] } :
|
||||
(s = ua.match(/version\/([\d\.]+).*safari/)) ? browser = { name: 'Safari', version: s[1] } : browser = { name: 'unknown', version: '' };
|
||||
// 根据关系进行判断
|
||||
return browser;
|
||||
}
|
||||
|
||||
function compareVersion(version1, version2) {
|
||||
var v1 = version1.split('.');
|
||||
var v2 = version2.split('.');
|
||||
var len = Math.max(v1.length, v2.length);
|
||||
while (v1.length < len) {
|
||||
v1.push('0');
|
||||
}
|
||||
while (v2.length < len) {
|
||||
v2.push('0');
|
||||
}
|
||||
for (var i = 0; i < len; i++) {
|
||||
var num1 = parseInt(v1[i]);
|
||||
var num2 = parseInt(v2[i]);
|
||||
if (num1 >= num2) {
|
||||
return 1;
|
||||
} else if (num1 < num2) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const browserInfo = getBrowserTypeAndVersion();
|
||||
|
||||
if (browserInfo.name === 'IE') {
|
||||
const div = document.getElementById('protect-browser');
|
||||
div.innerText = 'Sorry, this site does not support Internet Explorer. In order to avoid affecting the normal use of our features, please use a more modern browser such as Edge, Firefox, Chrome, or Safari.'
|
||||
} else {
|
||||
const notSupport = defaultList.some(item => {
|
||||
if (item.name === browserInfo.name) {
|
||||
return compareVersion(browserInfo.version, item.version) === -1;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (notSupport) {
|
||||
const div = document.getElementById('protect-browser');
|
||||
div.innerText = 'The current browser version is too low, in order not to affect the normal use of the function, please upgrade the browser to the latest version.'
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Answer",
|
||||
"name": "Apache Answer",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
====
|
||||
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.
|
||||
====
|
||||
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const configFilePath = path.resolve(__dirname, '../../configs/config.yaml');
|
||||
const envFilePath = path.resolve(__dirname, '../.env.production');
|
||||
|
||||
// Read config.yaml file
|
||||
const config = yaml.load(fs.readFileSync(configFilePath, 'utf8'));
|
||||
|
||||
// Generate .env file content
|
||||
let envContent = 'TSC_COMPILE_ON_ERROR=true\nESLINT_NO_DEV_ERRORS=true\n';
|
||||
for (const key in config.ui) {
|
||||
const value = config.ui[key];
|
||||
envContent += `${key !== 'public_url' ? 'REACT_APP_' : ''}${key.toUpperCase()}=${value}\n`;
|
||||
}
|
||||
|
||||
// Write .env file
|
||||
fs.writeFileSync(envFilePath, envContent);
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const pluginPath = path.join(__dirname, '../src/plugins');
|
||||
const pluginFolders = fs.readdirSync(pluginPath);
|
||||
|
||||
function resetPackageJson() {
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
const packageJsonContent = require(packageJsonPath);
|
||||
const dependencies = packageJsonContent.dependencies;
|
||||
for (const key in dependencies) {
|
||||
if (dependencies[key].startsWith('workspace')) {
|
||||
delete dependencies[key];
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJsonContent, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function addPluginToPackageJson(packageName) {
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
const packageJsonContent = require(packageJsonPath);
|
||||
packageJsonContent.dependencies[packageName] = 'workspace:*';
|
||||
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJsonContent, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
resetPackageJson();
|
||||
|
||||
pluginFolders.forEach((folder) => {
|
||||
const pluginFolder = path.join(pluginPath, folder);
|
||||
const stat = fs.statSync(pluginFolder);
|
||||
|
||||
if (stat.isDirectory() && folder !== 'builtin') {
|
||||
if (!fs.existsSync(path.join(pluginFolder, 'index.ts'))) {
|
||||
return;
|
||||
}
|
||||
const packageJson = require(path.join(pluginFolder, 'package.json'));
|
||||
const packageName = packageJson.name;
|
||||
|
||||
addPluginToPackageJson(packageName);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const pluginPath = path.join(__dirname, '../src/plugins');
|
||||
const pluginFolders = fs.readdirSync(pluginPath);
|
||||
|
||||
function pascalize(str) {
|
||||
return str.split(/[_-]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('');
|
||||
}
|
||||
|
||||
function resetIndexTs() {
|
||||
const indexTsPath = path.join(pluginPath, 'index.ts');
|
||||
fs.writeFileSync(indexTsPath, '');
|
||||
}
|
||||
|
||||
function addPluginToIndexTs(packageName, pluginFolder) {
|
||||
const indexTsPath = path.join(pluginPath, 'index.ts');
|
||||
const indexTsContent = fs.readFileSync(indexTsPath, 'utf-8');
|
||||
const lines = indexTsContent.split('\n');
|
||||
const ComponentName = pascalize(packageName);
|
||||
|
||||
const importLine = `const load${ComponentName} = () => import('${packageName}').then(module => module.default);`;
|
||||
const info = yaml.load(fs.readFileSync(path.join(pluginFolder, 'info.yaml'), 'utf8'));
|
||||
const exportLine = `export const ${info.slug_name} = load${ComponentName}`;
|
||||
|
||||
if (!lines.includes(exportLine)) {
|
||||
lines.push(importLine);
|
||||
lines.push(exportLine);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexTsPath, lines.join('\n'));
|
||||
}
|
||||
|
||||
const pluginLength = pluginFolders.filter((folder) => {
|
||||
const pluginFolder = path.join(pluginPath, folder);
|
||||
const stat = fs.statSync(pluginFolder);
|
||||
return stat.isDirectory() && folder !== 'builtin';
|
||||
}).length;
|
||||
|
||||
if (pluginLength > 0) {
|
||||
resetIndexTs();
|
||||
}
|
||||
|
||||
pluginFolders.forEach((folder) => {
|
||||
const pluginFolder = path.join(pluginPath, folder);
|
||||
const stat = fs.statSync(pluginFolder);
|
||||
|
||||
if (stat.isDirectory() && folder !== 'builtin') {
|
||||
if (!fs.existsSync(path.join(pluginFolder, 'index.ts'))) {
|
||||
return;
|
||||
}
|
||||
const packageJson = require(path.join(pluginFolder, 'package.json'));
|
||||
const packageName = packageJson.name;
|
||||
|
||||
addPluginToIndexTs(packageName, pluginFolder);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// There is a bug when using npm to install: the execution of preinstall is after install, so when this prompt is displayed, the dependent packages have already been installed.
|
||||
|
||||
require('./loadPlugins');
|
||||
|
||||
if (!/pnpm/.test(process.env.npm_execpath)) {
|
||||
console.warn(
|
||||
`\u001b[33mThis repository requires using pnpm as the package manager for scripts to work properly.\u001b[39m\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const UI_DIR = path.resolve(__dirname, '..'); // UI directory
|
||||
const ROOT_DIR = path.resolve(UI_DIR, '..'); // Project root directory
|
||||
const GIT_DIR = getGitDir(ROOT_DIR); // Git root directory
|
||||
const HUSKY_DIR = path.join(GIT_DIR, '.husky');
|
||||
|
||||
// Find Git directory
|
||||
function getGitDir(startDir) {
|
||||
let currentDir = startDir;
|
||||
|
||||
while (currentDir !== path.parse(currentDir).root) {
|
||||
const gitDir = path.join(currentDir, '.git');
|
||||
if (fs.existsSync(gitDir)) {
|
||||
return currentDir;
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
|
||||
throw new Error('Could not find Git directory');
|
||||
}
|
||||
|
||||
|
||||
if (!fs.existsSync(HUSKY_DIR)) {
|
||||
console.log(`Creating husky directory: ${HUSKY_DIR}`);
|
||||
fs.mkdirSync(HUSKY_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(HUSKY_DIR, '_'))) {
|
||||
console.log(`Creating husky _ directory: ${path.join(HUSKY_DIR, '_')}`);
|
||||
fs.mkdirSync(path.join(HUSKY_DIR, '_'), { recursive: true });
|
||||
}
|
||||
|
||||
// init husky
|
||||
try {
|
||||
console.log('Initializing husky...');
|
||||
execSync('npx husky install', { cwd: GIT_DIR, stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to initialize husky: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// create lint-staged config file
|
||||
const lintStagedConfig = {
|
||||
"src/**/*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"src/**/*.{scss,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
};
|
||||
|
||||
console.log(`Creating lint-staged config: ${path.join(UI_DIR, '.lintstagedrc.json')}`);
|
||||
fs.writeFileSync(
|
||||
path.join(UI_DIR, '.lintstagedrc.json'),
|
||||
JSON.stringify(lintStagedConfig, null, 2)
|
||||
);
|
||||
|
||||
// create pre-commit hook
|
||||
const preCommitContent = `#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Start running the code check..."
|
||||
|
||||
# Getting the Git Root Directory
|
||||
GIT_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Get a list of staging files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
|
||||
# Check for files in the ui/ directory
|
||||
UI_FILES=$(echo "$STAGED_FILES" | grep '^ui/' || echo "")
|
||||
|
||||
if [ -n "$UI_FILES" ]; then
|
||||
echo "🔎 Discover ui file changes, run code checks..."
|
||||
|
||||
# Switch to the ui directory
|
||||
cd "$GIT_ROOT/ui" || {
|
||||
echo "❌ Unable to access the UI catalog"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 运行 lint-staged
|
||||
echo "✨ Running ESLint and Prettier Formatting..."
|
||||
npx lint-staged --verbose
|
||||
|
||||
LINT_STAGED_RESULT=$?
|
||||
if [ $LINT_STAGED_RESULT -ne 0 ]; then
|
||||
echo "❌ Code check failed, please fix the above problem"
|
||||
exit $LINT_STAGED_RESULT
|
||||
fi
|
||||
|
||||
echo "✅ Code check passes!"
|
||||
else
|
||||
echo "ℹ️ No front-end file changes found, skip code checking"
|
||||
fi
|
||||
|
||||
echo "🎉 Pre-submission check completed"
|
||||
`;
|
||||
|
||||
console.log(`Creating pre-commit hook: ${path.join(HUSKY_DIR, 'pre-commit')}`);
|
||||
fs.writeFileSync(path.join(HUSKY_DIR, 'pre-commit'), preCommitContent);
|
||||
execSync(`chmod +x ${path.join(HUSKY_DIR, 'pre-commit')}`);
|
||||
|
||||
// create husky.sh
|
||||
const huskyShContent = `#!/bin/sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY=0, skip hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
`;
|
||||
|
||||
console.log(`Creating husky.sh: ${path.join(HUSKY_DIR, '_', 'husky.sh')}`);
|
||||
fs.writeFileSync(
|
||||
path.join(HUSKY_DIR, '_', 'husky.sh'),
|
||||
huskyShContent
|
||||
);
|
||||
execSync(`chmod +x ${path.join(HUSKY_DIR, '_', 'husky.sh')}`);
|
||||
|
||||
console.log('Lint setup complete! Husky and lint-staged have been configured.');
|
||||
console.log(`Git root directory: ${GIT_DIR}`);
|
||||
console.log(`Husky directory: ${HUSKY_DIR}`);
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
|
||||
import './i18n/init';
|
||||
|
||||
import '@/utils/pluginKit';
|
||||
import { useMergeRoutes } from '@/router';
|
||||
import InitialLoadingPlaceholder from '@/components/InitialLoadingPlaceholder';
|
||||
|
||||
function App() {
|
||||
const routes = useMergeRoutes();
|
||||
if (routes.length === 0) {
|
||||
return <InitialLoadingPlaceholder />;
|
||||
}
|
||||
const router = createBrowserRouter(routes, {
|
||||
basename: process.env.REACT_APP_BASE_URL,
|
||||
});
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" fill="#DEE2E6"/>
|
||||
<path d="M8.5 25C8.5 25 7 25 7 23.5C7 22 8.5 17.5 16 17.5C23.5 17.5 25 22 25 23.5C25 25 23.5 25 23.5 25H8.5ZM16 16C17.1935 16 18.3381 15.5259 19.182 14.682C20.0259 13.8381 20.5 12.6935 20.5 11.5C20.5 10.3065 20.0259 9.16193 19.182 8.31802C18.3381 7.47411 17.1935 7 16 7C14.8065 7 13.6619 7.47411 12.818 8.31802C11.9741 9.16193 11.5 10.3065 11.5 11.5C11.5 12.6935 11.9741 13.8381 12.818 14.682C13.6619 15.5259 14.8065 16 16 16Z" fill="#6C757D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { MouseEvent, useCallback } from 'react';
|
||||
|
||||
import { useLegalPrivacy, useLegalTos } from '@/services/client/legal';
|
||||
|
||||
export const useLegalClick = () => {
|
||||
const { data: tos } = useLegalTos();
|
||||
const { data: privacy } = useLegalPrivacy();
|
||||
|
||||
const legalClick = useCallback(
|
||||
(evt: MouseEvent, type: 'tos' | 'privacy') => {
|
||||
evt.stopPropagation();
|
||||
const contentText =
|
||||
type === 'tos'
|
||||
? tos?.terms_of_service_original_text
|
||||
: privacy?.privacy_policy_original_text;
|
||||
let matchUrl: URL | undefined;
|
||||
try {
|
||||
if (contentText) {
|
||||
matchUrl = new URL(contentText);
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (ex) {}
|
||||
if (matchUrl) {
|
||||
evt.preventDefault();
|
||||
window.open(matchUrl.toString());
|
||||
}
|
||||
},
|
||||
[tos, privacy],
|
||||
);
|
||||
|
||||
return legalClick;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
$link-hover-decoration: none;
|
||||
$enable-negative-margins: true;
|
||||
$blue: #0033ff !default;
|
||||
$placeholder-opacity-max: 0.2;
|
||||
$placeholder-opacity-min: 0.1;
|
||||
$enable-smooth-scroll: false;
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--an-side-nav-link: rgba(0, 0, 0, 0.65);
|
||||
--an-toolbar-divider: rgba(0, 0, 0, 0.1);
|
||||
--an-ced4da: #ced4da;
|
||||
--an-e9ecef: #e9ecef;
|
||||
--an-pre: #f8f9fa;
|
||||
--an-6c757d: #6c757d;
|
||||
--an-212529: #212529;
|
||||
--an-gray-300: var(--bs-gray-300);
|
||||
--an-white: #fff;
|
||||
--an-inbox-warning: #fff3cd80;
|
||||
--an-f5: #f5f5f5;
|
||||
--an-answer-item-border-top: rgba(0, 0, 0, 0.125);
|
||||
--an-answer-inbox-nav-border-top: var(--bs-border-color);
|
||||
--an-comment-item-border-bottom: var(--bs-colors-gray-200, #e9ecef);
|
||||
--an-editor-toolbar-hover: #f8f9fa;
|
||||
--ans-editor-toolbar-focus: #dae0e5;
|
||||
--an-editor-placeholder-color: #6c757d;
|
||||
--an-side-nav-link-hover-color: rgba(0, 0, 0, 0.85);
|
||||
--an-invite-answer-item-active: #e9ecef;
|
||||
--an-alert-exist-color: #055160;
|
||||
}
|
||||
|
||||
[data-bs-theme='dark'] {
|
||||
--an-side-nav-link: rgba(255, 255, 255, 0.65);
|
||||
--an-toolbar-divider: rgba(255, 255, 255, 0.3);
|
||||
--an-ced4da: var(--bs-border-color);
|
||||
--an-e9ecef: #161b22;
|
||||
--an-pre: #161b22;
|
||||
--an-6c757d: var(--bs-body-color);
|
||||
--an-212529: var(--bs-body-color);
|
||||
--an-gray-300: #161b22;
|
||||
--an-white: #000;
|
||||
--an-inbox-warning: #38363180;
|
||||
--an-f5: var(--bs-body-bg);
|
||||
--an-answer-item-border-top: var(--bs-border-color);
|
||||
--an-answer-inbox-nav-border-top: var(--bs-border-color);
|
||||
--an-comment-item-border-bottom: var(--bs-border-color);
|
||||
--an-editor-toolbar-hover: var(--bs-tertiary-bg);
|
||||
--ans-editor-toolbar-focus: var(--bs-tertiary-bg);
|
||||
--an-editor-placeholder-color: var(--bs-body-color);
|
||||
--an-side-nav-link-hover-color: var(--bs-body-color);
|
||||
--an-invite-answer-item-active: var(--bs-tertiary-bg);
|
||||
--an-alert-exist-color: #60cee4;
|
||||
}
|
||||
|
||||
[data-bs-theme='dark'] {
|
||||
.link-dark {
|
||||
color: rgba(var(--bs-emphasis-color-rgb), 0.8) !important;
|
||||
}
|
||||
/** CodeMirror **/
|
||||
|
||||
.cm-editor {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
border-left-color: var(--bs-body-color);
|
||||
}
|
||||
.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground {
|
||||
background-color: #3e4451;
|
||||
}
|
||||
/** link color **/
|
||||
.ͼc {
|
||||
color: rgb(60, 138, 233);
|
||||
}
|
||||
.ͼ5 {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.ͼ2 .cm-selectionBackground {
|
||||
background: #3e4451;
|
||||
}
|
||||
/** CodeMirror end **/
|
||||
|
||||
.bg-light {
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
.text-bg-dark {
|
||||
color: #000 !important;
|
||||
background-color: RGBA(255, 255, 255, var(--bs-bg-opacity, 1)) !important;
|
||||
}
|
||||
/** side nav **/
|
||||
#sideNav,
|
||||
#answerAccordion {
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
background-color: #2b3035 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/** tag **/
|
||||
.badge-tag {
|
||||
background: $gray-800;
|
||||
color: $gray-300;
|
||||
&:hover {
|
||||
background: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-tag-reserved {
|
||||
color: $orange-200;
|
||||
background: $orange-800;
|
||||
&:hover {
|
||||
background: $orange-700;
|
||||
}
|
||||
}
|
||||
|
||||
.view-level1 {
|
||||
color: $orange-300;
|
||||
}
|
||||
|
||||
.view-level2 {
|
||||
color: $orange-200;
|
||||
}
|
||||
|
||||
.view-level3 {
|
||||
color: $orange-100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const DEFAULT_SITE_NAME = 'Answer';
|
||||
export const DEFAULT_LANG = 'en_US';
|
||||
export const CURRENT_LANG_STORAGE_KEY = '_a_lang_';
|
||||
export const LANG_RESOURCE_STORAGE_KEY = '_a_lang_r_';
|
||||
export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_';
|
||||
export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_';
|
||||
export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_';
|
||||
export const DRAFT_QUESTION_STORAGE_KEY = '_a_dq_';
|
||||
export const DRAFT_ANSWER_STORAGE_KEY = '_a_da_';
|
||||
export const DRAFT_TIMESIGH_STORAGE_KEY = '|_a_t_s_|';
|
||||
export const DEFAULT_THEME = 'system';
|
||||
export const ADMIN_PRIVILEGE_CUSTOM_LEVEL = 99;
|
||||
export const SKELETON_SHOW_TIME = 1000;
|
||||
export const LIST_VIEW_STORAGE_KEY = '_a_list_view_';
|
||||
export const EXTERNAL_CONTENT_DISPLAY_MODE = '_a_ecd_';
|
||||
|
||||
export const USER_AGENT_NAMES = {
|
||||
SegmentFault: 'SegmentFault',
|
||||
WeChat: 'WeChat',
|
||||
WeCom: 'WeCom',
|
||||
DingTalk: 'DingTalk',
|
||||
};
|
||||
|
||||
export const ADMIN_LIST_STATUS = {
|
||||
// normal;
|
||||
1: {
|
||||
variant: 'text-bg-success',
|
||||
name: 'normal',
|
||||
},
|
||||
// closed;
|
||||
2: {
|
||||
variant: 'text-bg-warning',
|
||||
name: 'closed',
|
||||
},
|
||||
// deleted
|
||||
10: {
|
||||
variant: 'text-bg-danger',
|
||||
name: 'deleted',
|
||||
},
|
||||
// pending
|
||||
11: {
|
||||
variant: 'text-bg-warning',
|
||||
name: 'pending',
|
||||
},
|
||||
normal: {
|
||||
variant: 'text-bg-success',
|
||||
name: 'normal',
|
||||
},
|
||||
closed: {
|
||||
variant: 'text-bg-warning',
|
||||
name: 'closed',
|
||||
},
|
||||
deleted: {
|
||||
variant: 'text-bg-danger',
|
||||
name: 'deleted',
|
||||
},
|
||||
pending: {
|
||||
variant: 'text-bg-warning',
|
||||
name: 'pending',
|
||||
},
|
||||
unlisted: {
|
||||
variant: 'text-bg-secondary',
|
||||
name: 'unlisted',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ADMIN_NAV_MENUS is the navigation menu for the admin panel.
|
||||
* pathPrefix is used to activate the menu item when the activeKey starts with the pathPrefix.
|
||||
*/
|
||||
|
||||
export const ADMIN_NAV_MENUS = [
|
||||
{
|
||||
name: 'dashboard',
|
||||
icon: 'speedometer',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
name: 'contents',
|
||||
icon: 'file-earmark-text-fill',
|
||||
children: [
|
||||
{ name: 'questions', path: 'qa/questions', pathPrefix: 'qa/' },
|
||||
{ name: 'tags', path: 'tags/settings', pathPrefix: 'tags/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'intelligence',
|
||||
icon: 'robot',
|
||||
children: [
|
||||
{ name: 'ai_settings', path: 'ai-settings' },
|
||||
{ name: 'ai_assistant', path: 'ai-assistant' },
|
||||
{ name: 'mcp' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'community',
|
||||
icon: 'people-fill',
|
||||
children: [
|
||||
{ name: 'users', pathPrefix: 'users/' },
|
||||
{ name: 'badges' },
|
||||
{ name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'apperance',
|
||||
icon: 'palette-fill',
|
||||
children: [
|
||||
{
|
||||
name: 'themes',
|
||||
},
|
||||
{
|
||||
name: 'customize',
|
||||
},
|
||||
{ name: 'branding' },
|
||||
{ name: 'interface' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'advanced',
|
||||
icon: 'gear-fill',
|
||||
children: [
|
||||
{ name: 'general' },
|
||||
{ name: 'security' },
|
||||
{ name: 'files' },
|
||||
{ name: 'login' },
|
||||
{ name: 'seo' },
|
||||
{ name: 'smtp' },
|
||||
{ name: 'apikeys' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
icon: 'plugin',
|
||||
children: [
|
||||
{
|
||||
name: 'installed_plugins',
|
||||
path: 'installed-plugins',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ADMIN_QA_NAV_MENUS = [
|
||||
{ name: 'questions', path: '/admin/qa/questions' },
|
||||
{ name: 'answers', path: '/admin/qa/answers' },
|
||||
{ name: 'settings', path: '/admin/qa/settings' },
|
||||
];
|
||||
|
||||
export const ADMIN_TAGS_NAV_MENUS = [
|
||||
// { name: 'tags', path: '/admin/tags' },
|
||||
{
|
||||
name: 'settings',
|
||||
path: '/admin/tags/settings',
|
||||
},
|
||||
];
|
||||
|
||||
export const ADMIN_USERS_NAV_MENUS = [
|
||||
{ name: 'users', path: '/admin/users' },
|
||||
{ name: 'settings', path: '/admin/users/settings' },
|
||||
];
|
||||
|
||||
export const ADMIN_RULES_NAV_MENUS = [
|
||||
{ name: 'privileges', path: '/admin/rules/privileges' },
|
||||
{ name: 'policies', path: '/admin/rules/policies' },
|
||||
];
|
||||
|
||||
export const TIMEZONES = [
|
||||
{
|
||||
label: 'Africa',
|
||||
options: [
|
||||
{ value: 'Africa/Abidjan', label: 'Abidjan' },
|
||||
{ value: 'Africa/Accra', label: 'Accra' },
|
||||
{ value: 'Africa/Addis_Ababa', label: 'Addis Ababa' },
|
||||
{ value: 'Africa/Algiers', label: 'Algiers' },
|
||||
{ value: 'Africa/Asmara', label: 'Asmara' },
|
||||
{ value: 'Africa/Bamako', label: 'Bamako' },
|
||||
{ value: 'Africa/Bangui', label: 'Bangui' },
|
||||
{ value: 'Africa/Banjul', label: 'Banjul' },
|
||||
{ value: 'Africa/Bissau', label: 'Bissau' },
|
||||
{ value: 'Africa/Blantyre', label: 'Blantyre' },
|
||||
{ value: 'Africa/Brazzaville', label: 'Brazzaville' },
|
||||
{ value: 'Africa/Bujumbura', label: 'Bujumbura' },
|
||||
{ value: 'Africa/Cairo', label: 'Cairo' },
|
||||
{ value: 'Africa/Casablanca', label: 'Casablanca' },
|
||||
{ value: 'Africa/Ceuta', label: 'Ceuta' },
|
||||
{ value: 'Africa/Conakry', label: 'Conakry' },
|
||||
{ value: 'Africa/Dakar', label: 'Dakar' },
|
||||
{ value: 'Africa/Dar_es_Salaam', label: 'Dar es Salaam' },
|
||||
{ value: 'Africa/Djibouti', label: 'Djibouti' },
|
||||
{ value: 'Africa/Douala', label: 'Douala' },
|
||||
{ value: 'Africa/El_Aaiun', label: 'El Aaiun' },
|
||||
{ value: 'Africa/Freetown', label: 'Freetown' },
|
||||
{ value: 'Africa/Gaborone', label: 'Gaborone' },
|
||||
{ value: 'Africa/Harare', label: 'Harare' },
|
||||
{ value: 'Africa/Johannesburg', label: 'Johannesburg' },
|
||||
{ value: 'Africa/Juba', label: 'Juba' },
|
||||
{ value: 'Africa/Kampala', label: 'Kampala' },
|
||||
{ value: 'Africa/Khartoum', label: 'Khartoum' },
|
||||
{ value: 'Africa/Kigali', label: 'Kigali' },
|
||||
{ value: 'Africa/Kinshasa', label: 'Kinshasa' },
|
||||
{ value: 'Africa/Lagos', label: 'Lagos' },
|
||||
{ value: 'Africa/Libreville', label: 'Libreville' },
|
||||
{ value: 'Africa/Lome', label: 'Lome' },
|
||||
{ value: 'Africa/Luanda', label: 'Luanda' },
|
||||
{ value: 'Africa/Lubumbashi', label: 'Lubumbashi' },
|
||||
{ value: 'Africa/Lusaka', label: 'Lusaka' },
|
||||
{ value: 'Africa/Malabo', label: 'Malabo' },
|
||||
{ value: 'Africa/Maputo', label: 'Maputo' },
|
||||
{ value: 'Africa/Maseru', label: 'Maseru' },
|
||||
{ value: 'Africa/Mbabane', label: 'Mbabane' },
|
||||
{ value: 'Africa/Mogadishu', label: 'Mogadishu' },
|
||||
{ value: 'Africa/Monrovia', label: 'Monrovia' },
|
||||
{ value: 'Africa/Nairobi', label: 'Nairobi' },
|
||||
{ value: 'Africa/Ndjamena', label: 'Ndjamena' },
|
||||
{ value: 'Africa/Niamey', label: 'Niamey' },
|
||||
{ value: 'Africa/Nouakchott', label: 'Nouakchott' },
|
||||
{ value: 'Africa/Ouagadougou', label: 'Ouagadougou' },
|
||||
{ value: 'Africa/Porto-Novo', label: 'Porto-Novo' },
|
||||
{ value: 'Africa/Sao_Tome', label: 'Sao Tome' },
|
||||
{ value: 'Africa/Tripoli', label: 'Tripoli' },
|
||||
{ value: 'Africa/Tunis', label: 'Tunis' },
|
||||
{ value: 'Africa/Windhoek', label: 'Windhoek' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'America',
|
||||
options: [
|
||||
{ value: 'America/Adak', label: 'Adak' },
|
||||
{ value: 'America/Anchorage', label: 'Anchorage' },
|
||||
{ value: 'America/Anguilla', label: 'Anguilla' },
|
||||
{ value: 'America/Antigua', label: 'Antigua' },
|
||||
{ value: 'America/Araguaina', label: 'Araguaina' },
|
||||
{
|
||||
value: 'America/Argentina/Buenos_Aires',
|
||||
label: 'Argentina - Buenos Aires',
|
||||
},
|
||||
{ value: 'America/Argentina/Catamarca', label: 'Argentina - Catamarca' },
|
||||
{ value: 'America/Argentina/Cordoba', label: 'Argentina - Cordoba' },
|
||||
{ value: 'America/Argentina/Jujuy', label: 'Argentina - Jujuy' },
|
||||
{ value: 'America/Argentina/La_Rioja', label: 'Argentina - La Rioja' },
|
||||
{ value: 'America/Argentina/Mendoza', label: 'Argentina - Mendoza' },
|
||||
{
|
||||
value: 'America/Argentina/Rio_Gallegos',
|
||||
label: 'Argentina - Rio Gallegos',
|
||||
},
|
||||
{ value: 'America/Argentina/Salta', label: 'Argentina - Salta' },
|
||||
{ value: 'America/Argentina/San_Juan', label: 'Argentina - San Juan' },
|
||||
{ value: 'America/Argentina/San_Luis', label: 'Argentina - San Luis' },
|
||||
{ value: 'America/Argentina/Tucuman', label: 'Argentina - Tucuman' },
|
||||
{ value: 'America/Argentina/Ushuaia', label: 'Argentina - Ushuaia' },
|
||||
{ value: 'America/Aruba', label: 'Aruba' },
|
||||
{ value: 'America/Asuncion', label: 'Asuncion' },
|
||||
{ value: 'America/Atikokan', label: 'Atikokan' },
|
||||
{ value: 'America/Bahia', label: 'Bahia' },
|
||||
{ value: 'America/Bahia_Banderas', label: 'Bahia Banderas' },
|
||||
{ value: 'America/Barbados', label: 'Barbados' },
|
||||
{ value: 'America/Belem', label: 'Belem' },
|
||||
{ value: 'America/Belize', label: 'Belize' },
|
||||
{ value: 'America/Blanc-Sablon', label: 'Blanc-Sablon' },
|
||||
{ value: 'America/Boa_Vista', label: 'Boa Vista' },
|
||||
{ value: 'America/Bogota', label: 'Bogota' },
|
||||
{ value: 'America/Boise', label: 'Boise' },
|
||||
{ value: 'America/Cambridge_Bay', label: 'Cambridge Bay' },
|
||||
{ value: 'America/Campo_Grande', label: 'Campo Grande' },
|
||||
{ value: 'America/Cancun', label: 'Cancun' },
|
||||
{ value: 'America/Caracas', label: 'Caracas' },
|
||||
{ value: 'America/Cayenne', label: 'Cayenne' },
|
||||
{ value: 'America/Cayman', label: 'Cayman' },
|
||||
{ value: 'America/Chicago', label: 'Chicago' },
|
||||
{ value: 'America/Chihuahua', label: 'Chihuahua' },
|
||||
{ value: 'America/Costa_Rica', label: 'Costa Rica' },
|
||||
{ value: 'America/Creston', label: 'Creston' },
|
||||
{ value: 'America/Cuiaba', label: 'Cuiaba' },
|
||||
{ value: 'America/Curacao', label: 'Curacao' },
|
||||
{ value: 'America/Danmarkshavn', label: 'Danmarkshavn' },
|
||||
{ value: 'America/Dawson', label: 'Dawson' },
|
||||
{ value: 'America/Dawson_Creek', label: 'Dawson Creek' },
|
||||
{ value: 'America/Denver', label: 'Denver' },
|
||||
{ value: 'America/Detroit', label: 'Detroit' },
|
||||
{ value: 'America/Dominica', label: 'Dominica' },
|
||||
{ value: 'America/Edmonton', label: 'Edmonton' },
|
||||
{ value: 'America/Eirunepe', label: 'Eirunepe' },
|
||||
{ value: 'America/El_Salvador', label: 'El Salvador' },
|
||||
{ value: 'America/Fort_Nelson', label: 'Fort Nelson' },
|
||||
{ value: 'America/Fortaleza', label: 'Fortaleza' },
|
||||
{ value: 'America/Glace_Bay', label: 'Glace Bay' },
|
||||
{ value: 'America/Godthab', label: 'Godthab' },
|
||||
{ value: 'America/Goose_Bay', label: 'Goose Bay' },
|
||||
{ value: 'America/Grand_Turk', label: 'Grand Turk' },
|
||||
{ value: 'America/Grenada', label: 'Grenada' },
|
||||
{ value: 'America/Guadeloupe', label: 'Guadeloupe' },
|
||||
{ value: 'America/Guatemala', label: 'Guatemala' },
|
||||
{ value: 'America/Guayaquil', label: 'Guayaquil' },
|
||||
{ value: 'America/Guyana', label: 'Guyana' },
|
||||
{ value: 'America/Halifax', label: 'Halifax' },
|
||||
{ value: 'America/Havana', label: 'Havana' },
|
||||
{ value: 'America/Hermosillo', label: 'Hermosillo' },
|
||||
{
|
||||
value: 'America/Indiana/Indianapolis',
|
||||
label: 'Indiana - Indianapolis',
|
||||
},
|
||||
{ value: 'America/Indiana/Knox', label: 'Indiana - Knox' },
|
||||
{ value: 'America/Indiana/Marengo', label: 'Indiana - Marengo' },
|
||||
{ value: 'America/Indiana/Petersburg', label: 'Indiana - Petersburg' },
|
||||
{ value: 'America/Indiana/Tell_City', label: 'Indiana - Tell City' },
|
||||
{ value: 'America/Indiana/Vevay', label: 'Indiana - Vevay' },
|
||||
{ value: 'America/Indiana/Vincennes', label: 'Indiana - Vincennes' },
|
||||
{ value: 'America/Indiana/Winamac', label: 'Indiana - Winamac' },
|
||||
{ value: 'America/Inuvik', label: 'Inuvik' },
|
||||
{ value: 'America/Iqaluit', label: 'Iqaluit' },
|
||||
{ value: 'America/Jamaica', label: 'Jamaica' },
|
||||
{ value: 'America/Juneau', label: 'Juneau' },
|
||||
{ value: 'America/Kentucky/Louisville', label: 'Kentucky - Louisville' },
|
||||
{ value: 'America/Kentucky/Monticello', label: 'Kentucky - Monticello' },
|
||||
{ value: 'America/Kralendijk', label: 'Kralendijk' },
|
||||
{ value: 'America/La_Paz', label: 'La Paz' },
|
||||
{ value: 'America/Lima', label: 'Lima' },
|
||||
{ value: 'America/Los_Angeles', label: 'Los Angeles' },
|
||||
{ value: 'America/Lower_Princes', label: 'Lower Princes' },
|
||||
{ value: 'America/Maceio', label: 'Maceio' },
|
||||
{ value: 'America/Managua', label: 'Managua' },
|
||||
{ value: 'America/Manaus', label: 'Manaus' },
|
||||
{ value: 'America/Marigot', label: 'Marigot' },
|
||||
{ value: 'America/Martinique', label: 'Martinique' },
|
||||
{ value: 'America/Matamoros', label: 'Matamoros' },
|
||||
{ value: 'America/Mazatlan', label: 'Mazatlan' },
|
||||
{ value: 'America/Miquelon', label: 'Miquelon' },
|
||||
{ value: 'America/Moncton', label: 'Moncton' },
|
||||
{ value: 'America/Monterrey', label: 'Monterrey' },
|
||||
{ value: 'America/Montevideo', label: 'Montevideo' },
|
||||
{ value: 'America/Montserrat', label: 'Montserrat' },
|
||||
{ value: 'America/Nassau', label: 'Nassau' },
|
||||
{ value: 'America/New_York', label: 'New York' },
|
||||
{ value: 'America/Nipigon', label: 'Nipigon' },
|
||||
{ value: 'America/Nome', label: 'Nome' },
|
||||
{ value: 'America/Noronha', label: 'Noronha' },
|
||||
{ value: 'America/North_Dakota/Beulah', label: 'North Dakota - Beulah' },
|
||||
{ value: 'America/North_Dakota/Center', label: 'North Dakota - Center' },
|
||||
{
|
||||
value: 'America/North_Dakota/New_Salem',
|
||||
label: 'North Dakota - New Salem',
|
||||
},
|
||||
{ value: 'America/Ojinaga', label: 'Ojinaga' },
|
||||
{ value: 'America/Panama', label: 'Panama' },
|
||||
{ value: 'America/Pangnirtung', label: 'Pangnirtung' },
|
||||
{ value: 'America/Paramaribo', label: 'Paramaribo' },
|
||||
{ value: 'America/Phoenix', label: 'Phoenix' },
|
||||
{ value: 'America/Port-au-Prince', label: 'Port-au-Prince' },
|
||||
{ value: 'America/Port_of_Spain', label: 'Port of Spain' },
|
||||
{ value: 'America/Porto_Velho', label: 'Porto Velho' },
|
||||
{ value: 'America/Puerto_Rico', label: 'Puerto Rico' },
|
||||
{ value: 'America/Punta_Arenas', label: 'Punta Arenas' },
|
||||
{ value: 'America/Rainy_River', label: 'Rainy River' },
|
||||
{ value: 'America/Rankin_Inlet', label: 'Rankin Inlet' },
|
||||
{ value: 'America/Recife', label: 'Recife' },
|
||||
{ value: 'America/Regina', label: 'Regina' },
|
||||
{ value: 'America/Resolute', label: 'Resolute' },
|
||||
{ value: 'America/Rio_Branco', label: 'Rio Branco' },
|
||||
{ value: 'America/Santarem', label: 'Santarem' },
|
||||
{ value: 'America/Santiago', label: 'Santiago' },
|
||||
{ value: 'America/Santo_Domingo', label: 'Santo Domingo' },
|
||||
{ value: 'America/Sao_Paulo', label: 'Sao Paulo' },
|
||||
{ value: 'America/Scoresbysund', label: 'Scoresbysund' },
|
||||
{ value: 'America/Sitka', label: 'Sitka' },
|
||||
{ value: 'America/St_Barthelemy', label: 'St Barthelemy' },
|
||||
{ value: 'America/St_Johns', label: 'St Johns' },
|
||||
{ value: 'America/St_Kitts', label: 'St Kitts' },
|
||||
{ value: 'America/St_Lucia', label: 'St Lucia' },
|
||||
{ value: 'America/St_Thomas', label: 'St Thomas' },
|
||||
{ value: 'America/St_Vincent', label: 'St Vincent' },
|
||||
{ value: 'America/Swift_Current', label: 'Swift Current' },
|
||||
{ value: 'America/Tegucigalpa', label: 'Tegucigalpa' },
|
||||
{ value: 'America/Thule', label: 'Thule' },
|
||||
{ value: 'America/Thunder_Bay', label: 'Thunder Bay' },
|
||||
{ value: 'America/Tijuana', label: 'Tijuana' },
|
||||
{ value: 'America/Toronto', label: 'Toronto' },
|
||||
{ value: 'America/Tortola', label: 'Tortola' },
|
||||
{ value: 'America/Vancouver', label: 'Vancouver' },
|
||||
{ value: 'America/Whitehorse', label: 'Whitehorse' },
|
||||
{ value: 'America/Winnipeg', label: 'Winnipeg' },
|
||||
{ value: 'America/Yakutat', label: 'Yakutat' },
|
||||
{ value: 'America/Yellowknife', label: 'Yellowknife' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Antarctica',
|
||||
options: [
|
||||
{ value: 'Antarctica/Casey', label: 'Casey' },
|
||||
{ value: 'Antarctica/Davis', label: 'Davis' },
|
||||
{ value: 'Antarctica/DumontDUrville', label: 'DumontDUrville' },
|
||||
{ value: 'Antarctica/Macquarie', label: 'Macquarie' },
|
||||
{ value: 'Antarctica/Mawson', label: 'Mawson' },
|
||||
{ value: 'Antarctica/McMurdo', label: 'McMurdo' },
|
||||
{ value: 'Antarctica/Palmer', label: 'Palmer' },
|
||||
{ value: 'Antarctica/Rothera', label: 'Rothera' },
|
||||
{ value: 'Antarctica/Syowa', label: 'Syowa' },
|
||||
{ value: 'Antarctica/Troll', label: 'Troll' },
|
||||
{ value: 'Antarctica/Vostok', label: 'Vostok' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Arctic',
|
||||
options: [{ value: 'Arctic/Longyearbyen', label: 'Longyearbyen' }],
|
||||
},
|
||||
{
|
||||
label: 'Asia',
|
||||
options: [
|
||||
{ value: 'Asia/Aden', label: 'Aden' },
|
||||
{ value: 'Asia/Almaty', label: 'Almaty' },
|
||||
{ value: 'Asia/Amman', label: 'Amman' },
|
||||
{ value: 'Asia/Anadyr', label: 'Anadyr' },
|
||||
{ value: 'Asia/Aqtau', label: 'Aqtau' },
|
||||
{ value: 'Asia/Aqtobe', label: 'Aqtobe' },
|
||||
{ value: 'Asia/Ashgabat', label: 'Ashgabat' },
|
||||
{ value: 'Asia/Atyrau', label: 'Atyrau' },
|
||||
{ value: 'Asia/Baghdad', label: 'Baghdad' },
|
||||
{ value: 'Asia/Bahrain', label: 'Bahrain' },
|
||||
{ value: 'Asia/Baku', label: 'Baku' },
|
||||
{ value: 'Asia/Bangkok', label: 'Bangkok' },
|
||||
{ value: 'Asia/Barnaul', label: 'Barnaul' },
|
||||
{ value: 'Asia/Beirut', label: 'Beirut' },
|
||||
{ value: 'Asia/Bishkek', label: 'Bishkek' },
|
||||
{ value: 'Asia/Brunei', label: 'Brunei' },
|
||||
{ value: 'Asia/Chita', label: 'Chita' },
|
||||
{ value: 'Asia/Choibalsan', label: 'Choibalsan' },
|
||||
{ value: 'Asia/Colombo', label: 'Colombo' },
|
||||
{ value: 'Asia/Damascus', label: 'Damascus' },
|
||||
{ value: 'Asia/Dhaka', label: 'Dhaka' },
|
||||
{ value: 'Asia/Dili', label: 'Dili' },
|
||||
{ value: 'Asia/Dubai', label: 'Dubai' },
|
||||
{ value: 'Asia/Dushanbe', label: 'Dushanbe' },
|
||||
{ value: 'Asia/Famagusta', label: 'Famagusta' },
|
||||
{ value: 'Asia/Gaza', label: 'Gaza' },
|
||||
{ value: 'Asia/Hebron', label: 'Hebron' },
|
||||
{ value: 'Asia/Ho_Chi_Minh', label: 'Ho Chi Minh' },
|
||||
{ value: 'Asia/Hong_Kong', label: 'Hong Kong' },
|
||||
{ value: 'Asia/Hovd', label: 'Hovd' },
|
||||
{ value: 'Asia/Irkutsk', label: 'Irkutsk' },
|
||||
{ value: 'Asia/Jakarta', label: 'Jakarta' },
|
||||
{ value: 'Asia/Jayapura', label: 'Jayapura' },
|
||||
{ value: 'Asia/Jerusalem', label: 'Jerusalem' },
|
||||
{ value: 'Asia/Kabul', label: 'Kabul' },
|
||||
{ value: 'Asia/Kamchatka', label: 'Kamchatka' },
|
||||
{ value: 'Asia/Karachi', label: 'Karachi' },
|
||||
{ value: 'Asia/Kathmandu', label: 'Kathmandu' },
|
||||
{ value: 'Asia/Khandyga', label: 'Khandyga' },
|
||||
{ value: 'Asia/Kolkata', label: 'Kolkata' },
|
||||
{ value: 'Asia/Krasnoyarsk', label: 'Krasnoyarsk' },
|
||||
{ value: 'Asia/Kuala_Lumpur', label: 'Kuala Lumpur' },
|
||||
{ value: 'Asia/Kuching', label: 'Kuching' },
|
||||
{ value: 'Asia/Kuwait', label: 'Kuwait' },
|
||||
{ value: 'Asia/Macau', label: 'Macau' },
|
||||
{ value: 'Asia/Magadan', label: 'Magadan' },
|
||||
{ value: 'Asia/Makassar', label: 'Makassar' },
|
||||
{ value: 'Asia/Manila', label: 'Manila' },
|
||||
{ value: 'Asia/Muscat', label: 'Muscat' },
|
||||
{ value: 'Asia/Nicosia', label: 'Nicosia' },
|
||||
{ value: 'Asia/Novokuznetsk', label: 'Novokuznetsk' },
|
||||
{ value: 'Asia/Novosibirsk', label: 'Novosibirsk' },
|
||||
{ value: 'Asia/Omsk', label: 'Omsk' },
|
||||
{ value: 'Asia/Oral', label: 'Oral' },
|
||||
{ value: 'Asia/Phnom_Penh', label: 'Phnom Penh' },
|
||||
{ value: 'Asia/Pontianak', label: 'Pontianak' },
|
||||
{ value: 'Asia/Pyongyang', label: 'Pyongyang' },
|
||||
{ value: 'Asia/Qatar', label: 'Qatar' },
|
||||
{ value: 'Asia/Qostanay', label: 'Qostanay' },
|
||||
{ value: 'Asia/Qyzylorda', label: 'Qyzylorda' },
|
||||
{ value: 'Asia/Riyadh', label: 'Riyadh' },
|
||||
{ value: 'Asia/Sakhalin', label: 'Sakhalin' },
|
||||
{ value: 'Asia/Samarkand', label: 'Samarkand' },
|
||||
{ value: 'Asia/Seoul', label: 'Seoul' },
|
||||
{ value: 'Asia/Shanghai', label: 'Shanghai' },
|
||||
{ value: 'Asia/Singapore', label: 'Singapore' },
|
||||
{ value: 'Asia/Srednekolymsk', label: 'Srednekolymsk' },
|
||||
{ value: 'Asia/Taipei', label: 'Taipei' },
|
||||
{ value: 'Asia/Tashkent', label: 'Tashkent' },
|
||||
{ value: 'Asia/Tbilisi', label: 'Tbilisi' },
|
||||
{ value: 'Asia/Tehran', label: 'Tehran' },
|
||||
{ value: 'Asia/Thimphu', label: 'Thimphu' },
|
||||
{ value: 'Asia/Tokyo', label: 'Tokyo' },
|
||||
{ value: 'Asia/Tomsk', label: 'Tomsk' },
|
||||
{ value: 'Asia/Ulaanbaatar', label: 'Ulaanbaatar' },
|
||||
{ value: 'Asia/Urumqi', label: 'Urumqi' },
|
||||
{ value: 'Asia/Ust-Nera', label: 'Ust-Nera' },
|
||||
{ value: 'Asia/Vientiane', label: 'Vientiane' },
|
||||
{ value: 'Asia/Vladivostok', label: 'Vladivostok' },
|
||||
{ value: 'Asia/Yakutsk', label: 'Yakutsk' },
|
||||
{ value: 'Asia/Yangon', label: 'Yangon' },
|
||||
{ value: 'Asia/Yekaterinburg', label: 'Yekaterinburg' },
|
||||
{ value: 'Asia/Yerevan', label: 'Yerevan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Atlantic',
|
||||
options: [
|
||||
{ value: 'Atlantic/Azores', label: 'Azores' },
|
||||
{ value: 'Atlantic/Bermuda', label: 'Bermuda' },
|
||||
{ value: 'Atlantic/Canary', label: 'Canary' },
|
||||
{ value: 'Atlantic/Cape_Verde', label: 'Cape Verde' },
|
||||
{ value: 'Atlantic/Faroe', label: 'Faroe' },
|
||||
{ value: 'Atlantic/Madeira', label: 'Madeira' },
|
||||
{ value: 'Atlantic/Reykjavik', label: 'Reykjavik' },
|
||||
{ value: 'Atlantic/South_Georgia', label: 'South Georgia' },
|
||||
{ value: 'Atlantic/Stanley', label: 'Stanley' },
|
||||
{ value: 'Atlantic/St_Helena', label: 'St Helena' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Australia',
|
||||
options: [
|
||||
{ value: 'Australia/Adelaide', label: 'Adelaide' },
|
||||
{ value: 'Australia/Brisbane', label: 'Brisbane' },
|
||||
{ value: 'Australia/Broken_Hill', label: 'Broken Hill' },
|
||||
{ value: 'Australia/Currie', label: 'Currie' },
|
||||
{ value: 'Australia/Darwin', label: 'Darwin' },
|
||||
{ value: 'Australia/Eucla', label: 'Eucla' },
|
||||
{ value: 'Australia/Hobart', label: 'Hobart' },
|
||||
{ value: 'Australia/Lindeman', label: 'Lindeman' },
|
||||
{ value: 'Australia/Lord_Howe', label: 'Lord Howe' },
|
||||
{ value: 'Australia/Melbourne', label: 'Melbourne' },
|
||||
{ value: 'Australia/Perth', label: 'Perth' },
|
||||
{ value: 'Australia/Sydney', label: 'Sydney' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Europe',
|
||||
options: [
|
||||
{ value: 'Europe/Amsterdam', label: 'Amsterdam' },
|
||||
{ value: 'Europe/Andorra', label: 'Andorra' },
|
||||
{ value: 'Europe/Astrakhan', label: 'Astrakhan' },
|
||||
{ value: 'Europe/Athens', label: 'Athens' },
|
||||
{ value: 'Europe/Belgrade', label: 'Belgrade' },
|
||||
{ value: 'Europe/Berlin', label: 'Berlin' },
|
||||
{ value: 'Europe/Bratislava', label: 'Bratislava' },
|
||||
{ value: 'Europe/Brussels', label: 'Brussels' },
|
||||
{ value: 'Europe/Bucharest', label: 'Bucharest' },
|
||||
{ value: 'Europe/Budapest', label: 'Budapest' },
|
||||
{ value: 'Europe/Busingen', label: 'Busingen' },
|
||||
{ value: 'Europe/Chisinau', label: 'Chisinau' },
|
||||
{ value: 'Europe/Copenhagen', label: 'Copenhagen' },
|
||||
{ value: 'Europe/Dublin', label: 'Dublin' },
|
||||
{ value: 'Europe/Gibraltar', label: 'Gibraltar' },
|
||||
{ value: 'Europe/Guernsey', label: 'Guernsey' },
|
||||
{ value: 'Europe/Helsinki', label: 'Helsinki' },
|
||||
{ value: 'Europe/Isle_of_Man', label: 'Isle of Man' },
|
||||
{ value: 'Europe/Istanbul', label: 'Istanbul' },
|
||||
{ value: 'Europe/Jersey', label: 'Jersey' },
|
||||
{ value: 'Europe/Kaliningrad', label: 'Kaliningrad' },
|
||||
{ value: 'Europe/Kiev', label: 'Kiev' },
|
||||
{ value: 'Europe/Kirov', label: 'Kirov' },
|
||||
{ value: 'Europe/Lisbon', label: 'Lisbon' },
|
||||
{ value: 'Europe/Ljubljana', label: 'Ljubljana' },
|
||||
{ value: 'Europe/London', label: 'London' },
|
||||
{ value: 'Europe/Luxembourg', label: 'Luxembourg' },
|
||||
{ value: 'Europe/Madrid', label: 'Madrid' },
|
||||
{ value: 'Europe/Malta', label: 'Malta' },
|
||||
{ value: 'Europe/Mariehamn', label: 'Mariehamn' },
|
||||
{ value: 'Europe/Minsk', label: 'Minsk' },
|
||||
{ value: 'Europe/Monaco', label: 'Monaco' },
|
||||
{ value: 'Europe/Moscow', label: 'Moscow' },
|
||||
{ value: 'Europe/Oslo', label: 'Oslo' },
|
||||
{ value: 'Europe/Paris', label: 'Paris' },
|
||||
{ value: 'Europe/Podgorica', label: 'Podgorica' },
|
||||
{ value: 'Europe/Prague', label: 'Prague' },
|
||||
{ value: 'Europe/Riga', label: 'Riga' },
|
||||
{ value: 'Europe/Rome', label: 'Rome' },
|
||||
{ value: 'Europe/Samara', label: 'Samara' },
|
||||
{ value: 'Europe/San_Marino', label: 'San Marino' },
|
||||
{ value: 'Europe/Sarajevo', label: 'Sarajevo' },
|
||||
{ value: 'Europe/Saratov', label: 'Saratov' },
|
||||
{ value: 'Europe/Simferopol', label: 'Simferopol' },
|
||||
{ value: 'Europe/Skopje', label: 'Skopje' },
|
||||
{ value: 'Europe/Sofia', label: 'Sofia' },
|
||||
{ value: 'Europe/Stockholm', label: 'Stockholm' },
|
||||
{ value: 'Europe/Tallinn', label: 'Tallinn' },
|
||||
{ value: 'Europe/Tirane', label: 'Tirane' },
|
||||
{ value: 'Europe/Ulyanovsk', label: 'Ulyanovsk' },
|
||||
{ value: 'Europe/Uzhgorod', label: 'Uzhgorod' },
|
||||
{ value: 'Europe/Vaduz', label: 'Vaduz' },
|
||||
{ value: 'Europe/Vatican', label: 'Vatican' },
|
||||
{ value: 'Europe/Vienna', label: 'Vienna' },
|
||||
{ value: 'Europe/Vilnius', label: 'Vilnius' },
|
||||
{ value: 'Europe/Volgograd', label: 'Volgograd' },
|
||||
{ value: 'Europe/Warsaw', label: 'Warsaw' },
|
||||
{ value: 'Europe/Zagreb', label: 'Zagreb' },
|
||||
{ value: 'Europe/Zaporozhye', label: 'Zaporozhye' },
|
||||
{ value: 'Europe/Zurich', label: 'Zurich' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Indian',
|
||||
options: [
|
||||
{ value: 'Indian/Antananarivo', label: 'Antananarivo' },
|
||||
{ value: 'Indian/Chagos', label: 'Chagos' },
|
||||
{ value: 'Indian/Christmas', label: 'Christmas' },
|
||||
{ value: 'Indian/Cocos', label: 'Cocos' },
|
||||
{ value: 'Indian/Comoro', label: 'Comoro' },
|
||||
{ value: 'Indian/Kerguelen', label: 'Kerguelen' },
|
||||
{ value: 'Indian/Mahe', label: 'Mahe' },
|
||||
{ value: 'Indian/Maldives', label: 'Maldives' },
|
||||
{ value: 'Indian/Mauritius', label: 'Mauritius' },
|
||||
{ value: 'Indian/Mayotte', label: 'Mayotte' },
|
||||
{ value: 'Indian/Reunion', label: 'Reunion' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Pacific',
|
||||
options: [
|
||||
{ value: 'Pacific/Apia', label: 'Apia' },
|
||||
{ value: 'Pacific/Auckland', label: 'Auckland' },
|
||||
{ value: 'Pacific/Bougainville', label: 'Bougainville' },
|
||||
{ value: 'Pacific/Chatham', label: 'Chatham' },
|
||||
{ value: 'Pacific/Chuuk', label: 'Chuuk' },
|
||||
{ value: 'Pacific/Easter', label: 'Easter' },
|
||||
{ value: 'Pacific/Efate', label: 'Efate' },
|
||||
{ value: 'Pacific/Enderbury', label: 'Enderbury' },
|
||||
{ value: 'Pacific/Fakaofo', label: 'Fakaofo' },
|
||||
{ value: 'Pacific/Fiji', label: 'Fiji' },
|
||||
{ value: 'Pacific/Funafuti', label: 'Funafuti' },
|
||||
|
||||
{ value: 'Pacific/Galapagos', label: 'Galapagos' },
|
||||
{ value: 'Pacific/Gambier', label: 'Gambier' },
|
||||
{ value: 'Pacific/Guadalcanal', label: 'Guadalcanal' },
|
||||
{ value: 'Pacific/Guam', label: 'Guam' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Honolulu' },
|
||||
{ value: 'Pacific/Kiritimati', label: 'Kiritimati' },
|
||||
{ value: 'Pacific/Kosrae', label: 'Kosrae' },
|
||||
{ value: 'Pacific/Kwajalein', label: 'Kwajalein' },
|
||||
{ value: 'Pacific/Majuro', label: 'Majuro' },
|
||||
{ value: 'Pacific/Marquesas', label: 'Marquesas' },
|
||||
{ value: 'Pacific/Midway', label: 'Midway' },
|
||||
{ value: 'Pacific/Nauru', label: 'Nauru' },
|
||||
{ value: 'Pacific/Niue', label: 'Niue' },
|
||||
{ value: 'Pacific/Norfolk', label: 'Norfolk' },
|
||||
{ value: 'Pacific/Noumea', label: 'Noumea' },
|
||||
{ value: 'Pacific/Pago_Pago', label: 'Pago Pago' },
|
||||
{ value: 'Pacific/Palau', label: 'Palau' },
|
||||
{ value: 'Pacific/Pitcairn', label: 'Pitcairn' },
|
||||
{ value: 'Pacific/Pohnpei', label: 'Pohnpei' },
|
||||
{ value: 'Pacific/Port_Moresby', label: 'Port Moresby' },
|
||||
{ value: 'Pacific/Rarotonga', label: 'Rarotonga' },
|
||||
{ value: 'Pacific/Saipan', label: 'Saipan' },
|
||||
{ value: 'Pacific/Tahiti', label: 'Tahiti' },
|
||||
{ value: 'Pacific/Tarawa', label: 'Tarawa' },
|
||||
{ value: 'Pacific/Tongatapu', label: 'Tongatapu' },
|
||||
{ value: 'Pacific/Wake', label: 'Wake' },
|
||||
{ value: 'Pacific/Wallis', label: 'Wallis' },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
label: 'UTC',
|
||||
options: [{ value: 'UTC', label: 'UTC' }],
|
||||
},
|
||||
];
|
||||
export const DEFAULT_TIMEZONE = 'UTC';
|
||||
|
||||
export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
|
||||
'undeleted',
|
||||
'deleted',
|
||||
'downvote',
|
||||
'upvote',
|
||||
'reopened',
|
||||
'closed',
|
||||
'pin',
|
||||
'unpin',
|
||||
'show',
|
||||
'hide',
|
||||
];
|
||||
|
||||
export const SYSTEM_AVATAR_OPTIONS = [
|
||||
{
|
||||
label: 'System',
|
||||
value: 'system',
|
||||
},
|
||||
{
|
||||
label: 'Gravatar',
|
||||
value: 'gravatar',
|
||||
},
|
||||
];
|
||||
|
||||
export const TAG_SLUG_NAME_MAX_LENGTH = 35;
|
||||
|
||||
export const DEFAULT_THEME_COLOR = '#0033ff';
|
||||
|
||||
export const SUSPENSE_USER_TIME = [
|
||||
{
|
||||
label: 'hours',
|
||||
time: '24',
|
||||
value: '24h',
|
||||
},
|
||||
{
|
||||
label: 'hours',
|
||||
time: '48',
|
||||
value: '48h',
|
||||
},
|
||||
{
|
||||
label: 'hours',
|
||||
time: '72',
|
||||
value: '72h',
|
||||
},
|
||||
{
|
||||
label: 'days',
|
||||
time: '7',
|
||||
value: '7d',
|
||||
},
|
||||
{
|
||||
label: 'days',
|
||||
time: '14',
|
||||
value: '14d',
|
||||
},
|
||||
{
|
||||
label: 'months',
|
||||
time: '1',
|
||||
value: '1m',
|
||||
},
|
||||
{
|
||||
label: 'months',
|
||||
time: '2',
|
||||
value: '2m',
|
||||
},
|
||||
{
|
||||
label: 'months',
|
||||
time: '3',
|
||||
value: '3m',
|
||||
},
|
||||
{
|
||||
label: 'months',
|
||||
time: '6',
|
||||
value: '6m',
|
||||
},
|
||||
{
|
||||
label: 'year',
|
||||
time: '1',
|
||||
value: '1y',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,895 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface FormValue<T = any> {
|
||||
value: T;
|
||||
isInvalid: boolean;
|
||||
errorMsg: string;
|
||||
[prop: string]: any;
|
||||
}
|
||||
|
||||
export interface FormDataType {
|
||||
[prop: string]: FormValue;
|
||||
}
|
||||
|
||||
export interface FieldError {
|
||||
error_field: string;
|
||||
error_msg: string;
|
||||
}
|
||||
|
||||
export interface Paging {
|
||||
page: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export type ReportType = 'question' | 'answer' | 'comment' | 'user';
|
||||
export type ReportAction = 'close' | 'flag' | 'review';
|
||||
export interface ReportParams {
|
||||
type: ReportType;
|
||||
action: ReportAction;
|
||||
}
|
||||
|
||||
export interface TagBase {
|
||||
display_name: string;
|
||||
slug_name: string;
|
||||
original_text?: string;
|
||||
recommend?: boolean;
|
||||
reserved?: boolean;
|
||||
}
|
||||
|
||||
export interface Tag extends TagBase {
|
||||
main_tag_slug_name?: string;
|
||||
parsed_text?: string;
|
||||
tag_id?: string;
|
||||
}
|
||||
|
||||
export interface SynonymsTag extends Tag {
|
||||
tag_id: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface TagInfo extends TagBase {
|
||||
tag_id: string;
|
||||
original_text: string;
|
||||
parsed_text: string;
|
||||
follow_count: number;
|
||||
question_count: number;
|
||||
is_follower: boolean;
|
||||
member_actions;
|
||||
created_at?;
|
||||
updated_at?;
|
||||
main_tag_slug_name?: string;
|
||||
excerpt?;
|
||||
status: string;
|
||||
}
|
||||
export interface QuestionParams extends ImgCodeReq {
|
||||
title: string;
|
||||
url_title?: string;
|
||||
content: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface QuestionWithAnswer extends QuestionParams {
|
||||
answer_content: string;
|
||||
}
|
||||
|
||||
export interface ListResult<T = any> {
|
||||
count: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
export interface AnswerParams extends ImgCodeReq {
|
||||
content: string;
|
||||
html: string;
|
||||
question_id: string;
|
||||
id: string;
|
||||
edit_summary?: string;
|
||||
}
|
||||
|
||||
export interface LoginReqParams {
|
||||
e_mail: string;
|
||||
/** password */
|
||||
pass: string;
|
||||
captcha_id?: string;
|
||||
captcha_code?: string;
|
||||
}
|
||||
|
||||
export interface RegisterReqParams extends LoginReqParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ModifyPasswordReq {
|
||||
old_pass: string;
|
||||
pass: string;
|
||||
}
|
||||
|
||||
/** User */
|
||||
export interface ModifyUserReq {
|
||||
display_name: string;
|
||||
username?: string;
|
||||
avatar: any;
|
||||
bio: string;
|
||||
bio_html?: string;
|
||||
location: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
enum RoleId {
|
||||
User = 1,
|
||||
Admin = 2,
|
||||
Moderator = 3,
|
||||
}
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
rank: number;
|
||||
vote_count: number;
|
||||
display_name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface UserInfoBase {
|
||||
id?: string;
|
||||
avatar: any;
|
||||
username: string;
|
||||
display_name: string;
|
||||
rank: number;
|
||||
website: string;
|
||||
location: string;
|
||||
ip_info?: string;
|
||||
status?: 'normal' | 'suspended' | 'deleted' | 'inactive';
|
||||
/** roles */
|
||||
role_id?: RoleId;
|
||||
}
|
||||
|
||||
export interface UserInfoRes extends UserInfoBase {
|
||||
bio: string;
|
||||
bio_html: string;
|
||||
create_time?: string;
|
||||
/**
|
||||
* value = 1 active;
|
||||
* value = 2 inactivated
|
||||
*/
|
||||
mail_status: number;
|
||||
language: string;
|
||||
e_mail?: string;
|
||||
have_password: boolean;
|
||||
[prop: string]: any;
|
||||
}
|
||||
|
||||
export type UploadType = 'post' | 'avatar' | 'branding' | 'post_attachment';
|
||||
export interface UploadReq {
|
||||
file: FormData;
|
||||
}
|
||||
|
||||
export interface ImgCodeReq {
|
||||
captcha_id?: string;
|
||||
captcha_code?: string;
|
||||
}
|
||||
|
||||
export interface ImgCodeRes {
|
||||
captcha_id: string;
|
||||
captcha_img: string;
|
||||
verify: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordResetReq extends ImgCodeReq {
|
||||
e_mail: string;
|
||||
}
|
||||
|
||||
export interface PasswordReplaceReq extends ImgCodeReq {
|
||||
code: string;
|
||||
pass: string;
|
||||
}
|
||||
|
||||
export interface CaptchaReq extends ImgCodeReq {
|
||||
verify: ImgCodeRes['verify'];
|
||||
}
|
||||
|
||||
export type CaptchaKey =
|
||||
| 'email'
|
||||
| 'password'
|
||||
| 'edit_userinfo'
|
||||
| 'question'
|
||||
| 'answer'
|
||||
| 'comment'
|
||||
| 'edit'
|
||||
| 'invitation_answer'
|
||||
| 'search'
|
||||
| 'report'
|
||||
| 'delete'
|
||||
| 'vote';
|
||||
|
||||
export interface SetNoticeReq {
|
||||
notice_switch: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationBadgeAward {
|
||||
notification_id: string;
|
||||
badge_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
level: number;
|
||||
}
|
||||
export interface NotificationStatus {
|
||||
inbox: number;
|
||||
achievement: number;
|
||||
revision: number;
|
||||
can_revision: boolean;
|
||||
badge_award: NotificationBadgeAward | null;
|
||||
}
|
||||
|
||||
export interface QuestionDetailRes {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
html: string;
|
||||
tags: any[];
|
||||
view_count: number;
|
||||
unique_view_count?: number;
|
||||
answer_count: number;
|
||||
favorites_count: number;
|
||||
follow_counts: 0;
|
||||
accepted_answer_id: string;
|
||||
last_answer_id: string;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
user_info: UserInfoBase;
|
||||
answered: boolean;
|
||||
collected: boolean;
|
||||
answer_ids: string[];
|
||||
|
||||
[prop: string]: any;
|
||||
}
|
||||
|
||||
export interface AnswersReq extends Paging {
|
||||
order?: 'default' | 'updated' | 'created';
|
||||
question_id: string;
|
||||
}
|
||||
|
||||
export interface AnswerItem {
|
||||
id: string;
|
||||
question_id: string;
|
||||
content: string;
|
||||
html: string;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
user_info: UserInfoBase;
|
||||
[prop: string]: any;
|
||||
}
|
||||
|
||||
export interface PostAnswerReq extends ImgCodeReq {
|
||||
content: string;
|
||||
html?: string;
|
||||
question_id: string;
|
||||
}
|
||||
|
||||
export interface PageUser {
|
||||
id?;
|
||||
displayName;
|
||||
userName?;
|
||||
avatar_url?;
|
||||
}
|
||||
|
||||
export interface LangsType {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description interface for Question
|
||||
*/
|
||||
export type QuestionOrderBy =
|
||||
| 'recommend'
|
||||
| 'newest'
|
||||
| 'active'
|
||||
| 'hot'
|
||||
| 'score'
|
||||
| 'unanswered'
|
||||
| 'frequent';
|
||||
|
||||
export interface QueryQuestionsReq extends Paging {
|
||||
order: QuestionOrderBy;
|
||||
tag?: string;
|
||||
in_days?: number;
|
||||
}
|
||||
|
||||
export type AdminQuestionStatus =
|
||||
| 'available'
|
||||
| 'pending'
|
||||
| 'closed'
|
||||
| 'deleted';
|
||||
|
||||
export type AdminContentsFilterBy = 'normal' | 'pending' | 'closed' | 'deleted';
|
||||
|
||||
export interface AdminContentsReq extends Paging {
|
||||
status: AdminContentsFilterBy;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description interface for Answer
|
||||
*/
|
||||
export type AdminAnswerStatus = 'available' | 'deleted';
|
||||
|
||||
/**
|
||||
* @description interface for Users
|
||||
*/
|
||||
export type UserFilterBy =
|
||||
| 'normal'
|
||||
| 'staff'
|
||||
| 'inactive'
|
||||
| 'suspended'
|
||||
| 'deleted';
|
||||
|
||||
export type BadgeFilterBy = 'all' | 'active' | 'inactive';
|
||||
|
||||
export type InstalledPluginsFilterBy =
|
||||
| 'all'
|
||||
| 'active'
|
||||
| 'inactive'
|
||||
| 'outdated';
|
||||
/**
|
||||
* @description interface for Flags
|
||||
*/
|
||||
export type FlagStatus = 'pending' | 'completed';
|
||||
export type FlagType = 'all' | 'question' | 'answer' | 'comment';
|
||||
export interface AdminFlagsReq extends Paging {
|
||||
status: FlagStatus;
|
||||
object_type: FlagType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description interface for Admin Settings
|
||||
*/
|
||||
export interface AdminSettingsGeneral {
|
||||
name: string;
|
||||
short_description: string;
|
||||
description: string;
|
||||
site_url: string;
|
||||
contact_email: string;
|
||||
permalink?: number;
|
||||
}
|
||||
|
||||
export interface HelmetBase {
|
||||
pageTitle?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export interface HelmetUpdate extends Omit<HelmetBase, 'pageTitle'> {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsInterface {
|
||||
language: string;
|
||||
time_zone?: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsSmtp {
|
||||
encryption: string;
|
||||
from_email: string;
|
||||
from_name: string;
|
||||
smtp_authentication: boolean;
|
||||
smtp_host: string;
|
||||
smtp_password?: string;
|
||||
smtp_port: number;
|
||||
smtp_username?: string;
|
||||
test_email_recipient?: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsUsers {
|
||||
allow_update_avatar: boolean;
|
||||
allow_update_bio: boolean;
|
||||
allow_update_display_name: boolean;
|
||||
allow_update_location: boolean;
|
||||
allow_update_username: boolean;
|
||||
allow_update_website: boolean;
|
||||
default_avatar: string;
|
||||
gravatar_base_url: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsSecurity {
|
||||
external_content_display: string;
|
||||
check_update: boolean;
|
||||
login_required: boolean;
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
branding: AdminSettingBranding;
|
||||
general: AdminSettingsGeneral;
|
||||
interface: AdminSettingsInterface;
|
||||
login: AdminSettingsLogin;
|
||||
custom_css_html: AdminSettingsCustom;
|
||||
theme: AdminSettingsTheme;
|
||||
site_seo: AdminSettingsSeo;
|
||||
site_users: AdminSettingsUsers;
|
||||
site_advanced: AdminSettingsWrite;
|
||||
site_questions: AdminQuestionSetting;
|
||||
site_tags: AdminTagsSetting;
|
||||
version: string;
|
||||
revision: string;
|
||||
site_security: AdminSettingsSecurity;
|
||||
ai_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettingBranding {
|
||||
logo: string;
|
||||
square_icon: string;
|
||||
mobile_logo?: string;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsLegal {
|
||||
privacy_policy_original_text?: string;
|
||||
privacy_policy_parsed_text?: string;
|
||||
terms_of_service_original_text?: string;
|
||||
terms_of_service_parsed_text?: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsWrite {
|
||||
max_image_size?: number;
|
||||
max_attachment_size?: number;
|
||||
max_image_megapixel?: number;
|
||||
authorized_image_extensions?: string[];
|
||||
authorized_attachment_extensions?: string[];
|
||||
}
|
||||
|
||||
export interface AdminSettingsSeo {
|
||||
robots: string;
|
||||
/**
|
||||
* 0: not set
|
||||
* 1:with title
|
||||
* 2: no title
|
||||
*/
|
||||
permalink: number;
|
||||
}
|
||||
|
||||
export type themeConfig = {
|
||||
navbar_style: string;
|
||||
primary_color: string;
|
||||
[k: string]: string | number;
|
||||
};
|
||||
export interface AdminSettingsTheme {
|
||||
theme: string;
|
||||
color_scheme: string;
|
||||
layout: string;
|
||||
theme_options?: { label: string; value: string }[];
|
||||
theme_config: Record<string, themeConfig>;
|
||||
}
|
||||
|
||||
export interface AdminSettingsCustom {
|
||||
custom_css: string;
|
||||
custom_head: string;
|
||||
custom_header: string;
|
||||
custom_footer: string;
|
||||
custom_sidebar: string;
|
||||
}
|
||||
|
||||
export interface AdminSettingsLogin {
|
||||
allow_new_registrations: boolean;
|
||||
allow_email_registrations: boolean;
|
||||
allow_email_domains: string[];
|
||||
allow_password_login: boolean;
|
||||
require_email_verification: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description interface for Activity
|
||||
*/
|
||||
export interface FollowParams {
|
||||
is_cancel: boolean;
|
||||
object_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description search request params
|
||||
*/
|
||||
export interface SearchParams extends ImgCodeReq {
|
||||
q: string;
|
||||
order: string;
|
||||
page: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description search response data
|
||||
*/
|
||||
export interface SearchResItem {
|
||||
object_type: string;
|
||||
object: {
|
||||
url_title?: string;
|
||||
id: string;
|
||||
question_id?: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
created_at: number;
|
||||
user_info: UserInfoBase;
|
||||
vote_count: number;
|
||||
answer_count: number;
|
||||
accepted: boolean;
|
||||
tags: TagBase[];
|
||||
status?: string;
|
||||
};
|
||||
}
|
||||
export interface SearchRes extends ListResult<SearchResItem> {
|
||||
extra: any;
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
info: {
|
||||
question_count: number;
|
||||
resolved_count: number;
|
||||
resolved_rate: string;
|
||||
unanswered_count: number;
|
||||
unanswered_rate: string;
|
||||
answer_count: number;
|
||||
comment_count: number;
|
||||
vote_count: number;
|
||||
user_count: number;
|
||||
report_count: number;
|
||||
uploading_files: boolean;
|
||||
smtp: 'enabled' | 'disabled' | 'not_configured';
|
||||
time_zone: string;
|
||||
occupying_storage_space: string;
|
||||
app_start_time: number;
|
||||
https: boolean;
|
||||
login_required: boolean;
|
||||
go_version: string;
|
||||
database_version: string;
|
||||
database_size: string;
|
||||
version_info: {
|
||||
remote_version: string;
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineReq {
|
||||
show_vote: boolean;
|
||||
object_id: string;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
activity_id: number;
|
||||
revision_id: number;
|
||||
created_at: number;
|
||||
activity_type: string;
|
||||
comment: string;
|
||||
object_id: string;
|
||||
object_type: string;
|
||||
cancelled: boolean;
|
||||
cancelled_at: any;
|
||||
user_info: UserInfoBase;
|
||||
}
|
||||
|
||||
export interface TimelineObject {
|
||||
title: string;
|
||||
url_title?: string;
|
||||
object_type: string;
|
||||
question_id: string;
|
||||
answer_id: string;
|
||||
main_tag_slug_name?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface TimelineRes {
|
||||
object_info: TimelineObject;
|
||||
timeline: TimelineItem[];
|
||||
}
|
||||
|
||||
export interface SuggestReviewItem {
|
||||
type: 'question' | 'answer' | 'tag';
|
||||
info: {
|
||||
url_title?: string;
|
||||
object_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
html: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
unreviewed_info: {
|
||||
id: string;
|
||||
use_id: string;
|
||||
object_id: string;
|
||||
title: string;
|
||||
status: 0 | 1;
|
||||
create_at: number;
|
||||
user_info: UserInfoBase;
|
||||
reason: string;
|
||||
content: Tag | QuestionDetailRes | AnswerItem;
|
||||
};
|
||||
}
|
||||
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;
|
||||
url_title: string;
|
||||
parsed_text: string;
|
||||
}
|
||||
|
||||
export interface FlagReviewItem extends BaseReviewItem {
|
||||
reason: ReasonItem;
|
||||
reason_content: string;
|
||||
submitter_user: UserInfoBase;
|
||||
}
|
||||
|
||||
export interface FlagReviewResp {
|
||||
count: number;
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
export interface MemberActionItem {
|
||||
action: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface QuestionOperationReq {
|
||||
id: string;
|
||||
operation: 'pin' | 'unpin' | 'hide' | 'show';
|
||||
}
|
||||
|
||||
export interface OauthBindEmailReq {
|
||||
binding_key: string;
|
||||
email: string;
|
||||
must: boolean;
|
||||
}
|
||||
|
||||
export interface UserOauthConnectorItem {
|
||||
icon: string;
|
||||
name: string;
|
||||
link: string;
|
||||
binding: boolean;
|
||||
external_id: string;
|
||||
}
|
||||
|
||||
export interface NotificationConfigItem {
|
||||
enable: boolean;
|
||||
key: string;
|
||||
}
|
||||
export interface NotificationConfig {
|
||||
all_new_question: NotificationConfigItem;
|
||||
all_new_question_for_following_tags: NotificationConfigItem;
|
||||
inbox: NotificationConfigItem;
|
||||
}
|
||||
|
||||
export interface ActivatedPlugin {
|
||||
slug_name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description response for reaction
|
||||
*/
|
||||
export interface ReactionItems {
|
||||
reaction_summary: ReactionItem[];
|
||||
}
|
||||
|
||||
export interface ReactionItem {
|
||||
emoji: string;
|
||||
count: number;
|
||||
tooltip: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface BadgeListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
award_count: number;
|
||||
earned: boolean;
|
||||
/** 1: bronze 2: silver 3:gold */
|
||||
level: number;
|
||||
earned_count?: number;
|
||||
}
|
||||
|
||||
export interface BadgeListGroupItem {
|
||||
badges: BadgeListItem[];
|
||||
group_name: string;
|
||||
}
|
||||
|
||||
export interface BadgeInfo extends BadgeListItem {
|
||||
description: string;
|
||||
earned_count: number;
|
||||
is_single: boolean;
|
||||
}
|
||||
|
||||
export interface AdminBadgeListItem extends BadgeListItem {
|
||||
group_name: string;
|
||||
status: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BadgeDetailListReq {
|
||||
page: number;
|
||||
page_size: number;
|
||||
badge_id: string;
|
||||
username?: string | null;
|
||||
}
|
||||
export interface BadgeDetailListItem {
|
||||
created_at: number;
|
||||
author_user_info: UserInfoBase;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
url_title: string;
|
||||
question_id: string;
|
||||
answer_id: string;
|
||||
comment_id: string;
|
||||
}
|
||||
|
||||
export interface BadgeDetailListRes {
|
||||
count: number;
|
||||
list: BadgeDetailListItem[];
|
||||
}
|
||||
|
||||
export interface AdminApiKeysItem {
|
||||
access_key: string;
|
||||
created_at: number;
|
||||
description: string;
|
||||
id: number;
|
||||
last_used_at: number;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface AddOrEditApiKeyParams {
|
||||
description: string;
|
||||
scope?: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
enabled: boolean;
|
||||
chosen_provider: string;
|
||||
ai_providers: Array<{
|
||||
provider: string;
|
||||
api_host: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AiProviderItem {
|
||||
name: string;
|
||||
display_name: string;
|
||||
default_api_host: string;
|
||||
}
|
||||
|
||||
export interface ConversationListItem {
|
||||
conversation_id: string;
|
||||
created_at: number;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface AdminConversationListItem {
|
||||
id: string;
|
||||
topic: string;
|
||||
helpful_count: number;
|
||||
unhelpful_count: number;
|
||||
created_at: number;
|
||||
user_info: UserInfoBase;
|
||||
}
|
||||
|
||||
export interface ConversationDetailItem {
|
||||
chat_completion_id: string;
|
||||
content: string;
|
||||
reasoning_content?: string;
|
||||
role: string;
|
||||
helpful: number;
|
||||
unhelpful: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ConversationDetail {
|
||||
conversation_id: string;
|
||||
created_at: number;
|
||||
records: ConversationDetailItem[];
|
||||
topic: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface VoteConversationParams {
|
||||
cancel: boolean;
|
||||
chat_completion_id: string;
|
||||
vote_type: 'helpful' | 'unhelpful';
|
||||
}
|
||||
|
||||
export interface AdminQuestionSetting {
|
||||
min_tags: number;
|
||||
min_content: number;
|
||||
restrict_answer: boolean;
|
||||
}
|
||||
|
||||
export interface AdminTagsSetting {
|
||||
recommend_tags: Tag[];
|
||||
required_tag: boolean;
|
||||
reserved_tags: Tag[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const pattern = {
|
||||
email:
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+\.)+[a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}))$/,
|
||||
search:
|
||||
/(\[.*\])|(is:answer)|(is:question)|(score:\d*)|(user:\S*)|(answers:\d*)/g,
|
||||
uaWeChat: /micromessenger/i,
|
||||
uaWeCom: /wxwork/i,
|
||||
uaDingTalk: /dingtalk/i,
|
||||
};
|
||||
|
||||
export default pattern;
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.main-mx-with {
|
||||
width: 100%;
|
||||
max-width: 1072px;
|
||||
}
|
||||
|
||||
.answer-container {
|
||||
min-height: calc(100vh - 53px - 62px);
|
||||
}
|
||||
|
||||
.page-right-side {
|
||||
flex: none;
|
||||
width: 300px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
// lg
|
||||
@media screen and (max-width: 1199.9px) {
|
||||
.main-mx-with {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
max-width: 100%;
|
||||
}
|
||||
.page-right-side {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.collapse-indicator {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.expanding .collapse-indicator {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
#answerAccordion {
|
||||
max-width: 208px;
|
||||
.nav-link {
|
||||
color: var(--an-side-nav-link);
|
||||
}
|
||||
.nav-link:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--an-side-nav-link-hover-color);
|
||||
background-color: var(--bs-gray-100);
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--an-side-nav-link-hover-color);
|
||||
background-color: var(--bs-gray-200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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, useEffect, useState } from 'react';
|
||||
import { Accordion, Nav } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useMatch, NavLink } from 'react-router-dom';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { floppyNavigation } from '@/utils';
|
||||
import { Icon } from '@/components';
|
||||
import './index.css';
|
||||
|
||||
export interface MenuItem {
|
||||
name: string;
|
||||
path?: string;
|
||||
pathPrefix?: string;
|
||||
icon?: string;
|
||||
displayName?: string;
|
||||
badgeContent?: string | number;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
function MenuNode({
|
||||
menu,
|
||||
callback,
|
||||
activeKey,
|
||||
expanding = false,
|
||||
path = '/',
|
||||
}: {
|
||||
menu: MenuItem;
|
||||
callback: (evt: any, menu: MenuItem, href: string, isLeaf: boolean) => void;
|
||||
activeKey: string;
|
||||
expanding?: boolean;
|
||||
path?: string;
|
||||
}) {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
|
||||
const isLeaf = !menu.children || menu.children.length === 0;
|
||||
const href = isLeaf ? `${path}${menu.path || ''}` : '#';
|
||||
|
||||
return (
|
||||
<Nav.Item key={menu.path} className="w-100">
|
||||
{isLeaf ? (
|
||||
<Nav.Link
|
||||
eventKey={menu.path}
|
||||
as={NavLink}
|
||||
to={href}
|
||||
onClick={(evt) => {
|
||||
callback(evt, menu, href, isLeaf);
|
||||
}}
|
||||
className={classNames(
|
||||
'text-nowrap d-flex flex-nowrap align-items-center w-100',
|
||||
{
|
||||
expanding,
|
||||
active:
|
||||
activeKey === menu.path ||
|
||||
(menu.path && activeKey.startsWith(`${menu.path}/`)) ||
|
||||
// if pathPrefix is set, activate when activeKey starts with the pathPrefix
|
||||
(menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)),
|
||||
},
|
||||
)}>
|
||||
{menu?.icon && <Icon name={menu.icon} className="me-2" />}
|
||||
|
||||
<span className="me-auto text-truncate">
|
||||
{menu.displayName ? menu.displayName : t(menu.name)}
|
||||
</span>
|
||||
{menu.badgeContent ? (
|
||||
<span className="badge text-bg-dark">{menu.badgeContent}</span>
|
||||
) : null}
|
||||
{!isLeaf && (
|
||||
<Icon className="collapse-indicator" name="chevron-right" />
|
||||
)}
|
||||
</Nav.Link>
|
||||
) : (
|
||||
<Nav.Link
|
||||
eventKey={menu.path}
|
||||
as="button"
|
||||
href={href}
|
||||
onClick={(evt) => {
|
||||
callback(evt, menu, href, isLeaf);
|
||||
}}
|
||||
className={classNames(
|
||||
'text-nowrap d-flex flex-nowrap align-items-center w-100',
|
||||
{
|
||||
expanding,
|
||||
active:
|
||||
activeKey === menu.path ||
|
||||
(menu.path && activeKey.startsWith(`${menu.path}/`)) ||
|
||||
(menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)),
|
||||
},
|
||||
)}>
|
||||
{menu?.icon && <Icon name={menu.icon} className="me-2" />}
|
||||
<span className="me-auto text-truncate">
|
||||
{menu.displayName ? menu.displayName : t(menu.name)}
|
||||
</span>
|
||||
{menu.badgeContent ? (
|
||||
<span className="badge text-bg-dark">{menu.badgeContent}</span>
|
||||
) : null}
|
||||
{!isLeaf && (
|
||||
<Icon className="collapse-indicator" name="chevron-right" />
|
||||
)}
|
||||
</Nav.Link>
|
||||
)}
|
||||
|
||||
{menu.children && menu.children.length > 0 ? (
|
||||
<Accordion.Collapse eventKey={menu.path || menu.name} className="ms-4">
|
||||
<>
|
||||
{menu.children.map((leaf) => {
|
||||
return (
|
||||
<MenuNode
|
||||
menu={leaf}
|
||||
callback={callback}
|
||||
activeKey={activeKey}
|
||||
path={path}
|
||||
key={leaf.path || leaf.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</Accordion.Collapse>
|
||||
) : null}
|
||||
</Nav.Item>
|
||||
);
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
menus: MenuItem[];
|
||||
path?: string;
|
||||
}
|
||||
const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
|
||||
const navigate = useNavigate();
|
||||
const pathMatch = useMatch(`${path}*`);
|
||||
// auto set menu fields
|
||||
menus.forEach((m) => {
|
||||
if (!m.path) {
|
||||
m.path = m.name;
|
||||
}
|
||||
if (!Array.isArray(m.children)) {
|
||||
m.children = [];
|
||||
}
|
||||
m.children.forEach((sm) => {
|
||||
if (!sm.path) {
|
||||
sm.path = sm.name;
|
||||
}
|
||||
if (!Array.isArray(sm.children)) {
|
||||
sm.children = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const splat = pathMatch && pathMatch.params['*'];
|
||||
let activeKey: string = menus[0]?.path || menus[0]?.name || '';
|
||||
|
||||
if (splat) {
|
||||
activeKey = splat;
|
||||
}
|
||||
|
||||
const getOpenKey = () => {
|
||||
let openKey = '';
|
||||
menus.forEach((li) => {
|
||||
if (li.children && li.children.length > 0) {
|
||||
const matchedChild = li.children.find((el) => {
|
||||
// exact match or path prefix match
|
||||
return (
|
||||
el.path === activeKey ||
|
||||
(el.path && activeKey.startsWith(`${el.path}/`)) ||
|
||||
// if pathPrefix is set, activate when activeKey starts with the pathPrefix
|
||||
(el.pathPrefix && activeKey.startsWith(el.pathPrefix))
|
||||
);
|
||||
});
|
||||
if (matchedChild) {
|
||||
openKey = li.path || li.name || '';
|
||||
}
|
||||
}
|
||||
});
|
||||
return openKey;
|
||||
};
|
||||
|
||||
const [openKey, setOpenKey] = useState(getOpenKey());
|
||||
const menuClick = (evt, menu, href, isLeaf) => {
|
||||
evt.stopPropagation();
|
||||
if (isLeaf) {
|
||||
if (floppyNavigation.shouldProcessLinkClick(evt)) {
|
||||
evt.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
} else {
|
||||
setOpenKey(openKey === menu.path ? '' : menu.path);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setOpenKey(getOpenKey());
|
||||
}, [activeKey, menus]);
|
||||
return (
|
||||
<Accordion activeKey={openKey} flush id="answerAccordion">
|
||||
<Nav variant="pills" className="flex-column" activeKey={activeKey}>
|
||||
{menus.map((li) => {
|
||||
return (
|
||||
<MenuNode
|
||||
menu={li}
|
||||
path={path}
|
||||
callback={menuClick}
|
||||
activeKey={activeKey}
|
||||
expanding={openKey === (li.path || li.name)}
|
||||
key={li.path || li.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Nav>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccordionNav;
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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 { memo, FC, useState, useEffect } from 'react';
|
||||
import { Button, ButtonGroup } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
import { useToast } from '@/hooks';
|
||||
import { useCaptchaPlugin } from '@/utils/pluginKit';
|
||||
import { tryNormalLogged } from '@/utils/guard';
|
||||
import { bookmark, postVote } from '@/services';
|
||||
import * as Types from '@/common/interface';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
source: 'question' | 'answer';
|
||||
data: {
|
||||
id: string;
|
||||
votesCount: number;
|
||||
isLike: boolean;
|
||||
isHate: boolean;
|
||||
hideCollect?: boolean;
|
||||
collected: boolean;
|
||||
collectCount: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({ className, data, source }) => {
|
||||
const [votes, setVotes] = useState(0);
|
||||
const [like, setLike] = useState(false);
|
||||
const [hate, setHated] = useState(false);
|
||||
const [bookmarkState, setBookmark] = useState({
|
||||
state: data?.collected,
|
||||
count: data?.collectCount,
|
||||
});
|
||||
const { username = '' } = loggedUserInfoStore((state) => state.user);
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const vCaptcha = useCaptchaPlugin('vote');
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setVotes(data.votesCount);
|
||||
setLike(data.isLike);
|
||||
setHated(data.isHate);
|
||||
setBookmark({
|
||||
state: data?.collected,
|
||||
count: data?.collectCount,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitVote = (type) => {
|
||||
const isCancel = (type === 'up' && like) || (type === 'down' && hate);
|
||||
const imgCode: Types.ImgCodeReq = {
|
||||
captcha_id: undefined,
|
||||
captcha_code: undefined,
|
||||
};
|
||||
vCaptcha?.resolveCaptchaReq?.(imgCode);
|
||||
|
||||
postVote(
|
||||
{
|
||||
object_id: data?.id,
|
||||
is_cancel: isCancel,
|
||||
...imgCode,
|
||||
},
|
||||
type,
|
||||
)
|
||||
.then(async (res) => {
|
||||
await vCaptcha?.close();
|
||||
setVotes(res.votes);
|
||||
setLike(res.vote_status === 'vote_up');
|
||||
setHated(res.vote_status === 'vote_down');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.isError) {
|
||||
vCaptcha?.handleCaptchaError(err.list);
|
||||
}
|
||||
const errMsg = err?.value;
|
||||
if (errMsg) {
|
||||
toast.onShow({
|
||||
msg: errMsg,
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleVote = (type: 'up' | 'down') => {
|
||||
if (!tryNormalLogged(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.username === username) {
|
||||
toast.onShow({
|
||||
msg: t('cannot_vote_for_self'),
|
||||
variant: 'danger',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vCaptcha) {
|
||||
submitVote(type);
|
||||
return;
|
||||
}
|
||||
|
||||
vCaptcha.check(() => {
|
||||
submitVote(type);
|
||||
});
|
||||
};
|
||||
|
||||
const handleBookmark = () => {
|
||||
if (!tryNormalLogged(true)) {
|
||||
return;
|
||||
}
|
||||
bookmark({
|
||||
group_id: '0',
|
||||
object_id: data?.id,
|
||||
bookmark: !bookmarkState.state,
|
||||
}).then((res) => {
|
||||
setBookmark({
|
||||
state: !bookmarkState.state,
|
||||
count: res.object_collection_count,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(className)}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
title={
|
||||
source === 'question'
|
||||
? t('question_detail.question_useful')
|
||||
: t('question_detail.answer_useful')
|
||||
}
|
||||
variant="outline-secondary"
|
||||
active={like}
|
||||
onClick={() => handleVote('up')}>
|
||||
<Icon name="hand-thumbs-up-fill" />
|
||||
</Button>
|
||||
<Button variant="outline-secondary" className="opacity-100" disabled>
|
||||
{votes}
|
||||
</Button>
|
||||
<Button
|
||||
title={
|
||||
source === 'question'
|
||||
? t('question_detail.question_un_useful')
|
||||
: t('question_detail.answer_un_useful')
|
||||
}
|
||||
variant="outline-secondary"
|
||||
active={hate}
|
||||
onClick={() => handleVote('down')}>
|
||||
<Icon name="hand-thumbs-down-fill" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{!data?.hideCollect && (
|
||||
<Button
|
||||
variant="outline-secondary ms-3"
|
||||
title={t('question_detail.question_bookmark')}
|
||||
active={bookmarkState.state}
|
||||
onClick={handleBookmark}>
|
||||
<Icon name="bookmark-fill" />
|
||||
<span style={{ paddingLeft: '10px' }}>{bookmarkState.count}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 { useEffect } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import { AccordionNav, Icon } from '@/components';
|
||||
import type { MenuItem } from '@/components/AccordionNav';
|
||||
import { ADMIN_NAV_MENUS } from '@/common/constants';
|
||||
import { useQueryPlugins } from '@/services';
|
||||
import { interfaceStore } from '@/stores';
|
||||
|
||||
const AdminSideNav = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'btns' });
|
||||
const interfaceLang = interfaceStore((_) => _.interface.language);
|
||||
const { data: configurablePlugins, mutate: updateConfigurablePlugins } =
|
||||
useQueryPlugins({
|
||||
status: 'active',
|
||||
have_config: true,
|
||||
});
|
||||
|
||||
const menus = cloneDeep(ADMIN_NAV_MENUS) as MenuItem[];
|
||||
if (configurablePlugins && configurablePlugins.length > 0) {
|
||||
menus.forEach((item) => {
|
||||
if (item.name === 'plugins' && item.children) {
|
||||
item.children = [
|
||||
...item.children,
|
||||
...configurablePlugins.map(
|
||||
(plugin): MenuItem => ({
|
||||
name: plugin.slug_name,
|
||||
displayName: plugin.name,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const observePlugins = (evt) => {
|
||||
if (evt.data.msgType === 'refreshConfigurablePlugins') {
|
||||
updateConfigurablePlugins();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', observePlugins);
|
||||
return () => {
|
||||
window.removeEventListener('message', observePlugins);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
updateConfigurablePlugins();
|
||||
}, [interfaceLang]);
|
||||
|
||||
return (
|
||||
<div id="adminSideNav">
|
||||
<NavLink to="/" className="pb-3 d-inline-block link-secondary">
|
||||
<Icon name="arrow-left" className="me-2" />
|
||||
<span>{t('back_sites')}</span>
|
||||
</NavLink>
|
||||
<AccordionNav menus={menus} path="/admin/" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSideNav;
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 { memo, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import DefaultAvatar from '@/assets/images/default-avatar.svg';
|
||||
|
||||
interface IProps {
|
||||
/** avatar url */
|
||||
avatar: string | { type: string; gravatar: string; custom: string };
|
||||
/** size 48 96 128 256 */
|
||||
size: string;
|
||||
searchStr?: string;
|
||||
className?: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const Index: FC<IProps> = ({
|
||||
avatar,
|
||||
size,
|
||||
className,
|
||||
searchStr = '',
|
||||
alt,
|
||||
}) => {
|
||||
let url = '';
|
||||
if (typeof avatar === 'string') {
|
||||
if (avatar.length > 1) {
|
||||
url = `${avatar}?${searchStr}${
|
||||
avatar?.includes('gravatar') ? '&d=identicon' : ''
|
||||
}`;
|
||||
}
|
||||
} else if (avatar?.type === 'gravatar' && avatar.gravatar) {
|
||||
url = `${avatar.gravatar}?${searchStr}&d=identicon`;
|
||||
} else if (avatar?.type === 'custom' && avatar.custom) {
|
||||
url = `${avatar.custom}?${searchStr}`;
|
||||
}
|
||||
|
||||
const roundedCls =
|
||||
className && className.indexOf('rounded') !== -1 ? '' : 'rounded-circle';
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url || DefaultAvatar}
|
||||
width={size}
|
||||
height={size}
|
||||
className={classNames(roundedCls, className)}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 { memo, FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/components';
|
||||
import { formatCount } from '@/utils';
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
showAvatar?: boolean;
|
||||
avatarSize?: string;
|
||||
showReputation?: boolean;
|
||||
avatarSearchStr?: string;
|
||||
className?: string;
|
||||
avatarClass?: string;
|
||||
nameMaxWidth?: string;
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({
|
||||
data,
|
||||
showAvatar = true,
|
||||
avatarClass = '',
|
||||
avatarSize = '24px',
|
||||
className = 'small',
|
||||
avatarSearchStr = 's=48',
|
||||
showReputation = true,
|
||||
nameMaxWidth = '300px',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`d-flex align-items-center text-secondary ${className}`}>
|
||||
{data?.status !== 'deleted' ? (
|
||||
<Link
|
||||
to={`/users/${data?.username}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="d-flex align-items-center">
|
||||
{showAvatar && (
|
||||
<Avatar
|
||||
avatar={data?.avatar}
|
||||
size={avatarSize}
|
||||
className={`me-1 ${avatarClass}`}
|
||||
searchStr={avatarSearchStr}
|
||||
alt={data?.display_name}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="me-1 name-ellipsis"
|
||||
style={{ maxWidth: nameMaxWidth }}>
|
||||
{data?.display_name}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{showAvatar && (
|
||||
<Avatar
|
||||
avatar={data?.avatar}
|
||||
size={avatarSize}
|
||||
className={`me-1 ${avatarClass}`}
|
||||
searchStr={avatarSearchStr}
|
||||
alt={data?.display_name}
|
||||
/>
|
||||
)}
|
||||
<span className="me-1 name-ellipsis">{data?.display_name}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showReputation && (
|
||||
<span className="fw-bold" title="Reputation">
|
||||
{formatCount(data?.rank)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { ButtonGroup, Button } from 'react-bootstrap';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Icon, UploadImg } from '@/components';
|
||||
import { UploadType } from '@/common/interface';
|
||||
|
||||
interface Props {
|
||||
type: UploadType;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
acceptType?: string;
|
||||
readOnly?: boolean;
|
||||
imgClassNames?: classNames.Argument;
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({
|
||||
type = 'post',
|
||||
value,
|
||||
onChange,
|
||||
acceptType,
|
||||
readOnly = false,
|
||||
imgClassNames = '',
|
||||
}) => {
|
||||
const onUpload = (imgPath: string) => {
|
||||
onChange(imgPath);
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
onChange('');
|
||||
};
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="bg-gray-300 upload-img-wrap me-2 d-flex align-items-center justify-content-center">
|
||||
<img
|
||||
className={classNames(imgClassNames)}
|
||||
src={value}
|
||||
alt=""
|
||||
style={{ maxWidth: '100%', maxHeight: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<ButtonGroup vertical className="fit-content">
|
||||
<UploadImg
|
||||
type={type}
|
||||
uploadCallback={onUpload}
|
||||
className="mb-0"
|
||||
disabled={readOnly}
|
||||
acceptType={acceptType}>
|
||||
<Icon name="cloud-upload" />
|
||||
</UploadImg>
|
||||
|
||||
<Button
|
||||
disabled={readOnly}
|
||||
variant="outline-secondary"
|
||||
onClick={onRemove}>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* 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, useRef } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import copy from 'copy-to-clipboard';
|
||||
|
||||
import { markdownToHtml, voteConversation } from '@/services';
|
||||
import { Icon, htmlRender } from '@/components';
|
||||
|
||||
interface IProps {
|
||||
canType?: boolean;
|
||||
chatId: string;
|
||||
isLast: boolean;
|
||||
isCompleted: boolean;
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
minHeight?: number;
|
||||
actionData: {
|
||||
helpful: number;
|
||||
unhelpful: number;
|
||||
};
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string) =>
|
||||
text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const renderPlainTextAsHtml = (text: string) =>
|
||||
escapeHtml(text).replace(/\r?\n/g, '<br />');
|
||||
|
||||
const BubbleAi: FC<IProps> = ({
|
||||
canType = false,
|
||||
isLast,
|
||||
isCompleted,
|
||||
content,
|
||||
reasoningContent = '',
|
||||
chatId = '',
|
||||
actionData,
|
||||
minHeight = 0,
|
||||
}) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
|
||||
const [displayContent, setDisplayContent] = useState('');
|
||||
const [copyText, setCopyText] = useState<string>(t('copy'));
|
||||
const [isHelpful, setIsHelpful] = useState(false);
|
||||
const [isUnhelpful, setIsUnhelpful] = useState(false);
|
||||
const [canShowAction, setCanShowAction] = useState(false);
|
||||
const [isThinkingOpen, setIsThinkingOpen] = useState(true);
|
||||
const [safeHtml, setSafeHtml] = useState('');
|
||||
const typewriterRef = useRef<{
|
||||
timer: NodeJS.Timeout | null;
|
||||
index: number;
|
||||
isTyping: boolean;
|
||||
}>({
|
||||
timer: null,
|
||||
index: 0,
|
||||
isTyping: false,
|
||||
});
|
||||
const renderTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const renderTaskRef = useRef(0);
|
||||
const fmtContainer = useRef<HTMLDivElement>(null);
|
||||
// add ref for ScrollIntoView
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopy = () => {
|
||||
const res = copy(displayContent);
|
||||
if (res) {
|
||||
setCopyText(t('copied', { keyPrefix: 'messages' }));
|
||||
setTimeout(() => {
|
||||
setCopyText(t('copy'));
|
||||
}, 1200);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = (voteType: 'helpful' | 'unhelpful') => {
|
||||
const isCancel =
|
||||
(voteType === 'helpful' && isHelpful) ||
|
||||
(voteType === 'unhelpful' && isUnhelpful);
|
||||
voteConversation({
|
||||
chat_completion_id: chatId,
|
||||
cancel: isCancel,
|
||||
vote_type: voteType,
|
||||
}).then(() => {
|
||||
setIsHelpful(voteType === 'helpful' && !isCancel);
|
||||
setIsUnhelpful(voteType === 'unhelpful' && !isCancel);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ((!canType || !isLast) && content) {
|
||||
// 如果不是最后一个消息,直接返回,不进行打字效果
|
||||
if (typewriterRef.current.timer) {
|
||||
clearInterval(typewriterRef.current.timer);
|
||||
typewriterRef.current.timer = null;
|
||||
}
|
||||
setDisplayContent(content);
|
||||
setCanShowAction(true);
|
||||
typewriterRef.current.timer = null;
|
||||
typewriterRef.current.isTyping = false;
|
||||
return;
|
||||
}
|
||||
// 当内容变化时,清理之前的计时器
|
||||
if (typewriterRef.current.timer) {
|
||||
clearInterval(typewriterRef.current.timer);
|
||||
typewriterRef.current.timer = null;
|
||||
}
|
||||
|
||||
// 如果内容为空,则直接返回
|
||||
if (!content) {
|
||||
setDisplayContent('');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果内容比当前显示的短,则重置
|
||||
if (content.length < displayContent.length) {
|
||||
setDisplayContent('');
|
||||
typewriterRef.current.index = 0;
|
||||
}
|
||||
|
||||
// 如果内容与显示内容相同,不需要做任何事
|
||||
if (content === displayContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
typewriterRef.current.isTyping = true;
|
||||
|
||||
// start typing animation
|
||||
typewriterRef.current.timer = setInterval(() => {
|
||||
const currentIndex = typewriterRef.current.index;
|
||||
if (currentIndex < content.length) {
|
||||
const remainingLength = content.length - currentIndex;
|
||||
const baseRandomNum = Math.floor(Math.random() * 3) + 2;
|
||||
let randomNum = Math.min(baseRandomNum, remainingLength);
|
||||
|
||||
// 简单的单词边界检查(可选)
|
||||
const nextChar = content[currentIndex + randomNum];
|
||||
const prevChar = content[currentIndex + randomNum - 1];
|
||||
|
||||
// 如果下一个字符是字母,当前字符也是字母,尝试调整到空格处
|
||||
if (
|
||||
nextChar &&
|
||||
/[a-zA-Z]/.test(nextChar) &&
|
||||
/[a-zA-Z]/.test(prevChar)
|
||||
) {
|
||||
// 向前找1-2个字符,看看有没有空格
|
||||
for (
|
||||
let i = 1;
|
||||
i <= 2 && currentIndex + randomNum - i > currentIndex;
|
||||
i += 1
|
||||
) {
|
||||
if (content[currentIndex + randomNum - i] === ' ') {
|
||||
randomNum = randomNum - i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 向后找1-2个字符,看看有没有空格
|
||||
for (
|
||||
let i = 1;
|
||||
i <= 2 && currentIndex + randomNum + i < content.length;
|
||||
i += 1
|
||||
) {
|
||||
if (content[currentIndex + randomNum + i] === ' ') {
|
||||
randomNum = randomNum + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextIndex = currentIndex + randomNum;
|
||||
const newContent = content.substring(0, nextIndex);
|
||||
setDisplayContent(newContent);
|
||||
typewriterRef.current.index = nextIndex;
|
||||
setCanShowAction(false);
|
||||
} else {
|
||||
clearInterval(typewriterRef.current.timer as NodeJS.Timeout);
|
||||
typewriterRef.current.timer = null;
|
||||
typewriterRef.current.isTyping = false;
|
||||
setCanShowAction(false);
|
||||
}
|
||||
}, 30);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
if (typewriterRef.current.timer) {
|
||||
clearInterval(typewriterRef.current.timer);
|
||||
typewriterRef.current.timer = null;
|
||||
}
|
||||
};
|
||||
}, [content, isCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderTimerRef.current) {
|
||||
clearTimeout(renderTimerRef.current);
|
||||
renderTimerRef.current = null;
|
||||
}
|
||||
renderTaskRef.current += 1;
|
||||
const currentRenderTask = renderTaskRef.current;
|
||||
|
||||
if (!displayContent) {
|
||||
setSafeHtml('');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// During streaming, render escaped plain text to avoid executing unsanitized HTML.
|
||||
if (!isCompleted) {
|
||||
setSafeHtml(renderPlainTextAsHtml(displayContent));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
renderTimerRef.current = setTimeout(() => {
|
||||
markdownToHtml(displayContent)
|
||||
.then((resp) => {
|
||||
if (renderTaskRef.current !== currentRenderTask) {
|
||||
return;
|
||||
}
|
||||
setSafeHtml(resp || renderPlainTextAsHtml(displayContent));
|
||||
})
|
||||
.catch(() => {
|
||||
if (renderTaskRef.current !== currentRenderTask) {
|
||||
return;
|
||||
}
|
||||
setSafeHtml(renderPlainTextAsHtml(displayContent));
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
if (renderTimerRef.current) {
|
||||
clearTimeout(renderTimerRef.current);
|
||||
renderTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [displayContent, isCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHelpful(actionData.helpful > 0);
|
||||
setIsUnhelpful(actionData.unhelpful > 0);
|
||||
}, [actionData]);
|
||||
|
||||
// Auto-collapse the "Thinking" panel once the actual answer starts streaming
|
||||
// (only while the message is being generated; users can still toggle manually).
|
||||
useEffect(() => {
|
||||
if (content && !isCompleted) {
|
||||
setIsThinkingOpen(false);
|
||||
}
|
||||
}, [content, isCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fmtContainer.current && isCompleted && safeHtml) {
|
||||
htmlRender(fmtContainer.current, {
|
||||
copySuccessText: t('copied', { keyPrefix: 'messages' }),
|
||||
copyText: t('copy', { keyPrefix: 'messages' }),
|
||||
});
|
||||
const links = fmtContainer.current.querySelectorAll('a');
|
||||
links.forEach((link) => {
|
||||
link.setAttribute('target', '_blank');
|
||||
});
|
||||
setCanShowAction(true);
|
||||
}
|
||||
}, [isCompleted, safeHtml, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded bubble-ai"
|
||||
ref={containerRef}
|
||||
style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}>
|
||||
<div id={chatId}>
|
||||
{reasoningContent ? (
|
||||
<div
|
||||
className="bubble-ai-thinking mb-2 border-start border-2 ps-2 small text-secondary"
|
||||
style={{ borderColor: 'var(--bs-border-color)' }}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 link-secondary small text-decoration-none d-inline-flex align-items-center"
|
||||
onClick={() => setIsThinkingOpen((v) => !v)}>
|
||||
<Icon name={isThinkingOpen ? 'chevron-down' : 'chevron-right'} />
|
||||
<span className="ms-1">
|
||||
{isCompleted ? t('thoughts') : t('thinking')}
|
||||
</span>
|
||||
</Button>
|
||||
{isThinkingOpen && (
|
||||
<div
|
||||
className="mt-1 text-secondary"
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.85,
|
||||
}}>
|
||||
{reasoningContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="fmt text-break text-wrap"
|
||||
ref={fmtContainer}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
|
||||
{canShowAction && (
|
||||
<div className="action">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 link-secondary small me-3"
|
||||
onClick={handleCopy}>
|
||||
<Icon name="copy" />
|
||||
<span className="ms-1">{copyText}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className={`p-0 small me-3 ${isHelpful ? 'link-primary active' : 'link-secondary'}`}
|
||||
onClick={() => handleVote('helpful')}>
|
||||
<Icon name="hand-thumbs-up-fill" />
|
||||
<span className="ms-1">Helpful</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className={`p-0 small me-3 ${isUnhelpful ? 'link-primary active' : 'link-secondary'}`}
|
||||
onClick={() => handleVote('unhelpful')}>
|
||||
<Icon name="hand-thumbs-down-fill" />
|
||||
<span className="ms-1">Unhelpful</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BubbleAi;
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.bubble-user-wrap {
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
.bubble-user {
|
||||
background-color: var(--bs-gray-200);
|
||||
|
||||
[data-bs-theme='dark'] & {
|
||||
background-color: var(--bs-gray-800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import './index.scss';
|
||||
|
||||
interface BubbleUserProps {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const BubbleUser: FC<BubbleUserProps> = ({ content }) => {
|
||||
return (
|
||||
<div className="text-end bubble-user-wrap">
|
||||
<div className="d-inline-block text-start bubble-user p-3 rounded pre-line">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BubbleUser;
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.badge-card {
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 { useTranslation } from 'react-i18next';
|
||||
import { FC } from 'react';
|
||||
import { Card, Badge } from 'react-bootstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
import * as Type from '@/common/interface';
|
||||
import { formatCount } from '@/utils';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
interface IProps {
|
||||
data: Type.BadgeListItem;
|
||||
showAwardedCount?: boolean;
|
||||
urlSearchParams?: string;
|
||||
badgePillType?: 'earned' | 'count';
|
||||
}
|
||||
|
||||
const Index: FC<IProps> = ({
|
||||
data,
|
||||
badgePillType = 'earned',
|
||||
showAwardedCount = false,
|
||||
urlSearchParams,
|
||||
}) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'badges' });
|
||||
return (
|
||||
<Link
|
||||
className="card text-center badge-card"
|
||||
to={`/badges/${data.id}${urlSearchParams ? `?${urlSearchParams}` : ''}`}>
|
||||
<Card.Body>
|
||||
{Number(data?.earned_count) > 0 && badgePillType === 'earned' && (
|
||||
<Badge
|
||||
bg="success"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}>
|
||||
{`${t('earned')}${
|
||||
Number(data?.earned_count) > 1 ? ` ×${data.earned_count}` : ''
|
||||
}`}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{badgePillType === 'count' && Number(data?.earned_count) > 1 && (
|
||||
<Badge
|
||||
pill
|
||||
bg="secondary"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}>
|
||||
×{data.earned_count}
|
||||
</Badge>
|
||||
)}
|
||||
{data.icon.startsWith('http') ? (
|
||||
<img src={data.icon} width={96} height={96} alt={data.name} />
|
||||
) : (
|
||||
<Icon
|
||||
name={data.icon}
|
||||
size="96px"
|
||||
className={classnames(
|
||||
'lh-1',
|
||||
data.level === 1 && 'bronze',
|
||||
data.level === 2 && 'silver',
|
||||
data.level === 3 && 'gold',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h6 className="mb-0 mt-3 text-center">{data.name}</h6>
|
||||
{showAwardedCount && (
|
||||
<div className="small text-secondary mt-2">
|
||||
{t('×_awarded', { number: formatCount(data.award_count) })}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { Button, Dropdown } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Icon, FormatTime } from '@/components';
|
||||
|
||||
const ActionBar = ({
|
||||
nickName,
|
||||
username,
|
||||
createdAt,
|
||||
isVote,
|
||||
voteCount = 0,
|
||||
memberActions,
|
||||
onReply,
|
||||
onVote,
|
||||
onAction,
|
||||
userStatus = '',
|
||||
}) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-between flex-wrap small">
|
||||
<div className="d-flex align-items-center flex-wrap link-secondary">
|
||||
{userStatus !== 'deleted' ? (
|
||||
<Link
|
||||
to={`/users/${username}`}
|
||||
className="name-ellipsis"
|
||||
style={{ maxWidth: '200px' }}>
|
||||
{nickName}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{nickName}</span>
|
||||
)}
|
||||
<span className="mx-1">•</span>
|
||||
<FormatTime time={createdAt} className="me-3 flex-shrink-0" />
|
||||
<Button
|
||||
title={t('tip_vote')}
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={`flex-shrink-0 me-3 btn-no-border p-0 ${
|
||||
isVote ? '' : 'link-secondary'
|
||||
}`}
|
||||
onClick={onVote}>
|
||||
<Icon name="hand-thumbs-up-fill" />
|
||||
{voteCount > 0 && (
|
||||
<span className="ms-2 link-secondary">{voteCount}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="link-secondary m-0 p-0 btn-no-border"
|
||||
onClick={onReply}>
|
||||
{t('btn_reply')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="align-items-center control-area d-none">
|
||||
{memberActions.map((action, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={action.name}
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={classNames(
|
||||
'link-secondary btn-no-border m-0 p-0',
|
||||
index > 0 && 'ms-3',
|
||||
)}
|
||||
onClick={() => onAction(action)}>
|
||||
{action.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Dropdown className="d-block d-md-none">
|
||||
<Dropdown.Toggle
|
||||
as="div"
|
||||
variant="success"
|
||||
className="no-toggle"
|
||||
id="dropdown-comment">
|
||||
<Icon name="three-dots" className="text-secondary" />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu align="end">
|
||||
{memberActions.map((action) => {
|
||||
return (
|
||||
<Dropdown.Item key={action.name} onClick={() => onAction(action)}>
|
||||
{action.name}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ActionBar);
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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, useEffect, memo } from 'react';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { TextArea, Mentions } from '@/components';
|
||||
import { usePageUsers, usePromptWithUnload } from '@/hooks';
|
||||
import { parseEditMentionUser } from '@/utils';
|
||||
|
||||
const Index = ({
|
||||
className = '',
|
||||
value: initialValue = '',
|
||||
onSendReply,
|
||||
type = '',
|
||||
onCancel,
|
||||
mode,
|
||||
}) => {
|
||||
const [value, setValue] = useState('');
|
||||
const [immData, setImmData] = useState('');
|
||||
const pageUsers = usePageUsers();
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
|
||||
const [validationErrorMsg, setValidationErrorMsg] = useState('');
|
||||
|
||||
usePromptWithUnload({
|
||||
when: type === 'edit' ? immData !== value : Boolean(value),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!initialValue) {
|
||||
return;
|
||||
}
|
||||
setImmData(initialValue);
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setValue(e.target.value);
|
||||
};
|
||||
const handleSelected = (val) => {
|
||||
setValue(val);
|
||||
};
|
||||
const handleSendReply = () => {
|
||||
onSendReply(value).catch((ex) => {
|
||||
if (ex.isError) {
|
||||
setValidationErrorMsg(ex.msg);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'd-flex align-items-start flex-column flex-md-row',
|
||||
className,
|
||||
)}>
|
||||
<div className="w-100">
|
||||
<div
|
||||
className={classNames('custom-form-control', {
|
||||
'is-invalid': validationErrorMsg,
|
||||
})}>
|
||||
<Mentions
|
||||
pageUsers={pageUsers.getUsers()}
|
||||
onSelected={handleSelected}>
|
||||
<TextArea
|
||||
size="sm"
|
||||
value={type === 'edit' ? parseEditMentionUser(value) : value}
|
||||
onChange={handleChange}
|
||||
isInvalid={validationErrorMsg !== ''}
|
||||
/>
|
||||
</Mentions>
|
||||
<div className="form-text">{t(`tip_${mode}`)}</div>
|
||||
</div>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{validationErrorMsg}
|
||||
</Form.Control.Feedback>
|
||||
</div>
|
||||
{type === 'edit' ? (
|
||||
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-nowrap "
|
||||
onClick={() => handleSendReply()}>
|
||||
{t('btn_save_edits')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-nowrap btn-no-border ms-2 ms-md-0"
|
||||
onClick={onCancel}>
|
||||
{t('btn_cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-nowrap ms-0 ms-md-2 mt-2 mt-md-0"
|
||||
onClick={() => handleSendReply()}>
|
||||
{t('btn_add_comment')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { TextArea, Mentions } from '@/components';
|
||||
import { usePageUsers, usePromptWithUnload } from '@/hooks';
|
||||
|
||||
const Index = ({ userName, onSendReply, onCancel, mode }) => {
|
||||
const [value, setValue] = useState('');
|
||||
const pageUsers = usePageUsers();
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
|
||||
const [validationErrorMsg, setValidationErrorMsg] = useState('');
|
||||
|
||||
usePromptWithUnload({
|
||||
when: Boolean(value),
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
setValue(e.target.value);
|
||||
};
|
||||
const handleSelected = (val) => {
|
||||
setValue(val);
|
||||
};
|
||||
const handleSendReply = () => {
|
||||
onSendReply(value).catch((ex) => {
|
||||
if (ex.isError) {
|
||||
setValidationErrorMsg(ex.msg);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="small mb-2">
|
||||
{t('reply_to')} {userName}
|
||||
</div>
|
||||
<div className="d-flex mb-1 align-items-start flex-column flex-md-row">
|
||||
<div className="w-100">
|
||||
<div
|
||||
className={classNames('custom-form-control', {
|
||||
'is-invalid': validationErrorMsg,
|
||||
})}>
|
||||
<Mentions
|
||||
pageUsers={pageUsers.getUsers()}
|
||||
onSelected={handleSelected}>
|
||||
<TextArea
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
isInvalid={validationErrorMsg !== ''}
|
||||
/>
|
||||
</Mentions>
|
||||
<div className="form-text">{t(`tip_${mode}`)}</div>
|
||||
</div>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{validationErrorMsg}
|
||||
</Form.Control.Feedback>
|
||||
</div>
|
||||
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-nowrap"
|
||||
onClick={() => handleSendReply()}>
|
||||
{t('btn_add_comment')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-nowrap btn-no-border ms-2 ms-md-0"
|
||||
onClick={onCancel}>
|
||||
{t('btn_cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 Form from './Form';
|
||||
import ActionBar from './ActionBar';
|
||||
import Reply from './Reply';
|
||||
|
||||
export { Form, ActionBar, Reply };
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/mixins/_breakpoints';
|
||||
|
||||
.comments-wrap {
|
||||
.comment-item {
|
||||
&:hover {
|
||||
@include media-breakpoint-up(md) {
|
||||
.control-area {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
border-bottom: 1px solid var(--an-comment-item-border-bottom);
|
||||
}
|
||||
.fmt {
|
||||
display: inline;
|
||||
p {
|
||||
&:last-child {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
* 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, useState, useEffect } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import unionBy from 'lodash/unionBy';
|
||||
|
||||
import * as Types from '@/common/interface';
|
||||
import { Modal } from '@/components';
|
||||
import { usePageUsers, useReportModal, useCaptchaModal } from '@/hooks';
|
||||
import {
|
||||
matchedUsers,
|
||||
parseUserInfo,
|
||||
scrollToElementTop,
|
||||
bgFadeOut,
|
||||
} from '@/utils';
|
||||
import { tryNormalLogged } from '@/utils/guard';
|
||||
import { useCaptchaPlugin } from '@/utils/pluginKit';
|
||||
import {
|
||||
useQueryComments,
|
||||
addComment,
|
||||
deleteComment,
|
||||
updateComment,
|
||||
postVote,
|
||||
} from '@/services';
|
||||
import { commentReplyStore } from '@/stores';
|
||||
import Reactions from '@/pages/Questions/Detail/components/Reactions';
|
||||
|
||||
import { Form, ActionBar, Reply } from './components';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
interface IProps {
|
||||
objectId: string;
|
||||
mode?: 'answer' | 'question';
|
||||
commentId?: string | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Comment: FC<IProps> = ({ objectId, mode, commentId, children }) => {
|
||||
const pageUsers = usePageUsers();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [visibleComment, setVisibleComment] = useState(false);
|
||||
const { id: currentReplyId, update: updateCurrentReplyId } =
|
||||
commentReplyStore();
|
||||
const pageSize = pageIndex === 0 ? 3 : 15;
|
||||
const { data, mutate } = useQueryComments({
|
||||
object_id: objectId,
|
||||
comment_id: commentId,
|
||||
page: pageIndex,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const [comments, setComments] = useState<any>([]);
|
||||
|
||||
const reportModal = useReportModal();
|
||||
|
||||
const addCaptcha = useCaptchaModal('comment');
|
||||
const editCaptcha = useCaptchaPlugin('edit');
|
||||
const dCaptcha = useCaptchaPlugin('delete');
|
||||
const vCaptcha = useCaptchaPlugin('vote');
|
||||
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
|
||||
|
||||
useEffect(() => {
|
||||
if (pageIndex === 0 && commentId && comments.length !== 0) {
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(commentId);
|
||||
scrollToElementTop(el);
|
||||
bgFadeOut(el);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return () => {
|
||||
updateCurrentReplyId('');
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (data.count <= 3) {
|
||||
data.list.sort((a, b) => a.created_at - b.created_at);
|
||||
}
|
||||
if (pageIndex === 1 || pageIndex === 0) {
|
||||
setComments(data?.list);
|
||||
} else {
|
||||
setComments([...comments, ...data.list]);
|
||||
}
|
||||
const user: Types.PageUser[] = [];
|
||||
data.list.forEach((item) => {
|
||||
user.push({
|
||||
id: item.user_id,
|
||||
displayName: item.user_display_name,
|
||||
userName: item.username,
|
||||
});
|
||||
user.push({
|
||||
id: item.reply_comment_id,
|
||||
displayName: item.reply_user_display_name,
|
||||
userName: item.username,
|
||||
});
|
||||
});
|
||||
pageUsers.setUsers(user);
|
||||
}, [data]);
|
||||
|
||||
const handleReply = (id) => {
|
||||
if (!tryNormalLogged(true)) {
|
||||
return;
|
||||
}
|
||||
comments.forEach((item) => {
|
||||
if (item.comment_id === id) {
|
||||
updateCurrentReplyId(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (id) => {
|
||||
setComments(
|
||||
comments.map((item) => {
|
||||
if (item.comment_id === id) {
|
||||
item.showEdit = !item.showEdit;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const submitUpdateComment = (params, item) => {
|
||||
const up = {
|
||||
...params,
|
||||
comment_id: item.comment_id,
|
||||
captcha_code: undefined,
|
||||
captcha_id: undefined,
|
||||
};
|
||||
editCaptcha?.resolveCaptchaReq(up);
|
||||
|
||||
return updateComment(up)
|
||||
.then(async (res) => {
|
||||
await editCaptcha?.close();
|
||||
setComments(
|
||||
comments.map((comment) => {
|
||||
if (comment.comment_id === item.comment_id) {
|
||||
comment.showEdit = false;
|
||||
comment.parsed_text = res.parsed_text;
|
||||
comment.original_text = res.original_text;
|
||||
}
|
||||
return comment;
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.isError) {
|
||||
const captchaErr = editCaptcha?.handleCaptchaError(err.list);
|
||||
// If it is not a CAPTCHA error, leave it to the subsequent error handling logic to continue processing.
|
||||
if (!(captchaErr && err.list.length === 1)) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const submitAddComment = (params, item) => {
|
||||
const req = {
|
||||
...params,
|
||||
captcha_code: undefined,
|
||||
captcha_id: undefined,
|
||||
};
|
||||
addCaptcha?.resolveCaptchaReq(req);
|
||||
|
||||
return addComment(req)
|
||||
.then(async (res) => {
|
||||
await addCaptcha?.close();
|
||||
if (item.type === 'reply') {
|
||||
const index = comments.findIndex(
|
||||
(comment) => comment.comment_id === item.comment_id,
|
||||
);
|
||||
updateCurrentReplyId('');
|
||||
comments.splice(index + 1, 0, res);
|
||||
setComments([...comments]);
|
||||
} else {
|
||||
setComments([
|
||||
...comments.map((comment) => {
|
||||
if (comment.comment_id === item.comment_id) {
|
||||
updateCurrentReplyId('');
|
||||
}
|
||||
return comment;
|
||||
}),
|
||||
res,
|
||||
]);
|
||||
}
|
||||
|
||||
setVisibleComment(false);
|
||||
})
|
||||
.catch((ex) => {
|
||||
if (ex.isError) {
|
||||
const captchaErr = addCaptcha?.handleCaptchaError(ex.list);
|
||||
// If it is not a CAPTCHA error, leave it to the subsequent error handling logic to continue processing.
|
||||
if (!(captchaErr && ex.list.length === 1)) {
|
||||
return Promise.reject(ex);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendReply = (item) => {
|
||||
const users = matchedUsers(item.value);
|
||||
const userNames = unionBy(users.map((user) => user.userName));
|
||||
const commentMarkDown = parseUserInfo(item.value);
|
||||
|
||||
const params = {
|
||||
object_id: objectId,
|
||||
original_text: commentMarkDown,
|
||||
mention_username_list: userNames,
|
||||
...(item.type === 'reply'
|
||||
? {
|
||||
reply_comment_id: item.comment_id,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (item.type === 'edit') {
|
||||
if (!editCaptcha) {
|
||||
return submitUpdateComment(params, item);
|
||||
}
|
||||
return editCaptcha.check(() => submitUpdateComment(params, item));
|
||||
}
|
||||
|
||||
if (!addCaptcha) {
|
||||
return submitAddComment(params, item);
|
||||
}
|
||||
|
||||
return addCaptcha.check(() => submitAddComment(params, item));
|
||||
};
|
||||
|
||||
const submitDeleteComment = (id) => {
|
||||
const imgCode = { captcha_id: undefined, captcha_code: undefined };
|
||||
dCaptcha?.resolveCaptchaReq(imgCode);
|
||||
|
||||
deleteComment(id, imgCode)
|
||||
.then(async () => {
|
||||
await dCaptcha?.close();
|
||||
if (pageIndex === 0) {
|
||||
mutate();
|
||||
}
|
||||
setComments(comments.filter((item) => item.comment_id !== id));
|
||||
})
|
||||
.catch((ex) => {
|
||||
if (ex.isError) {
|
||||
dCaptcha?.handleCaptchaError(ex.list);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
Modal.confirm({
|
||||
title: t('title', { keyPrefix: 'delete' }),
|
||||
content: t('other', { keyPrefix: 'delete' }),
|
||||
confirmBtnVariant: 'danger',
|
||||
confirmText: t('delete', { keyPrefix: 'btns' }),
|
||||
onConfirm: () => {
|
||||
if (!dCaptcha) {
|
||||
submitDeleteComment(id);
|
||||
return;
|
||||
}
|
||||
dCaptcha.check(() => {
|
||||
submitDeleteComment(id);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submitVoteComment = (id, is_cancel) => {
|
||||
const imgCode: Types.ImgCodeReq = {
|
||||
captcha_id: undefined,
|
||||
captcha_code: undefined,
|
||||
};
|
||||
vCaptcha?.resolveCaptchaReq(imgCode);
|
||||
|
||||
postVote(
|
||||
{
|
||||
object_id: id,
|
||||
is_cancel,
|
||||
...imgCode,
|
||||
},
|
||||
'up',
|
||||
)
|
||||
.then(async () => {
|
||||
await vCaptcha?.close();
|
||||
setComments(
|
||||
comments.map((item) => {
|
||||
if (item.comment_id === id) {
|
||||
item.vote_count = is_cancel
|
||||
? item.vote_count - 1
|
||||
: item.vote_count + 1;
|
||||
item.is_vote = !is_cancel;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((ex) => {
|
||||
if (ex.isError) {
|
||||
vCaptcha?.handleCaptchaError(ex.list);
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleVote = (id, is_cancel) => {
|
||||
if (!tryNormalLogged(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vCaptcha) {
|
||||
submitVoteComment(id, is_cancel);
|
||||
return;
|
||||
}
|
||||
|
||||
vCaptcha.check(() => {
|
||||
submitVoteComment(id, is_cancel);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, item) => {
|
||||
if (!tryNormalLogged(true)) {
|
||||
return;
|
||||
}
|
||||
if (action === 'report') {
|
||||
reportModal.onShow({
|
||||
id: item.comment_id,
|
||||
type: 'comment',
|
||||
action: 'flag',
|
||||
});
|
||||
} else if (action === 'delete') {
|
||||
handleDelete(item.comment_id);
|
||||
} else if (action === 'edit') {
|
||||
handleEdit(item.comment_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (id) => {
|
||||
setComments(
|
||||
comments.map((item) => {
|
||||
if (item.comment_id === id) {
|
||||
item.showEdit = false;
|
||||
updateCurrentReplyId('');
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddComment = () => {
|
||||
if (!tryNormalLogged(true)) {
|
||||
setVisibleComment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVisibleComment(!visibleComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
'd-flex flex-wrap justify-content-between align-items-center',
|
||||
comments.length === 0 ? '' : 'mb-3',
|
||||
)}>
|
||||
<Reactions
|
||||
objectId={objectId}
|
||||
showAddCommentBtn={comments.length === 0}
|
||||
handleClickComment={handleAddComment}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'comments-wrap',
|
||||
comments.length > 0 && 'bg-light px-3 py-2 rounded',
|
||||
)}>
|
||||
{comments.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.comment_id}
|
||||
id={item.comment_id}
|
||||
className="py-2 comment-item">
|
||||
{item.showEdit ? (
|
||||
<Form
|
||||
className="mt-2"
|
||||
value={item.original_text}
|
||||
type="edit"
|
||||
mode={mode}
|
||||
onSendReply={(value) =>
|
||||
handleSendReply({ ...item, value, type: 'edit' })
|
||||
}
|
||||
onCancel={() => handleCancel(item.comment_id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="d-block">
|
||||
{item.reply_user_display_name &&
|
||||
(item.reply_user_status !== 'deleted' ? (
|
||||
<Link
|
||||
to={`/users/${item.reply_username}`}
|
||||
className="small me-1 text-nowrap">
|
||||
@{item.reply_user_display_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="small me-1 text-nowrap">
|
||||
@{item.reply_user_display_name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="fmt small text-break text-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: item.parsed_text }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentReplyId === item.comment_id ? (
|
||||
<Reply
|
||||
userName={item.user_display_name}
|
||||
mode={mode}
|
||||
onSendReply={(value) =>
|
||||
handleSendReply({ ...item, value, type: 'reply' })
|
||||
}
|
||||
onCancel={() => handleCancel(item.comment_id)}
|
||||
/>
|
||||
) : null}
|
||||
{item.showEdit || currentReplyId === item.comment_id ? null : (
|
||||
<ActionBar
|
||||
nickName={item.user_display_name}
|
||||
username={item.username}
|
||||
createdAt={item.created_at}
|
||||
voteCount={item.vote_count}
|
||||
isVote={item.is_vote}
|
||||
memberActions={item.member_actions}
|
||||
userStatus={item.user_status}
|
||||
onReply={() => {
|
||||
handleReply(item.comment_id);
|
||||
}}
|
||||
onAction={(action) => handleAction(action, item)}
|
||||
onVote={(e) => {
|
||||
e.preventDefault();
|
||||
handleVote(item.comment_id, item.is_vote);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={classNames(comments.length > 0 && 'py-2')}>
|
||||
{comments.length > 0 && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 btn-no-border"
|
||||
size="sm"
|
||||
onClick={handleAddComment}>
|
||||
{t('btn_add_comment')}
|
||||
</Button>
|
||||
)}
|
||||
{data &&
|
||||
(pageIndex || 1) < Math.ceil((data?.count || 0) / pageSize) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="p-0 ms-3 btn-no-border"
|
||||
onClick={() => {
|
||||
setPageIndex(pageIndex + 1);
|
||||
}}>
|
||||
{t('show_more', {
|
||||
count:
|
||||
data.count - (pageIndex === 0 ? 3 : pageIndex * pageSize),
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleComment && (
|
||||
<Form
|
||||
mode={mode}
|
||||
className={classNames(
|
||||
comments.length <= 0 ? 'mt-3' : 'mt-2',
|
||||
comments.length <= 0 && 'bg-light p-3 rounded',
|
||||
)}
|
||||
onSendReply={(value) => handleSendReply({ value, type: 'comment' })}
|
||||
onCancel={() => setVisibleComment(!visibleComment)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comment;
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import classname from 'classnames';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
import { formatCount } from '@/utils/common';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
votes: number;
|
||||
answers: number;
|
||||
views: number;
|
||||
};
|
||||
showVotes?: boolean;
|
||||
showAnswers?: boolean;
|
||||
showViews?: boolean;
|
||||
showAccepted?: boolean;
|
||||
isAccepted?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
const Index: FC<Props> = ({
|
||||
data,
|
||||
showVotes = true,
|
||||
showAnswers = true,
|
||||
showViews = true,
|
||||
isAccepted = false,
|
||||
showAccepted = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'counts' });
|
||||
|
||||
return (
|
||||
<div className={classname('d-flex align-items-center', className)}>
|
||||
{showVotes && (
|
||||
<div className="d-flex align-items-center flex-shrink-0 text-body">
|
||||
<Icon name="hand-thumbs-up-fill me-1" />
|
||||
<span className="fw-medium">{data.votes}</span>
|
||||
<span className="ms-1">{t('votes')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAccepted && (
|
||||
<div className="d-flex align-items-center ms-3 text-success flex-shrink-0">
|
||||
<Icon name="check-circle-fill me-1" />
|
||||
<span>{t('accepted')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAnswers && (
|
||||
<div
|
||||
className={`d-flex flex-shrink-0 align-items-center ms-3 ${
|
||||
isAccepted ? 'text-bg-success rounded-pill px-2 ' : ''
|
||||
}`}>
|
||||
{isAccepted ? (
|
||||
<Icon name="check-circle-fill me-1" />
|
||||
) : (
|
||||
<Icon name="chat-square-text-fill me-1" />
|
||||
)}
|
||||
<span className="fw-medium">{data.answers}</span>
|
||||
<span className="ms-1">{t('answers')}</span>
|
||||
</div>
|
||||
)}
|
||||
{showViews && (
|
||||
<span
|
||||
className={classname(
|
||||
'summary-stat ms-3 flex-shrink-0',
|
||||
data.views >= 100 * 1000
|
||||
? 'view-level3'
|
||||
: data.views >= 10000
|
||||
? 'view-level2'
|
||||
: data.views >= 1000
|
||||
? 'view-level1'
|
||||
: '',
|
||||
)}>
|
||||
<Icon name="bar-chart-fill" />
|
||||
<span className="fw-medium ms-1">{formatCount(data.views)}</span>
|
||||
<span className="ms-1">{t('views')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
|
||||
import { customizeStore } from '@/stores';
|
||||
|
||||
const Index = () => {
|
||||
const { custom_sidebar } = customizeStore((state) => state);
|
||||
if (!custom_sidebar) return null;
|
||||
return <div dangerouslySetInnerHTML={{ __html: custom_sidebar }} />;
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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, memo, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { customizeStore } from '@/stores';
|
||||
|
||||
const CUSTOM_MARK_HEAD = 'customize_head';
|
||||
const CUSTOM_MARK_HEADER = 'customize_header';
|
||||
const CUSTOM_MARK_FOOTER = 'customize_footer';
|
||||
|
||||
const makeMarker = (mark) => {
|
||||
return `<!--${mark}-->`;
|
||||
};
|
||||
|
||||
const ActivateScriptNodes = (el, part) => {
|
||||
let startMarkNode;
|
||||
const scriptList: HTMLScriptElement[] = [];
|
||||
const { childNodes } = el;
|
||||
for (let i = 0; i < childNodes.length; i += 1) {
|
||||
const node = childNodes[i];
|
||||
if (node.nodeType === 8 && node.nodeValue === part) {
|
||||
if (!startMarkNode) {
|
||||
startMarkNode = node;
|
||||
} else {
|
||||
// this is the endMarkNode
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (
|
||||
startMarkNode &&
|
||||
node.nodeType === 1 &&
|
||||
node.nodeName.toLowerCase() === 'script'
|
||||
) {
|
||||
scriptList.push(node);
|
||||
}
|
||||
}
|
||||
scriptList?.forEach((so) => {
|
||||
const script = document.createElement('script');
|
||||
script.text = `(() => {${so.text}})();`;
|
||||
for (let i = 0; i < so.attributes.length; i += 1) {
|
||||
const attr = so.attributes[i];
|
||||
script.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
el.replaceChild(script, so);
|
||||
});
|
||||
};
|
||||
|
||||
type pos = 'afterbegin' | 'beforeend';
|
||||
const renderCustomArea = (el, part, pos: pos, content: string = '') => {
|
||||
let startMarkNode;
|
||||
let endMarkNode;
|
||||
const { childNodes } = el;
|
||||
for (let i = 0; i < childNodes.length; i += 1) {
|
||||
const node = childNodes[i];
|
||||
if (node.nodeType === 8 && node.nodeValue === part) {
|
||||
if (!startMarkNode) {
|
||||
startMarkNode = node;
|
||||
} else {
|
||||
endMarkNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startMarkNode && endMarkNode) {
|
||||
while (
|
||||
startMarkNode.nextSibling &&
|
||||
startMarkNode.nextSibling !== endMarkNode
|
||||
) {
|
||||
el.removeChild(startMarkNode.nextSibling);
|
||||
}
|
||||
}
|
||||
if (startMarkNode) {
|
||||
el.removeChild(startMarkNode);
|
||||
}
|
||||
if (endMarkNode) {
|
||||
el.removeChild(endMarkNode);
|
||||
}
|
||||
el.insertAdjacentHTML(pos, makeMarker(part));
|
||||
el.insertAdjacentHTML(pos, content);
|
||||
el.insertAdjacentHTML(pos, makeMarker(part));
|
||||
ActivateScriptNodes(el, part);
|
||||
};
|
||||
const handleCustomHead = (content) => {
|
||||
const el = document.head;
|
||||
renderCustomArea(el, CUSTOM_MARK_HEAD, 'beforeend', content);
|
||||
};
|
||||
|
||||
const handleCustomHeader = (content) => {
|
||||
const el = document.body;
|
||||
renderCustomArea(el, CUSTOM_MARK_HEADER, 'afterbegin', content);
|
||||
};
|
||||
|
||||
const handleCustomFooter = (content) => {
|
||||
const el = document.body;
|
||||
renderCustomArea(el, CUSTOM_MARK_FOOTER, 'beforeend', content);
|
||||
};
|
||||
|
||||
const Index: FC = () => {
|
||||
const { custom_head, custom_header, custom_footer } = customizeStore(
|
||||
(state) => state,
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const isSeo = document.querySelector('meta[name="go-template"]');
|
||||
if (!isSeo) {
|
||||
setTimeout(() => {
|
||||
handleCustomHead(custom_head);
|
||||
}, 1000);
|
||||
handleCustomHeader(custom_header);
|
||||
handleCustomFooter(custom_footer);
|
||||
} else {
|
||||
isSeo.remove();
|
||||
}
|
||||
}, [custom_head, custom_header, custom_footer]);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* description: Activate scripts with data-client attribute when route changes
|
||||
*/
|
||||
const allScript = document.body.querySelectorAll('script[data-client]');
|
||||
allScript.forEach((scriptNode) => {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('data-client', 'true');
|
||||
// If the script is already wrapped in an IIFE, use it directly; otherwise, wrap it in an IIFE
|
||||
if (
|
||||
/^\s*\(\s*function\s*\(\s*\)\s*{/.test(
|
||||
(scriptNode as HTMLScriptElement).text,
|
||||
) ||
|
||||
/^\s*\(\s*\(\s*\)\s*=>\s*{/.test((scriptNode as HTMLScriptElement).text)
|
||||
) {
|
||||
script.text = (scriptNode as HTMLScriptElement).text;
|
||||
} else {
|
||||
script.text = `(() => {${(scriptNode as HTMLScriptElement).text}})();`;
|
||||
}
|
||||
for (let i = 0; i < scriptNode.attributes.length; i += 1) {
|
||||
const attr = scriptNode.attributes[i];
|
||||
if (attr.name !== 'data-client') {
|
||||
script.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
scriptNode.parentElement?.replaceChild(script, scriptNode);
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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, useLayoutEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
import Color from 'color';
|
||||
|
||||
import { shiftColor, tintColor, shadeColor } from '@/utils';
|
||||
import { themeSettingStore } from '@/stores';
|
||||
import { DEFAULT_THEME_COLOR } from '@/common/constants';
|
||||
|
||||
const Index: FC = () => {
|
||||
const { theme, theme_config } = themeSettingStore((_) => _);
|
||||
let primaryColor;
|
||||
if (theme_config?.[theme]?.primary_color) {
|
||||
primaryColor = Color(theme_config[theme].primary_color);
|
||||
}
|
||||
const setThemeColor = () => {
|
||||
const themeMetaNode = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeMetaNode) {
|
||||
const themeColor = primaryColor
|
||||
? primaryColor.hex()
|
||||
: DEFAULT_THEME_COLOR;
|
||||
themeMetaNode.setAttribute('content', themeColor);
|
||||
}
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
setThemeColor();
|
||||
}, [primaryColor]);
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{primaryColor && (
|
||||
<style>
|
||||
{`
|
||||
:root {
|
||||
--bs-blue: ${primaryColor.hex()};
|
||||
--bs-primary: ${primaryColor.hex()};
|
||||
--bs-primary-rgb: ${primaryColor.rgb().array().join(',')};
|
||||
--bs-link-color: ${primaryColor.hex()};
|
||||
--bs-link-color-rgb: ${primaryColor.rgb().array().join(',')};
|
||||
--bs-link-hover-color: ${shiftColor(primaryColor, 0.8).hex()};
|
||||
--bs-link-hover-color-rgb: ${shiftColor(primaryColor, 0.8)
|
||||
.round()
|
||||
.array()}
|
||||
}
|
||||
:root[data-bs-theme='dark'] {
|
||||
--bs-link-color: ${tintColor(primaryColor, 0.6).hex()};
|
||||
--bs-link-color-rgb: ${tintColor(primaryColor, 0.6)
|
||||
.round()
|
||||
.array()};
|
||||
--bs-link-hover-color: ${shiftColor(
|
||||
tintColor(primaryColor, 0.6),
|
||||
-0.8,
|
||||
).hex()};
|
||||
--bs-link-hover-color-rgb: ${shiftColor(
|
||||
tintColor(primaryColor, 0.6),
|
||||
-0.8,
|
||||
)
|
||||
.round()
|
||||
.array()};
|
||||
}
|
||||
.nav-pills {
|
||||
--bs-nav-pills-link-active-bg: ${primaryColor.hex()};
|
||||
}
|
||||
.btn-primary {
|
||||
--bs-btn-bg: ${primaryColor.hex()};
|
||||
--bs-btn-border-color: ${primaryColor.hex()};
|
||||
--bs-btn-hover-bg: ${tintColor(primaryColor, 0.85)};
|
||||
--bs-btn-hover-border-color: ${tintColor(primaryColor, 0.9)};
|
||||
--bs-btn-focus-shadow-rgb: ${shadeColor(primaryColor, 0.85)};
|
||||
--bs-btn-active-bg: ${tintColor(primaryColor, 0.8)};
|
||||
--bs-btn-active-border-color: ${tintColor(primaryColor, 0.9)};
|
||||
--bs-btn-disabled-bg: ${primaryColor.hex()};
|
||||
--bs-btn-disabled-border-color: ${primaryColor.hex()};
|
||||
}
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: ${primaryColor.hex()};
|
||||
--bs-btn-border-color: ${primaryColor.hex()};
|
||||
--bs-btn-hover-bg: ${primaryColor.hex()};
|
||||
--bs-btn-hover-border-color: ${primaryColor.hex()};
|
||||
--bs-btn-active-bg: ${primaryColor.hex()};
|
||||
--bs-btn-active-border-color: ${primaryColor.hex()};
|
||||
--bs-btn-disabled-color: ${primaryColor.hex()};
|
||||
--bs-btn-disabled-border-color: ${primaryColor.hex()};
|
||||
}
|
||||
.pagination {
|
||||
--bs-btn-color: ${primaryColor.hex()};
|
||||
--bs-pagination-active-bg: ${primaryColor.hex()};
|
||||
--bs-pagination-active-border-color: ${primaryColor.hex()};
|
||||
}
|
||||
.form-select:focus,
|
||||
.form-control:focus,
|
||||
.form-control.focus{
|
||||
box-shadow: 0 0 0 0.25rem ${primaryColor
|
||||
.fade(0.75)
|
||||
.string()} !important;
|
||||
border-color: ${tintColor(primaryColor, 0.5)} !important;
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: ${primaryColor.hex()};
|
||||
border-color: ${primaryColor.hex()};
|
||||
}
|
||||
.form-check-input:focus {
|
||||
border-color: ${tintColor(primaryColor, 0.5)};
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), .4);
|
||||
}
|
||||
.form-switch .form-check-input:focus {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%27-4 -4 8 8%27%3e%3ccircle r=%273%27 fill=%27${tintColor(
|
||||
primaryColor,
|
||||
0.5,
|
||||
)}%27/%3e%3c/svg%3e");
|
||||
}
|
||||
.tag-selector-wrap--focus {
|
||||
box-shadow: 0 0 0 0.25rem ${primaryColor
|
||||
.fade(0.75)
|
||||
.string()} !important;
|
||||
border-color: ${tintColor(primaryColor, 0.5)} !important;
|
||||
}
|
||||
.dropdown-menu {
|
||||
--bs-dropdown-link-active-bg: rgb(var(--bs-primary-rgb));
|
||||
}
|
||||
.link-primary {
|
||||
color: ${primaryColor.hex()}!important;
|
||||
}
|
||||
.link-primary:hover, .link-primary:focus {
|
||||
color: ${shadeColor(primaryColor, 0.8).hex()}!important;
|
||||
}
|
||||
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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, memo } from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { Tag } from '@/components';
|
||||
import { diffText } from '@/utils';
|
||||
|
||||
interface Props {
|
||||
objectType: string | 'question' | 'answer' | 'tag';
|
||||
newData: Record<string, any>;
|
||||
oldData?: Record<string, any>;
|
||||
className?: string;
|
||||
opts?: Partial<{
|
||||
showTitle: boolean;
|
||||
showTagUrlSlug: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({
|
||||
objectType,
|
||||
newData,
|
||||
oldData,
|
||||
className = '',
|
||||
opts = {
|
||||
showTitle: true,
|
||||
showTagUrlSlug: true,
|
||||
},
|
||||
}) => {
|
||||
if (!newData) return null;
|
||||
|
||||
let tag = newData.tags;
|
||||
if (objectType === 'question' && oldData?.tags) {
|
||||
const addTags = newData.tags.filter(
|
||||
(c) => !oldData?.tags?.find((p) => p.slug_name === c.slug_name),
|
||||
);
|
||||
|
||||
let deleteTags = oldData?.tags
|
||||
.filter((c) => !newData?.tags.find((p) => p.slug_name === c.slug_name))
|
||||
.map((v) => ({ ...v, state: 'delete' }));
|
||||
|
||||
deleteTags = deleteTags?.map((v) => {
|
||||
const index = oldData?.tags?.findIndex(
|
||||
(c) => c.slug_name === v.slug_name,
|
||||
);
|
||||
return {
|
||||
...v,
|
||||
pre_index: index,
|
||||
};
|
||||
});
|
||||
|
||||
tag = newData.tags.map((item) => {
|
||||
const find = addTags.find((c) => c.slug_name === item.slug_name);
|
||||
if (find) {
|
||||
return {
|
||||
...find,
|
||||
state: 'add',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
deleteTags.forEach((v) => {
|
||||
tag.splice(v.pre_index, 0, v);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{objectType !== 'answer' && opts?.showTitle && (
|
||||
<h5
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: diffText(
|
||||
newData.title?.replace(/</gi, '<'),
|
||||
oldData?.title?.replace(/</gi, '<'),
|
||||
),
|
||||
}}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
{objectType === 'question' && (
|
||||
<div className="mb-4">
|
||||
{tag?.map((item) => {
|
||||
return (
|
||||
<Tag
|
||||
key={item.slug_name}
|
||||
className="me-1"
|
||||
data={item}
|
||||
textClassName={`d-inline-block review-text-${item.state}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{objectType === 'tag' && opts?.showTagUrlSlug && (
|
||||
<div
|
||||
className={classnames(
|
||||
'small font-monospace',
|
||||
newData.original_text && 'mb-4',
|
||||
)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `/tags/${
|
||||
newData?.main_tag_slug_name
|
||||
? diffText(
|
||||
newData.main_tag_slug_name,
|
||||
oldData?.main_tag_slug_name,
|
||||
)
|
||||
: diffText(newData.slug_name, oldData?.slug_name)
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: diffText(newData.original_text, oldData?.original_text),
|
||||
}}
|
||||
className="pre-line text-break font-monospace small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
|
||||
import { Editor } from './types';
|
||||
|
||||
export const EditorContext = React.createContext<Editor | null>(null);
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { useEffect, useRef } from 'react';
|
||||
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { BaseEditorProps } from './types';
|
||||
import { useEditor } from './utils';
|
||||
|
||||
interface MarkdownEditorProps extends BaseEditorProps {}
|
||||
|
||||
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
onEditorReady,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const lastSyncedValueRef = useRef<string>(value);
|
||||
const isInitializedRef = useRef<boolean>(false);
|
||||
|
||||
const editor = useEditor({
|
||||
editorRef,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
initialValue: value,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || isInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isInitializedRef.current = true;
|
||||
onEditorReady?.(editor);
|
||||
}, [editor, onEditorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || value === lastSyncedValueRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = editor.getValue();
|
||||
if (currentValue !== value) {
|
||||
editor.setValue(value || '');
|
||||
lastSyncedValueRef.current = value || '';
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
useEffect(() => {
|
||||
lastSyncedValueRef.current = value;
|
||||
isInitializedRef.current = false;
|
||||
|
||||
return () => {
|
||||
if (editor) {
|
||||
const view = editor as unknown as EditorView;
|
||||
if (view.destroy) {
|
||||
view.destroy();
|
||||
}
|
||||
}
|
||||
isInitializedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="content-wrap">
|
||||
<div
|
||||
className="md-editor position-relative w-100 h-100"
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 { Dropdown, FormControl } from 'react-bootstrap';
|
||||
|
||||
interface IProps {
|
||||
options;
|
||||
value?;
|
||||
onChange?;
|
||||
placeholder?;
|
||||
onSelect?;
|
||||
}
|
||||
const Select: FC<IProps> = ({
|
||||
options = [],
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '',
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isFocus, setFocusState] = useState(false);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(0);
|
||||
}, [value]);
|
||||
const handleKeyDown = (e) => {
|
||||
const { keyCode } = e;
|
||||
|
||||
if (keyCode === 38 && cursor > 0) {
|
||||
e.preventDefault();
|
||||
setCursor(cursor - 1);
|
||||
}
|
||||
if (keyCode === 40 && cursor < options.length - 1) {
|
||||
e.preventDefault();
|
||||
|
||||
setCursor(cursor + 1);
|
||||
}
|
||||
if (keyCode === 13 && cursor > -1 && cursor <= options.length - 1) {
|
||||
const lang = options.filter((opt) =>
|
||||
value ? opt.indexOf(value) === 0 : true,
|
||||
)[cursor];
|
||||
|
||||
setFocusState(false);
|
||||
onSelect(lang);
|
||||
}
|
||||
};
|
||||
|
||||
const result = options.filter((opt) =>
|
||||
value ? opt.indexOf(value) === 0 : true,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="position-relative" onKeyDown={handleKeyDown}>
|
||||
<FormControl
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setFocusState(true);
|
||||
if (onChange instanceof Function) {
|
||||
onChange(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{result.length > 0 && (
|
||||
<Dropdown.Menu
|
||||
show={value && isFocus}
|
||||
className="border py-2 rounded w-100"
|
||||
style={{ overflowY: 'auto', maxHeight: '250px' }}>
|
||||
{result.map((opt, index) => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={opt}
|
||||
className={`${cursor === index ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFocusState(false);
|
||||
onSelect(opt);
|
||||
}}>
|
||||
{opt}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const BlockQuote = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
|
||||
const item = {
|
||||
label: 'quote',
|
||||
keyMap: ['Ctrl-q'],
|
||||
tip: `${t('blockquote.text')} (Ctrl+Q)`,
|
||||
};
|
||||
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertBlockquote(t('blockquote.text'));
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(BlockQuote);
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Bold = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'type-bold',
|
||||
keyMap: ['Ctrl-b'],
|
||||
tip: `${t('bold.text')} (Ctrl+b)`,
|
||||
};
|
||||
const DEFAULTTEXT = t('bold.text');
|
||||
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertBold(DEFAULTTEXT);
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Bold);
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* 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 { useEffect, useRef, useState, memo } from 'react';
|
||||
import { Button, Form, Modal } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Select from '../Select';
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const codeLanguageType = [
|
||||
'bash',
|
||||
'sh',
|
||||
'zsh',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'c++',
|
||||
'h++',
|
||||
'cc',
|
||||
'hh',
|
||||
'cxx',
|
||||
'hxx',
|
||||
'c-like',
|
||||
'cs',
|
||||
'csharp',
|
||||
'c#',
|
||||
'clojure',
|
||||
'clj',
|
||||
'coffee',
|
||||
'coffeescript',
|
||||
'cson',
|
||||
'iced',
|
||||
'css',
|
||||
'dart',
|
||||
'erl',
|
||||
'erlang',
|
||||
'go',
|
||||
'golang',
|
||||
'hs',
|
||||
'haskell',
|
||||
'html',
|
||||
'xml',
|
||||
'xsl',
|
||||
'xhtml',
|
||||
'rss',
|
||||
'atom',
|
||||
'xjb',
|
||||
'xsd',
|
||||
'plist',
|
||||
'wsf',
|
||||
'svg',
|
||||
'http',
|
||||
'https',
|
||||
'ini',
|
||||
'toml',
|
||||
'java',
|
||||
'jsp',
|
||||
'js',
|
||||
'javascript',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'json',
|
||||
'kotlin',
|
||||
'kt',
|
||||
'latex',
|
||||
'tex',
|
||||
'less',
|
||||
'lisp',
|
||||
'lua',
|
||||
'makefile',
|
||||
'mk',
|
||||
'mak',
|
||||
'markdown',
|
||||
'md',
|
||||
'mkdown',
|
||||
'mkd',
|
||||
'matlab',
|
||||
'objectivec',
|
||||
'mm',
|
||||
'objc',
|
||||
'obj-c',
|
||||
'ocaml',
|
||||
'ml',
|
||||
'pascal',
|
||||
'delphi',
|
||||
'dpr',
|
||||
'dfm',
|
||||
'pas',
|
||||
'freepascal',
|
||||
'lazarus',
|
||||
'lpr',
|
||||
'lfm',
|
||||
'pl',
|
||||
'perl',
|
||||
'pm',
|
||||
'php',
|
||||
'php3',
|
||||
'php4',
|
||||
'php5',
|
||||
'php6',
|
||||
'php7',
|
||||
'php-template',
|
||||
'protobuf',
|
||||
'py',
|
||||
'python',
|
||||
'gyp',
|
||||
'ipython',
|
||||
'r',
|
||||
'rb',
|
||||
'ruby',
|
||||
'gemspec',
|
||||
'podspec',
|
||||
'thor',
|
||||
'irb',
|
||||
'rs',
|
||||
'rust',
|
||||
'scala',
|
||||
'scheme',
|
||||
'scss',
|
||||
'shell',
|
||||
'console',
|
||||
'sql',
|
||||
'swift',
|
||||
'typescript',
|
||||
'ts',
|
||||
'vhdl',
|
||||
'vbnet',
|
||||
'vb',
|
||||
'yaml',
|
||||
'yml',
|
||||
];
|
||||
|
||||
const Code = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
|
||||
const item = {
|
||||
label: 'code-slash',
|
||||
keyMap: ['Ctrl-k'],
|
||||
tip: `${t('code.text')} (Ctrl+k)`,
|
||||
};
|
||||
|
||||
const [code, setCode] = useState({
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
});
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [lang, setLang] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const SINGLELINEMAXLENGTH = 40;
|
||||
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
|
||||
|
||||
const addCode = (editor: Editor) => {
|
||||
setCurrentEditor(editor);
|
||||
const text = editor.getSelection();
|
||||
|
||||
if (!text) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (text.length > SINGLELINEMAXLENGTH) {
|
||||
editor.insertCodeBlock('', text);
|
||||
} else {
|
||||
editor.insertCode(text);
|
||||
}
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!currentEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code.value.trim()) {
|
||||
setCode({
|
||||
...code,
|
||||
errorMsg: t('code.form.fields.code.msg.empty'),
|
||||
isInvalid: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
code.value.split('\n').length > 1 ||
|
||||
code.value.length >= SINGLELINEMAXLENGTH
|
||||
) {
|
||||
currentEditor.insertCodeBlock(lang || undefined, code.value);
|
||||
} else {
|
||||
currentEditor.insertCode(code.value);
|
||||
}
|
||||
|
||||
setCode({
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
});
|
||||
setLang('');
|
||||
setVisible(false);
|
||||
currentEditor.focus();
|
||||
};
|
||||
const onHide = () => setVisible(false);
|
||||
const onExited = () => currentEditor?.focus();
|
||||
|
||||
return (
|
||||
<ToolItem {...item} onClick={addCode}>
|
||||
<Modal
|
||||
show={visible}
|
||||
onHide={onHide}
|
||||
onExited={onExited}
|
||||
fullscreen="sm-down">
|
||||
<Modal.Header closeButton>
|
||||
<h5 className="mb-0">{t('code.add_code')}</h5>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group controlId="editor.code" className="mb-3">
|
||||
<Form.Label>{t('code.form.fields.code.label')}</Form.Label>
|
||||
<Form.Control
|
||||
ref={inputRef}
|
||||
as="textarea"
|
||||
rows={3}
|
||||
value={code.value}
|
||||
isInvalid={code.isInvalid}
|
||||
className="font-monospace"
|
||||
style={{ height: '200px' }}
|
||||
onChange={(e) => setCode({ ...code, value: e.target.value })}
|
||||
/>
|
||||
{code.isInvalid && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{code.errorMsg}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<Form.Group controlId="editor.codeLanguageType" className="mb-3">
|
||||
<Form.Label>{`${t('code.form.fields.language.label')} ${t(
|
||||
'optional',
|
||||
{
|
||||
keyPrefix: 'form',
|
||||
},
|
||||
)}`}</Form.Label>
|
||||
<Select
|
||||
options={codeLanguageType}
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
onSelect={(val) => setLang(val)}
|
||||
placeholder={t('code.form.fields.language.placeholder')}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setCode({
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
});
|
||||
}}>
|
||||
{t('code.btn_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleClick}>
|
||||
{t('code.btn_confirm')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</ToolItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Code);
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { memo, useRef, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Modal as AnswerModal } from '@/components';
|
||||
import ToolItem from '../toolItem';
|
||||
import { EditorContext } from '../EditorContext';
|
||||
import { uploadImage } from '@/services';
|
||||
import { writeSettingStore } from '@/stores';
|
||||
|
||||
const File = () => {
|
||||
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 = useContext(EditorContext);
|
||||
|
||||
const item = {
|
||||
label: 'paperclip',
|
||||
tip: `${t('file.text')}`,
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
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(text, startPos, endPos);
|
||||
})
|
||||
.catch(() => {
|
||||
editor.replaceRange('', startPos, endPos);
|
||||
})
|
||||
.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(File);
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { Dropdown } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor, Level } from '../types';
|
||||
|
||||
const Heading = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const headerList = [
|
||||
{
|
||||
text: `<h2 class="mb-0 h4">${t('heading.options.h2')}</h2>`,
|
||||
level: 2,
|
||||
label: t('heading.options.h2'),
|
||||
},
|
||||
{
|
||||
text: `<h3 class="mb-0 h5">${t('heading.options.h3')}</h3>`,
|
||||
level: 3,
|
||||
label: t('heading.options.h3'),
|
||||
},
|
||||
{
|
||||
text: `<h4 class="mb-0 h6">${t('heading.options.h4')}</h4>`,
|
||||
level: 4,
|
||||
label: t('heading.options.h4'),
|
||||
},
|
||||
{
|
||||
text: `<h5 class="mb-0 small">${t('heading.options.h5')}</h5>`,
|
||||
level: 5,
|
||||
label: t('heading.options.h5'),
|
||||
},
|
||||
{
|
||||
text: `<h6 class="mb-0 fs-12">${t('heading.options.h6')}</h6>`,
|
||||
level: 6,
|
||||
label: t('heading.options.h6'),
|
||||
},
|
||||
];
|
||||
const item = {
|
||||
label: 'type-h2',
|
||||
keyMap: ['Ctrl-h'],
|
||||
tip: `${t('heading.text')} (Ctrl+h)`,
|
||||
};
|
||||
const [isShow, setShowState] = useState(false);
|
||||
const [isLocked, setLockState] = useState(false);
|
||||
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
|
||||
|
||||
const handleClick = (level: Level = 2, label?: string) => {
|
||||
if (!currentEditor) {
|
||||
return;
|
||||
}
|
||||
currentEditor.insertHeading(level, label);
|
||||
currentEditor.focus();
|
||||
setShowState(false);
|
||||
};
|
||||
const onAddHeader = (editor: Editor) => {
|
||||
setCurrentEditor(editor);
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
setShowState(!isShow);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setLockState(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setLockState(false);
|
||||
};
|
||||
return (
|
||||
<ToolItem
|
||||
as="dropdown"
|
||||
{...item}
|
||||
isShow={isShow}
|
||||
onClick={onAddHeader}
|
||||
onBlur={onAddHeader}>
|
||||
<Dropdown.Menu
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}>
|
||||
{headerList.map((header) => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={header.text}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClick(header.level as Level, header.label);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: header.text }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</ToolItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Heading);
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
|
||||
const Help = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
|
||||
const item = {
|
||||
label: 'question-circle-fill',
|
||||
tip: t('help.text'),
|
||||
};
|
||||
const handleClick = () => {
|
||||
window.open('https://commonmark.org/help/');
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Help);
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Hr = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'hr',
|
||||
keyMap: ['Ctrl-r'],
|
||||
tip: `${t('hr.text')} (Ctrl+r)`,
|
||||
};
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertHorizontalRule();
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Hr);
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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 { useEffect, useState, memo, useContext } from 'react';
|
||||
import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { EditorContext } from '../EditorContext';
|
||||
import { Editor } from '../types';
|
||||
import { useImageUpload } from '../hooks/useImageUpload';
|
||||
|
||||
const Image = () => {
|
||||
const editor = useContext(EditorContext);
|
||||
const [editorState, setEditorState] = useState<Editor | null>(editor);
|
||||
|
||||
// Update editor state when editor context changes
|
||||
// This ensures event listeners are re-bound when switching editor modes
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
setEditorState(editor);
|
||||
}
|
||||
}, [editor]);
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const { verifyImageSize, uploadFiles } = useImageUpload();
|
||||
|
||||
const loadingText = `![${t('image.uploading')}...]()`;
|
||||
|
||||
const item = {
|
||||
label: 'image-fill',
|
||||
keyMap: ['Ctrl-g'],
|
||||
tip: `${t('image.text')} (Ctrl+G)`,
|
||||
};
|
||||
const [currentTab, setCurrentTab] = useState('localImage');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [link, setLink] = useState({
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
type: '',
|
||||
});
|
||||
|
||||
const [imageName, setImageName] = useState({
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
});
|
||||
|
||||
function dragenter(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function dragover(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
const drop = async (e) => {
|
||||
const fileList = e.dataTransfer.files;
|
||||
const bool = verifyImageSize(fileList);
|
||||
|
||||
if (!bool) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorState) {
|
||||
return;
|
||||
}
|
||||
const startPos = editorState.getCursor();
|
||||
|
||||
const endPos = { ...startPos, ch: startPos.ch + loadingText.length };
|
||||
|
||||
editorState.replaceSelection(loadingText);
|
||||
editorState.setReadOnly(true);
|
||||
const urls = await uploadFiles(fileList)
|
||||
.catch(() => {
|
||||
editorState.replaceRange('', startPos, endPos);
|
||||
})
|
||||
.finally(() => {
|
||||
editorState?.setReadOnly(false);
|
||||
editorState?.focus();
|
||||
});
|
||||
|
||||
const text: string[] = [];
|
||||
if (Array.isArray(urls)) {
|
||||
urls.forEach(({ name, url, type }) => {
|
||||
if (name && url) {
|
||||
text.push(`${type === 'post' ? '!' : ''}[${name}](${url})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (text.length) {
|
||||
editorState.replaceRange(text.join('\n'), startPos, endPos);
|
||||
} else {
|
||||
editorState?.replaceRange('', startPos, endPos);
|
||||
}
|
||||
};
|
||||
|
||||
const paste = async (event) => {
|
||||
const clipboard = event.clipboardData;
|
||||
|
||||
const bool = verifyImageSize(clipboard.files);
|
||||
|
||||
if (bool) {
|
||||
event.preventDefault();
|
||||
if (!editorState) {
|
||||
return;
|
||||
}
|
||||
const startPos = editorState.getCursor();
|
||||
const endPos = { ...startPos, ch: startPos.ch + loadingText.length };
|
||||
|
||||
editorState?.replaceSelection(loadingText);
|
||||
editorState?.setReadOnly(true);
|
||||
uploadFiles(clipboard.files)
|
||||
.then((urls) => {
|
||||
const text = urls.map(({ name, url, type }) => {
|
||||
return `${type === 'post' ? '!' : ''}[${name}](${url})`;
|
||||
});
|
||||
|
||||
editorState.replaceRange(text.join('\n'), startPos, endPos);
|
||||
})
|
||||
.catch(() => {
|
||||
editorState.replaceRange('', startPos, endPos);
|
||||
})
|
||||
.finally(() => {
|
||||
editorState?.setReadOnly(false);
|
||||
editorState?.focus();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlStr = clipboard.getData('text/html');
|
||||
const imgRegex = /<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
|
||||
|
||||
if (!htmlStr.match(imgRegex)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlStr, 'text/html');
|
||||
const { body } = doc;
|
||||
|
||||
let markdownText = '';
|
||||
|
||||
function traverse(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
// text node
|
||||
markdownText += node.textContent;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// element node
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
|
||||
if (tagName === 'img') {
|
||||
// img node
|
||||
const src = node.getAttribute('src');
|
||||
const alt = node.getAttribute('alt') || t('image.text');
|
||||
markdownText += ``;
|
||||
} else if (tagName === 'br') {
|
||||
// br node
|
||||
markdownText += '\n';
|
||||
} else {
|
||||
for (let i = 0; i < node.childNodes.length; i += 1) {
|
||||
traverse(node.childNodes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const blockLevelElements = [
|
||||
'p',
|
||||
'div',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
];
|
||||
if (blockLevelElements.includes(tagName)) {
|
||||
markdownText += '\n\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(body);
|
||||
|
||||
markdownText = markdownText.replace(/[\n\s]+/g, (match) => {
|
||||
return match.length > 1 ? '\n\n' : match;
|
||||
});
|
||||
|
||||
if (editorState) {
|
||||
editorState.replaceSelection(markdownText);
|
||||
}
|
||||
};
|
||||
const handleClick = () => {
|
||||
if (!link.value) {
|
||||
setLink({ ...link, isInvalid: true });
|
||||
return;
|
||||
}
|
||||
setLink({ ...link, type: '' });
|
||||
|
||||
if (editorState) {
|
||||
editorState.insertImage(link.value, imageName.value || undefined);
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
|
||||
editorState?.focus();
|
||||
setLink({ ...link, value: '' });
|
||||
setImageName({ ...imageName, value: '' });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!editorState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
editorState.on('dragenter', dragenter);
|
||||
editorState.on('dragover', dragover);
|
||||
editorState.on('drop', drop);
|
||||
editorState.on('paste', paste);
|
||||
|
||||
return () => {
|
||||
editorState.off('dragenter', dragenter);
|
||||
editorState.off('dragover', dragover);
|
||||
editorState.off('drop', drop);
|
||||
editorState.off('paste', paste);
|
||||
};
|
||||
}, [editorState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (link.value && link.type === 'drop') {
|
||||
handleClick();
|
||||
}
|
||||
}, [link.value]);
|
||||
|
||||
const addLink = (editorInstance: Editor) => {
|
||||
setEditorState(editorInstance);
|
||||
const text = editorInstance?.getSelection();
|
||||
|
||||
setImageName({ ...imageName, value: text });
|
||||
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const { uploadSingleFile } = useImageUpload();
|
||||
|
||||
const onUpload = async (e) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const files = e.target?.files || [];
|
||||
const bool = verifyImageSize(files);
|
||||
|
||||
if (!bool) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadSingleFile(e.target.files[0]).then((url) => {
|
||||
setLink({ ...link, value: url });
|
||||
setImageName({ ...imageName, value: files[0].name });
|
||||
});
|
||||
};
|
||||
|
||||
const onHide = () => setVisible(false);
|
||||
const onExited = () => editor?.focus();
|
||||
|
||||
const handleSelect = (tab) => {
|
||||
setCurrentTab(tab);
|
||||
};
|
||||
return (
|
||||
<ToolItem {...item} onClick={addLink}>
|
||||
<Modal
|
||||
show={visible}
|
||||
onHide={onHide}
|
||||
onExited={onExited}
|
||||
fullscreen="sm-down">
|
||||
<Modal.Header closeButton>
|
||||
<h5 className="mb-0">{t('image.add_image')}</h5>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Tabs onSelect={handleSelect}>
|
||||
<Tab eventKey="localImage" title={t('image.tab_image')}>
|
||||
<Form className="mt-3" onSubmit={handleClick}>
|
||||
<Form.Group controlId="editor.imgLink" className="mb-3">
|
||||
<Form.Label>
|
||||
{t('image.form_image.fields.file.label')}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
onChange={onUpload}
|
||||
isInvalid={currentTab === 'localImage' && link.isInvalid}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{t('image.form_image.fields.file.msg.empty')}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="editor.imgDescription" className="mb-3">
|
||||
<Form.Label>
|
||||
{`${t('image.form_image.fields.desc.label')} ${t(
|
||||
'optional',
|
||||
{
|
||||
keyPrefix: 'form',
|
||||
},
|
||||
)}`}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={imageName.value}
|
||||
onChange={(e) =>
|
||||
setImageName({ ...imageName, value: e.target.value })
|
||||
}
|
||||
isInvalid={imageName.isInvalid}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Tab>
|
||||
<Tab eventKey="remoteImage" title={t('image.tab_url')}>
|
||||
<Form className="mt-3" onSubmit={handleClick}>
|
||||
<Form.Group controlId="editor.imgUrl" className="mb-3">
|
||||
<Form.Label>
|
||||
{t('image.form_url.fields.url.label')}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={link.value}
|
||||
onChange={(e) =>
|
||||
setLink({ ...link, value: e.target.value })
|
||||
}
|
||||
isInvalid={currentTab === 'remoteImage' && link.isInvalid}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{t('image.form_url.fields.url.msg.empty')}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="editor.imgName" className="mb-3">
|
||||
<Form.Label>
|
||||
{`${t('image.form_url.fields.name.label')} ${t('optional', {
|
||||
keyPrefix: 'form',
|
||||
})}`}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={imageName.value}
|
||||
onChange={(e) =>
|
||||
setImageName({ ...imageName, value: e.target.value })
|
||||
}
|
||||
isInvalid={imageName.isInvalid}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="link" onClick={() => setVisible(false)}>
|
||||
{t('image.btn_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleClick}>
|
||||
{t('image.btn_confirm')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</ToolItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Image);
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Indent = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'text-indent-left',
|
||||
tip: t('indent.text'),
|
||||
};
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.indent();
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Indent);
|
||||
@@ -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 Table from './table';
|
||||
import OL from './ol';
|
||||
import UL from './ul';
|
||||
import Indent from './indent';
|
||||
import Outdent from './outdent';
|
||||
import Hr from './hr';
|
||||
import Heading from './heading';
|
||||
import Bold from './bold';
|
||||
import Italice from './italic';
|
||||
import Code from './code';
|
||||
import Link from './link';
|
||||
import BlockQuote from './blockquote';
|
||||
import Image from './image';
|
||||
import Help from './help';
|
||||
import File from './file';
|
||||
|
||||
export {
|
||||
Table,
|
||||
OL,
|
||||
UL,
|
||||
Indent,
|
||||
Outdent,
|
||||
Hr,
|
||||
Heading,
|
||||
Bold,
|
||||
Italice,
|
||||
Code,
|
||||
Link,
|
||||
BlockQuote,
|
||||
Image,
|
||||
Help,
|
||||
File,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Italic = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'type-italic',
|
||||
keyMap: ['Ctrl-i'],
|
||||
tip: `${t('italic.text')} (Ctrl+i)`,
|
||||
};
|
||||
const DEFAULTTEXT = t('italic.text');
|
||||
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertItalic(DEFAULTTEXT);
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Italic);
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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 { useEffect, useRef, useState, memo } from 'react';
|
||||
import { Button, Form, Modal } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Link = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'link-45deg',
|
||||
keyMap: ['Ctrl-l'],
|
||||
tip: `${t('link.text')} (Ctrl+l)`,
|
||||
};
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
|
||||
const [link, setLink] = useState({
|
||||
value: 'https://',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
});
|
||||
const [name, setName] = useState({
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && inputRef.current) {
|
||||
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const addLink = (editor: Editor) => {
|
||||
setCurrentEditor(editor);
|
||||
const text = editor.getSelection();
|
||||
setName({ ...name, value: text });
|
||||
setVisible(true);
|
||||
};
|
||||
const handleClick = () => {
|
||||
if (!currentEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!link.value) {
|
||||
setLink({ ...link, isInvalid: true });
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditor.insertLink(link.value, name.value || undefined);
|
||||
|
||||
setVisible(false);
|
||||
currentEditor.focus();
|
||||
setLink({ ...link, value: '' });
|
||||
setName({ ...name, value: '' });
|
||||
};
|
||||
const onHide = () => setVisible(false);
|
||||
const onExited = () => {
|
||||
currentEditor?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolItem {...item} onClick={addLink} />
|
||||
<Modal
|
||||
show={visible}
|
||||
onHide={onHide}
|
||||
onExited={onExited}
|
||||
fullscreen="sm-down">
|
||||
<Modal.Header closeButton>
|
||||
<h5 className="mb-0">{t('link.add_link')}</h5>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form onSubmit={handleClick}>
|
||||
<Form.Group controlId="editor.internetSite" className="mb-3">
|
||||
<Form.Label>{t('link.form.fields.url.label')}</Form.Label>
|
||||
<Form.Control
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={link.value}
|
||||
onChange={(e) => setLink({ ...link, value: e.target.value })}
|
||||
isInvalid={link.isInvalid}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="editor.internetSiteName" className="mb-3">
|
||||
<Form.Label>{`${t('link.form.fields.name.label')} ${t(
|
||||
'optional',
|
||||
{
|
||||
keyPrefix: 'form',
|
||||
},
|
||||
)}`}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={name.value}
|
||||
onChange={(e) => setName({ ...name, value: e.target.value })}
|
||||
isInvalid={name.isInvalid}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="link" onClick={() => setVisible(false)}>
|
||||
{t('link.btn_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleClick}>
|
||||
{t('link.btn_confirm')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Link);
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const OL = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'list-ol',
|
||||
keyMap: ['Ctrl-o'],
|
||||
tip: `${t('ordered_list.text')} (Ctrl+o)`,
|
||||
};
|
||||
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertOrderedList();
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(OL);
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Outdent = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'text-indent-right',
|
||||
keyMap: ['Shift-Tab'],
|
||||
tip: t('outdent.text'),
|
||||
};
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.outdent();
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Outdent);
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const Table = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'table',
|
||||
tip: t('table.text'),
|
||||
};
|
||||
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertTable(3, 3);
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(Table);
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToolItem from '../toolItem';
|
||||
import { Editor } from '../types';
|
||||
|
||||
const UL = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'editor' });
|
||||
const item = {
|
||||
label: 'list-ul',
|
||||
keyMap: ['Ctrl-u'],
|
||||
tip: `${t('unordered_list.text')} (Ctrl+u)`,
|
||||
};
|
||||
|
||||
const handleClick = (editor: Editor) => {
|
||||
editor.insertUnorderedList();
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return <ToolItem {...item} onClick={handleClick} />;
|
||||
};
|
||||
|
||||
export default memo(UL);
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
memo,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { markdownToHtml } from '@/services';
|
||||
import ImgViewer from '@/components/ImgViewer';
|
||||
|
||||
import { htmlRender } from './utils';
|
||||
|
||||
let scrollTop = 0;
|
||||
let renderTimer;
|
||||
|
||||
const Index = ({ value }, ref) => {
|
||||
const [html, setHtml] = useState('');
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'messages' });
|
||||
|
||||
const renderMarkdown = (markdown) => {
|
||||
clearTimeout(renderTimer);
|
||||
const timeout = renderTimer ? 1000 : 0;
|
||||
renderTimer = setTimeout(() => {
|
||||
markdownToHtml(markdown).then((resp) => {
|
||||
scrollTop = previewRef.current?.scrollTop || 0;
|
||||
setHtml(resp);
|
||||
});
|
||||
}, timeout);
|
||||
};
|
||||
useEffect(() => {
|
||||
renderMarkdown(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!html) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRef.current?.scrollTo(0, scrollTop);
|
||||
|
||||
htmlRender(previewRef.current, {
|
||||
copySuccessText: t('copied', { keyPrefix: 'messages' }),
|
||||
copyText: t('copy', { keyPrefix: 'messages' }),
|
||||
});
|
||||
}, [html]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
getHtml: () => html,
|
||||
element: previewRef.current,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ImgViewer>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="preview-wrap position-relative p-3 rounded text-break text-wrap mt-2 fmt"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</ImgViewer>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(forwardRef(Index));
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Modal as AnswerModal } from '@/components';
|
||||
import { uploadImage } from '@/services';
|
||||
import { writeSettingStore } from '@/stores';
|
||||
|
||||
export const useImageUpload = () => {
|
||||
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 verifyImageSize = (files: FileList | File[]): boolean => {
|
||||
const fileArray = Array.isArray(files) ? files : Array.from(files);
|
||||
|
||||
if (fileArray.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canUploadAttachment = authorized_attachment_extensions.length > 0;
|
||||
const allowedAllType = [
|
||||
...authorized_image_extensions,
|
||||
...authorized_attachment_extensions,
|
||||
];
|
||||
|
||||
const unSupportFiles = fileArray.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: canUploadAttachment
|
||||
? t('file.not_supported', { file_type: allowedAllType.join(', ') })
|
||||
: t('image.form_image.fields.file.msg.only_image'),
|
||||
showCancel: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const otherFiles = fileArray.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 = fileArray.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', {
|
||||
size: max_image_size,
|
||||
}),
|
||||
showCancel: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadFiles = (
|
||||
files: FileList | File[],
|
||||
): Promise<{ url: string; name: string; type: string }[]> => {
|
||||
const fileArray = Array.isArray(files) ? files : Array.from(files);
|
||||
const promises = fileArray.map(async (file) => {
|
||||
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
|
||||
const url = await uploadImage({ file, type });
|
||||
|
||||
return {
|
||||
name: file.name,
|
||||
url,
|
||||
type,
|
||||
};
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
};
|
||||
|
||||
const uploadSingleFile = async (file: File): Promise<string> => {
|
||||
const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment';
|
||||
return uploadImage({ file, type });
|
||||
};
|
||||
|
||||
return {
|
||||
verifyImageSize,
|
||||
uploadFiles,
|
||||
uploadSingleFile,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.md-editor-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(-bs-body-bg);
|
||||
border: 1px solid var(--an-ced4da);
|
||||
overflow: hidden;
|
||||
.toolbar-wrap {
|
||||
border-bottom: 1px solid var(--an-ced4da);
|
||||
.toolbar-divider {
|
||||
float: left;
|
||||
width: 1px;
|
||||
height: 15px;
|
||||
background-color: var(--an-toolbar-divider);
|
||||
margin: 10px 8px;
|
||||
}
|
||||
.toolbar-item-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin: 0 2px;
|
||||
line-height: 0;
|
||||
.dropdown-menu {
|
||||
line-height: 1.5;
|
||||
}
|
||||
&.right {
|
||||
float: right;
|
||||
}
|
||||
.dropdown-toggle {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background-color: var(--bs-body-bg);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
&:hover {
|
||||
background-color: var(--an-editor-toolbar-hover);
|
||||
}
|
||||
&:focus {
|
||||
background-color: var(--ans-editor-toolbar-focus);
|
||||
}
|
||||
}
|
||||
.popup-wrap {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
margin-right: auto;
|
||||
border: 1px solid #cacaca;
|
||||
background: #fff;
|
||||
z-index: 9999;
|
||||
visibility: hidden;
|
||||
|
||||
&.heading-add {
|
||||
width: 158px;
|
||||
padding: 8px 0;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
li {
|
||||
padding: 4px 24px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
h4 {
|
||||
font-size: 19px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.content-wrap {
|
||||
height: 264px;
|
||||
}
|
||||
|
||||
.rich-editor-wrap {
|
||||
height: 264px;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem 0.75rem;
|
||||
|
||||
.tiptap-editor {
|
||||
outline: none;
|
||||
min-height: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.6;
|
||||
|
||||
&.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--an-editor-placeholder-color);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--an-text-secondary, #6c757d);
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: auto;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace !important;
|
||||
font-size: 14px;
|
||||
pre.CodeMirror-line,
|
||||
pre.CodeMirror-line-like {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.CodeMirror-lines {
|
||||
padding: 16px 0;
|
||||
}
|
||||
.CodeMirror-placeholder {
|
||||
color: var(--an-editor-placeholder-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
.preview-wrap {
|
||||
overflow-y: auto;
|
||||
min-height: 20px;
|
||||
padding: 16px;
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 {
|
||||
useRef,
|
||||
useState,
|
||||
ForwardRefRenderFunction,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { Spinner } from 'react-bootstrap';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
PluginType,
|
||||
useRenderPlugin,
|
||||
getReplacementPlugin,
|
||||
} from '@/utils/pluginKit';
|
||||
import { writeSettingStore } from '@/stores';
|
||||
import PluginRender, { PluginSlot } from '../PluginRender';
|
||||
|
||||
import { useImageUpload } from './hooks/useImageUpload';
|
||||
import {
|
||||
BlockQuote,
|
||||
Bold,
|
||||
Code,
|
||||
Heading,
|
||||
Help,
|
||||
Hr,
|
||||
Image,
|
||||
Indent,
|
||||
Italice,
|
||||
Link as LinkItem,
|
||||
OL,
|
||||
Outdent,
|
||||
Table,
|
||||
UL,
|
||||
File,
|
||||
} from './ToolBars';
|
||||
import { htmlRender } from './utils';
|
||||
import Viewer from './Viewer';
|
||||
import { EditorContext } from './EditorContext';
|
||||
import MarkdownEditor from './MarkdownEditor';
|
||||
import { Editor } from './types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export interface EditorRef {
|
||||
getHtml: () => string;
|
||||
}
|
||||
|
||||
interface EventRef {
|
||||
onChange?(value: string): void;
|
||||
onFocus?(): void;
|
||||
onBlur?(): void;
|
||||
}
|
||||
|
||||
interface Props extends EventRef {
|
||||
editorPlaceholder?;
|
||||
className?;
|
||||
value;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const MDEditor: ForwardRefRenderFunction<EditorRef, Props> = (
|
||||
{
|
||||
editorPlaceholder = '',
|
||||
className = '',
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [currentEditor, setCurrentEditor] = useState<Editor | null>(null);
|
||||
const previewRef = useRef<{ getHtml; element } | null>(null);
|
||||
const [fullEditorPlugin, setFullEditorPlugin] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { verifyImageSize, uploadSingleFile } = useImageUpload();
|
||||
const {
|
||||
max_image_size = 4,
|
||||
authorized_image_extensions = [],
|
||||
authorized_attachment_extensions = [],
|
||||
} = writeSettingStore((state) => state.write);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadPlugin = async () => {
|
||||
const plugin = await getReplacementPlugin(PluginType.EditorReplacement);
|
||||
if (mounted) {
|
||||
setFullEditorPlugin(plugin);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlugin();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useRenderPlugin(previewRef.current?.element);
|
||||
|
||||
const getHtml = useCallback(() => {
|
||||
return previewRef.current?.getHtml();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getHtml,
|
||||
}),
|
||||
[getHtml],
|
||||
);
|
||||
|
||||
const EditorComponent = MarkdownEditor;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={classNames('md-editor-wrap rounded', className)}>
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center"
|
||||
style={{ minHeight: '200px' }}>
|
||||
<Spinner animation="border" variant="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fullEditorPlugin) {
|
||||
const FullEditorComponent = fullEditorPlugin.component;
|
||||
|
||||
const handleImageUpload = async (file: File | string): Promise<string> => {
|
||||
if (typeof file === 'string') {
|
||||
return file;
|
||||
}
|
||||
|
||||
if (!verifyImageSize([file])) {
|
||||
throw new Error('File validation failed');
|
||||
}
|
||||
|
||||
return uploadSingleFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<FullEditorComponent
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={editorPlaceholder}
|
||||
autoFocus={autoFocus}
|
||||
imageUploadHandler={handleImageUpload}
|
||||
uploadConfig={{
|
||||
maxImageSizeMiB: max_image_size,
|
||||
allowedExtensions: [
|
||||
...authorized_image_extensions,
|
||||
...authorized_attachment_extensions,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames('md-editor-wrap rounded', className)}>
|
||||
<div className="toolbar-wrap px-3 d-flex align-items-center flex-wrap">
|
||||
<EditorContext.Provider value={currentEditor}>
|
||||
<PluginRender
|
||||
type={PluginType.Editor}
|
||||
className="d-flex align-items-center flex-wrap"
|
||||
editor={currentEditor}
|
||||
previewElement={previewRef.current?.element}>
|
||||
<Heading />
|
||||
<Bold />
|
||||
<Italice />
|
||||
<div className="toolbar-divider" />
|
||||
<Code />
|
||||
<LinkItem />
|
||||
<BlockQuote />
|
||||
<Image />
|
||||
<File />
|
||||
<Table />
|
||||
<div className="toolbar-divider" />
|
||||
<OL />
|
||||
<UL />
|
||||
<Indent />
|
||||
<Outdent />
|
||||
<Hr />
|
||||
<div className="toolbar-divider" />
|
||||
<PluginSlot />
|
||||
<Help />
|
||||
</PluginRender>
|
||||
</EditorContext.Provider>
|
||||
</div>
|
||||
|
||||
<EditorComponent
|
||||
key="markdown-editor"
|
||||
value={value}
|
||||
onChange={(markdown) => {
|
||||
onChange?.(markdown);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={editorPlaceholder}
|
||||
autoFocus={autoFocus}
|
||||
onEditorReady={(editor) => {
|
||||
setCurrentEditor(editor);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Viewer ref={previewRef} value={value} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export { htmlRender };
|
||||
export default forwardRef(MDEditor);
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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, useContext, useEffect } from 'react';
|
||||
import { Dropdown, Button } from 'react-bootstrap';
|
||||
|
||||
import { EditorContext } from './EditorContext';
|
||||
import { Editor } from './types';
|
||||
|
||||
interface IProps {
|
||||
keyMap?: string[];
|
||||
onClick?: (editor: Editor) => void;
|
||||
tip?: string;
|
||||
className?: string;
|
||||
as?: any;
|
||||
children?;
|
||||
label?: string;
|
||||
disable?: boolean;
|
||||
isShow?: boolean;
|
||||
onBlur?: (editor: Editor) => void;
|
||||
}
|
||||
const ToolItem: FC<IProps> = (props) => {
|
||||
const editor = useContext(EditorContext);
|
||||
|
||||
const {
|
||||
label,
|
||||
tip,
|
||||
disable = false,
|
||||
isShow,
|
||||
keyMap,
|
||||
onClick,
|
||||
className,
|
||||
as,
|
||||
children,
|
||||
onBlur,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
if (!keyMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyMap.forEach((key) => {
|
||||
editor?.addKeyMap({
|
||||
[key]: () => {
|
||||
onClick?.(editor);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
const btnRender = () => (
|
||||
<Button
|
||||
variant="link"
|
||||
title={tip}
|
||||
className={`p-0 b-0 btn-no-border toolbar text-body ${
|
||||
disable ? 'disabled' : ''
|
||||
}`}
|
||||
disabled={disable}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (editor) {
|
||||
onClick?.(editor);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.preventDefault();
|
||||
if (editor) {
|
||||
onBlur?.(editor);
|
||||
}
|
||||
}}>
|
||||
<i className={`bi bi-${label}`} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={`toolbar-item-wrap ${className || ''}`}>
|
||||
{as === 'dropdown' ? (
|
||||
<Dropdown className="h-100 w-100" show={isShow}>
|
||||
<Dropdown.Toggle as="div" className="h-100">
|
||||
{btnRender()}
|
||||
</Dropdown.Toggle>
|
||||
{children}
|
||||
</Dropdown>
|
||||
) : (
|
||||
<>
|
||||
{btnRender()}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolItem;
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 { EditorView, Command } from '@codemirror/view';
|
||||
|
||||
export interface Position {
|
||||
ch: number;
|
||||
line: number;
|
||||
sticky?: string | undefined;
|
||||
}
|
||||
|
||||
export type Level = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export interface ExtendEditor {
|
||||
addKeyMap: (keyMap: Record<string, Command>) => void;
|
||||
on: (
|
||||
event:
|
||||
| 'change'
|
||||
| 'focus'
|
||||
| 'blur'
|
||||
| 'dragenter'
|
||||
| 'dragover'
|
||||
| 'drop'
|
||||
| 'paste',
|
||||
callback: (e?) => void,
|
||||
) => void;
|
||||
getValue: () => string;
|
||||
setValue: (value: string) => void;
|
||||
off: (
|
||||
event:
|
||||
| 'change'
|
||||
| 'focus'
|
||||
| 'blur'
|
||||
| 'dragenter'
|
||||
| 'dragover'
|
||||
| 'drop'
|
||||
| 'paste',
|
||||
callback: (e?) => void,
|
||||
) => void;
|
||||
getSelection: () => string;
|
||||
replaceSelection: (value: string) => void;
|
||||
focus: () => void;
|
||||
getCursor: () => Position;
|
||||
replaceRange: (value: string, from: Position, to: Position) => void;
|
||||
setSelection: (anchor: Position, head?: Position) => void;
|
||||
setReadOnly: (readOnly: boolean) => void;
|
||||
|
||||
wrapText: (before: string, after?: string, defaultText?: string) => void;
|
||||
replaceLines: (
|
||||
replace: Parameters<Array<string>['map']>[0],
|
||||
symbolLen?: number,
|
||||
) => void;
|
||||
appendBlock: (content: string) => void;
|
||||
|
||||
insertBold: (text?: string) => void;
|
||||
insertItalic: (text?: string) => void;
|
||||
insertCode: (text?: string) => void;
|
||||
insertStrikethrough: (text?: string) => void;
|
||||
|
||||
insertHeading: (level: Level, text?: string) => void;
|
||||
insertBlockquote: (text?: string) => void;
|
||||
insertCodeBlock: (language?: string, code?: string) => void;
|
||||
insertHorizontalRule: () => void;
|
||||
|
||||
insertOrderedList: () => void;
|
||||
insertUnorderedList: () => void;
|
||||
toggleOrderedList: () => void;
|
||||
toggleUnorderedList: () => void;
|
||||
|
||||
insertLink: (url: string, text?: string) => void;
|
||||
insertImage: (url: string, alt?: string) => void;
|
||||
|
||||
insertTable: (rows?: number, cols?: number) => void;
|
||||
|
||||
indent: () => void;
|
||||
outdent: () => void;
|
||||
|
||||
isBold: () => boolean;
|
||||
isItalic: () => boolean;
|
||||
isHeading: (level?: number) => boolean;
|
||||
isBlockquote: () => boolean;
|
||||
isCodeBlock: () => boolean;
|
||||
isOrderedList: () => boolean;
|
||||
isUnorderedList: () => boolean;
|
||||
}
|
||||
|
||||
export type Editor = EditorView & ExtendEditor;
|
||||
export interface CodeMirrorEditor extends Editor {
|
||||
display: any;
|
||||
|
||||
moduleType;
|
||||
}
|
||||
|
||||
export interface BaseEditorProps {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { Editor, ExtendEditor } from '../../types';
|
||||
|
||||
import { createBaseMethods } from './base';
|
||||
import { createEventMethods } from './events';
|
||||
import { createCommandMethods } from './commands';
|
||||
|
||||
/**
|
||||
* Adapts CodeMirror editor to unified editor interface
|
||||
*
|
||||
* This adapter function extends CodeMirror editor with additional methods,
|
||||
* enabling toolbar components to work properly in Markdown mode. The adapter
|
||||
* implements the complete `ExtendEditor` interface, including base methods,
|
||||
* event handling, and command methods.
|
||||
*
|
||||
* @param editor - CodeMirror editor instance
|
||||
* @returns Extended editor instance that implements the unified Editor interface
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cmEditor = new EditorView({ ... });
|
||||
* const adaptedEditor = createCodeMirrorAdapter(cmEditor as Editor);
|
||||
* // Now you can use the unified API
|
||||
* adaptedEditor.insertBold('text');
|
||||
* adaptedEditor.insertHeading(1, 'Title');
|
||||
* ```
|
||||
*/
|
||||
export function createCodeMirrorAdapter(editor: Editor): Editor {
|
||||
const baseMethods = createBaseMethods(editor);
|
||||
const eventMethods = createEventMethods(editor);
|
||||
const commandMethods = createCommandMethods(editor);
|
||||
|
||||
const editorAdapter: ExtendEditor = {
|
||||
...editor,
|
||||
...baseMethods,
|
||||
...eventMethods,
|
||||
...commandMethods,
|
||||
};
|
||||
|
||||
return editorAdapter as unknown as Editor;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 { EditorSelection, StateEffect } from '@codemirror/state';
|
||||
import { keymap, KeyBinding, Command } from '@codemirror/view';
|
||||
|
||||
import { Editor, Position } from '../../types';
|
||||
|
||||
/**
|
||||
* Creates base methods module
|
||||
*
|
||||
* Provides core base methods for the editor, including:
|
||||
* - Content getter and setter (getValue, setValue)
|
||||
* - Selection operations (getSelection, replaceSelection)
|
||||
* - Cursor and selection position (getCursor, setSelection)
|
||||
* - Focus and keyboard mapping (focus, addKeyMap)
|
||||
*
|
||||
* @param editor - CodeMirror editor instance
|
||||
* @returns Object containing base methods
|
||||
*/
|
||||
export function createBaseMethods(editor: Editor) {
|
||||
return {
|
||||
focus: () => {
|
||||
editor.contentDOM.focus();
|
||||
},
|
||||
|
||||
getCursor: () => {
|
||||
const range = editor.state.selection.ranges[0];
|
||||
const line = editor.state.doc.lineAt(range.from).number;
|
||||
const { from, to } = editor.state.doc.line(line);
|
||||
return { from, to, ch: range.from - from, line };
|
||||
},
|
||||
|
||||
addKeyMap: (keyMap: Record<string, Command>) => {
|
||||
const array = Object.entries(keyMap).map(([key, value]) => {
|
||||
const keyBinding: KeyBinding = {
|
||||
key,
|
||||
preventDefault: true,
|
||||
run: value,
|
||||
};
|
||||
return keyBinding;
|
||||
});
|
||||
|
||||
editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of(keymap.of(array)),
|
||||
});
|
||||
},
|
||||
|
||||
getSelection: () => {
|
||||
return editor.state.sliceDoc(
|
||||
editor.state.selection.main.from,
|
||||
editor.state.selection.main.to,
|
||||
);
|
||||
},
|
||||
|
||||
replaceSelection: (value: string) => {
|
||||
editor.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: editor.state.selection.main.from,
|
||||
to: editor.state.selection.main.to,
|
||||
insert: value,
|
||||
},
|
||||
],
|
||||
selection: EditorSelection.cursor(
|
||||
editor.state.selection.main.from + value.length,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
setSelection: (anchor: Position, head?: Position) => {
|
||||
editor.dispatch({
|
||||
selection: EditorSelection.create([
|
||||
EditorSelection.range(
|
||||
editor.state.doc.line(anchor.line).from + anchor.ch,
|
||||
head
|
||||
? editor.state.doc.line(head.line).from + head.ch
|
||||
: editor.state.doc.line(anchor.line).from + anchor.ch,
|
||||
),
|
||||
]),
|
||||
});
|
||||
},
|
||||
|
||||
replaceRange: (value: string, from: Position, to: Position) => {
|
||||
const fromOffset = editor.state.doc.line(from.line).from + from.ch;
|
||||
const toOffset = editor.state.doc.line(to.line).from + to.ch;
|
||||
editor.dispatch({
|
||||
changes: { from: fromOffset, to: toOffset, insert: value },
|
||||
});
|
||||
},
|
||||
|
||||
getValue: () => {
|
||||
return editor.state.doc.toString();
|
||||
},
|
||||
|
||||
setValue: (value: string) => {
|
||||
editor.dispatch({
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: value },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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 { EditorSelection } from '@codemirror/state';
|
||||
|
||||
import { Editor, Level } from '../../types';
|
||||
|
||||
/**
|
||||
* Creates command methods module
|
||||
*
|
||||
* Provides semantic command methods and low-level text manipulation methods:
|
||||
* - Semantic methods: insertBold, insertHeading, insertImage, etc. (for toolbar use)
|
||||
* - Low-level methods: wrapText, replaceLines, appendBlock (for internal use)
|
||||
* - State query methods: isBold, isHeading, etc.
|
||||
*
|
||||
* @param editor - CodeMirror editor instance
|
||||
* @returns Object containing all command methods
|
||||
*/
|
||||
export function createCommandMethods(editor: Editor) {
|
||||
// Create methods object that allows self-reference
|
||||
const methods = {
|
||||
wrapText: (before: string, after = before, defaultText) => {
|
||||
const range = editor.state.selection.ranges[0];
|
||||
const selectedText = editor.state.sliceDoc(range.from, range.to);
|
||||
const text = selectedText || defaultText || '';
|
||||
const wrappedText = before + text + after;
|
||||
const insertFrom = range.from;
|
||||
const insertTo = range.to;
|
||||
|
||||
editor.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: insertFrom,
|
||||
to: insertTo,
|
||||
insert: wrappedText,
|
||||
},
|
||||
],
|
||||
selection: selectedText
|
||||
? EditorSelection.cursor(insertFrom + before.length + text.length)
|
||||
: EditorSelection.range(
|
||||
insertFrom + before.length,
|
||||
insertFrom + before.length + text.length,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
replaceLines: (replace: Parameters<Array<string>['map']>[0]) => {
|
||||
const { doc } = editor.state;
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= doc.lines; i += 1) {
|
||||
lines.push(doc.line(i).text);
|
||||
}
|
||||
|
||||
const newLines = lines.map(replace) as string[];
|
||||
const newText = newLines.join('\n');
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor.state.doc.length,
|
||||
insert: newText,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
appendBlock: (content: string) => {
|
||||
const { doc } = editor.state;
|
||||
const currentText = doc.toString();
|
||||
const newText = currentText ? `${currentText}\n\n${content}` : content;
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor.state.doc.length,
|
||||
insert: newText,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
insertBold: (text?: string) => {
|
||||
methods.wrapText('**', '**', text || 'bold text');
|
||||
},
|
||||
|
||||
insertItalic: (text?: string) => {
|
||||
methods.wrapText('*', '*', text || 'italic text');
|
||||
},
|
||||
|
||||
insertCode: (text?: string) => {
|
||||
methods.wrapText('`', '`', text || 'code');
|
||||
},
|
||||
|
||||
insertStrikethrough: (text?: string) => {
|
||||
methods.wrapText('~~', '~~', text || 'strikethrough text');
|
||||
},
|
||||
|
||||
insertHeading: (level: Level, text?: string) => {
|
||||
const headingText = '#'.repeat(level);
|
||||
methods.wrapText(`${headingText} `, '', text || 'heading');
|
||||
},
|
||||
|
||||
insertBlockquote: (text?: string) => {
|
||||
methods.wrapText('> ', '', text || 'quote');
|
||||
},
|
||||
|
||||
insertCodeBlock: (language?: string, code?: string) => {
|
||||
const lang = language || '';
|
||||
const codeText = code || '';
|
||||
const block = `\`\`\`${lang}\n${codeText}\n\`\`\``;
|
||||
methods.appendBlock(block);
|
||||
},
|
||||
|
||||
insertHorizontalRule: () => {
|
||||
methods.appendBlock('---');
|
||||
},
|
||||
|
||||
insertOrderedList: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
if (/^\d+\.\s/.test(lineText)) {
|
||||
return;
|
||||
}
|
||||
methods.replaceLines((lineItem) => {
|
||||
if (lineItem.trim() === '') {
|
||||
return lineItem;
|
||||
}
|
||||
return `1. ${lineItem}`;
|
||||
});
|
||||
},
|
||||
|
||||
insertUnorderedList: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
if (/^[-*+]\s/.test(lineText)) {
|
||||
return;
|
||||
}
|
||||
methods.replaceLines((lineItem) => {
|
||||
if (lineItem.trim() === '') {
|
||||
return lineItem;
|
||||
}
|
||||
return `- ${lineItem}`;
|
||||
});
|
||||
},
|
||||
|
||||
toggleOrderedList: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
if (/^\d+\.\s/.test(lineText)) {
|
||||
methods.replaceLines((lineItem) => {
|
||||
return lineItem.replace(/^\d+\.\s/, '');
|
||||
});
|
||||
} else {
|
||||
methods.insertOrderedList();
|
||||
}
|
||||
},
|
||||
|
||||
toggleUnorderedList: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
if (/^[-*+]\s/.test(lineText)) {
|
||||
methods.replaceLines((lineItem) => {
|
||||
return lineItem.replace(/^[-*+]\s/, '');
|
||||
});
|
||||
} else {
|
||||
methods.insertUnorderedList();
|
||||
}
|
||||
},
|
||||
|
||||
insertLink: (url: string, text?: string) => {
|
||||
const linkText = text || url;
|
||||
methods.wrapText('[', `](${url})`, linkText);
|
||||
},
|
||||
|
||||
insertImage: (url: string, alt?: string) => {
|
||||
const altText = alt || '';
|
||||
methods.wrapText('`, altText);
|
||||
},
|
||||
|
||||
insertTable: (rows = 3, cols = 3) => {
|
||||
const table: string[] = [];
|
||||
for (let i = 0; i < rows; i += 1) {
|
||||
const row: string[] = [];
|
||||
for (let j = 0; j < cols; j += 1) {
|
||||
row.push(i === 0 ? 'Header' : 'Cell');
|
||||
}
|
||||
table.push(`| ${row.join(' | ')} |`);
|
||||
if (i === 0) {
|
||||
table.push(`| ${'---'.repeat(cols).split('').join(' | ')} |`);
|
||||
}
|
||||
}
|
||||
methods.appendBlock(table.join('\n'));
|
||||
},
|
||||
|
||||
indent: () => {
|
||||
methods.replaceLines((line) => {
|
||||
if (line.trim() === '') {
|
||||
return line;
|
||||
}
|
||||
return ` ${line}`;
|
||||
});
|
||||
},
|
||||
|
||||
outdent: () => {
|
||||
methods.replaceLines((line) => {
|
||||
if (line.trim() === '') {
|
||||
return line;
|
||||
}
|
||||
return line.replace(/^ {2}/, '');
|
||||
});
|
||||
},
|
||||
|
||||
isBold: () => {
|
||||
const selection = editor.getSelection();
|
||||
return /^\*\*.*\*\*$/.test(selection) || /^__.*__$/.test(selection);
|
||||
},
|
||||
|
||||
isItalic: () => {
|
||||
const selection = editor.getSelection();
|
||||
return /^\*.*\*$/.test(selection) || /^_.*_$/.test(selection);
|
||||
},
|
||||
|
||||
isHeading: (level?: number) => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
if (level) {
|
||||
return new RegExp(`^#{${level}}\\s`).test(lineText);
|
||||
}
|
||||
return /^#{1,6}\s/.test(lineText);
|
||||
},
|
||||
|
||||
isBlockquote: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
return /^>\s/.test(lineText);
|
||||
},
|
||||
|
||||
isCodeBlock: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
return /^```/.test(lineText);
|
||||
},
|
||||
|
||||
isOrderedList: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
return /^\d+\.\s/.test(lineText);
|
||||
},
|
||||
|
||||
isUnorderedList: () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.state.doc.line(cursor.line);
|
||||
const lineText = line.text.trim();
|
||||
return /^[-*+]\s/.test(lineText);
|
||||
},
|
||||
};
|
||||
|
||||
return methods;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { StateEffect } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { Editor } from '../../types';
|
||||
|
||||
/**
|
||||
* Creates event methods module
|
||||
*
|
||||
* Provides event listener registration and removal for the editor.
|
||||
* Handles various DOM events including focus, blur, drag, drop, and paste.
|
||||
*
|
||||
* @param editor - CodeMirror editor instance
|
||||
* @returns Object containing event methods (on, off)
|
||||
*/
|
||||
export function createEventMethods(editor: Editor) {
|
||||
return {
|
||||
on: (event, callback) => {
|
||||
if (event === 'change') {
|
||||
const change = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of(change),
|
||||
});
|
||||
}
|
||||
if (event === 'focus') {
|
||||
editor.contentDOM.addEventListener('focus', callback);
|
||||
}
|
||||
if (event === 'blur') {
|
||||
editor.contentDOM.addEventListener('blur', callback);
|
||||
}
|
||||
|
||||
if (event === 'dragenter') {
|
||||
editor.contentDOM.addEventListener('dragenter', callback);
|
||||
}
|
||||
|
||||
if (event === 'dragover') {
|
||||
editor.contentDOM.addEventListener('dragover', callback);
|
||||
}
|
||||
|
||||
if (event === 'drop') {
|
||||
editor.contentDOM.addEventListener('drop', callback);
|
||||
}
|
||||
|
||||
if (event === 'paste') {
|
||||
editor.contentDOM.addEventListener('paste', callback);
|
||||
}
|
||||
},
|
||||
|
||||
off: (event, callback) => {
|
||||
if (event === 'focus') {
|
||||
editor.contentDOM.removeEventListener('focus', callback);
|
||||
}
|
||||
|
||||
if (event === 'blur') {
|
||||
editor.contentDOM.removeEventListener('blur', callback);
|
||||
}
|
||||
|
||||
if (event === 'dragenter') {
|
||||
editor.contentDOM.removeEventListener('dragenter', callback);
|
||||
}
|
||||
|
||||
if (event === 'dragover') {
|
||||
editor.contentDOM.removeEventListener('dragover', callback);
|
||||
}
|
||||
|
||||
if (event === 'drop') {
|
||||
editor.contentDOM.removeEventListener('drop', callback);
|
||||
}
|
||||
|
||||
if (event === 'paste') {
|
||||
editor.contentDOM.removeEventListener('paste', callback);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* 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 { useEffect, useState, useRef } from 'react';
|
||||
|
||||
import { minimalSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { EditorView, placeholder } from '@codemirror/view';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Tooltip from 'bootstrap/js/dist/tooltip';
|
||||
|
||||
import { Editor } from '../types';
|
||||
import { isDarkTheme } from '@/utils/common';
|
||||
|
||||
import { createCodeMirrorAdapter } from './codemirror/adapter';
|
||||
|
||||
const editableCompartment = new Compartment();
|
||||
interface htmlRenderConfig {
|
||||
copyText: string;
|
||||
copySuccessText: string;
|
||||
}
|
||||
export function htmlRender(el: HTMLElement | null, config?: htmlRenderConfig) {
|
||||
if (!el) return;
|
||||
const { copyText = '', copySuccessText = '' } = config || {
|
||||
copyText: 'Copy to clipboard',
|
||||
copySuccessText: 'Copied!',
|
||||
};
|
||||
// Replace all br tags with newlines
|
||||
// Fixed an issue where the BR tag in the editor block formula HTML caused rendering errors.
|
||||
el.querySelectorAll('p').forEach((p) => {
|
||||
if (p.innerHTML.startsWith('$$') && p.innerHTML.endsWith('$$')) {
|
||||
const str = p.innerHTML.replace(/<br>/g, '\n');
|
||||
p.innerHTML = str;
|
||||
}
|
||||
});
|
||||
|
||||
// change table style
|
||||
|
||||
el.querySelectorAll('table').forEach((table) => {
|
||||
if (
|
||||
(table.parentNode as HTMLDivElement)?.classList.contains(
|
||||
'table-responsive',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
table.classList.add('table', 'table-bordered');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'table-responsive';
|
||||
table.parentNode?.replaceChild(div, table);
|
||||
div.appendChild(table);
|
||||
});
|
||||
|
||||
// add rel nofollow for link not includes domain
|
||||
el.querySelectorAll('a').forEach((a) => {
|
||||
const base = window.location.origin;
|
||||
const targetUrl = new URL(a.href, base);
|
||||
|
||||
if (targetUrl.origin !== base) {
|
||||
a.rel = 'nofollow';
|
||||
}
|
||||
});
|
||||
|
||||
// Add copy button to all pre tags
|
||||
el.querySelectorAll('pre').forEach((pre) => {
|
||||
// Create copy button
|
||||
const codeWrap = document.createElement('div');
|
||||
codeWrap.className = 'position-relative a-code-wrap';
|
||||
const codeTool = document.createElement('div');
|
||||
codeTool.className = 'a-code-tool';
|
||||
const uniqueId = `a-copy-code-${Date.now().toString().substring(5)}-${Math.floor(Math.random() * 10)}${Math.floor(Math.random() * 10)}${Math.floor(Math.random() * 10)}`;
|
||||
const str = `
|
||||
<a role="button" class="link-secondary a-copy-code" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="${copyText}" id="${uniqueId}">
|
||||
<i class="br bi-copy"></i>
|
||||
</a>
|
||||
`;
|
||||
codeTool.innerHTML = str;
|
||||
|
||||
pre.style.position = 'relative';
|
||||
|
||||
codeWrap.appendChild(codeTool);
|
||||
pre.parentNode?.replaceChild(codeWrap, pre);
|
||||
codeWrap.appendChild(pre);
|
||||
|
||||
const tooltipTriggerList = el.querySelectorAll('.a-copy-code');
|
||||
|
||||
Array.from(tooltipTriggerList)?.map(
|
||||
(tooltipTriggerEl) => new Tooltip(tooltipTriggerEl),
|
||||
);
|
||||
|
||||
// Copy pre content on button click
|
||||
const copyBtn = codeTool.querySelector('.a-copy-code');
|
||||
copyBtn?.addEventListener('click', () => {
|
||||
const textToCopy = pre.textContent || '';
|
||||
copy(textToCopy);
|
||||
// Change tooltip text on copy success
|
||||
const tooltipInstance = Tooltip.getOrCreateInstance(`#${uniqueId}`);
|
||||
tooltipInstance?.setContent({ '.tooltip-inner': copySuccessText });
|
||||
const myTooltipEl = document.querySelector(`#${uniqueId}`);
|
||||
myTooltipEl?.addEventListener('hidden.bs.tooltip', () => {
|
||||
tooltipInstance.setContent({ '.tooltip-inner': copyText });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const useEditor = ({
|
||||
editorRef,
|
||||
placeholder: placeholderText,
|
||||
autoFocus,
|
||||
initialValue,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}) => {
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const isInternalUpdateRef = useRef<boolean>(false);
|
||||
|
||||
const init = async () => {
|
||||
const isDark = isDarkTheme();
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
padding: '.375rem .75rem',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-content': {
|
||||
width: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
},
|
||||
'.ͼ7, .ͼ6': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
'border-left-color': isDark ? 'white' : 'black',
|
||||
},
|
||||
});
|
||||
|
||||
const startState = EditorState.create({
|
||||
doc: initialValue || '',
|
||||
extensions: [
|
||||
minimalSetup,
|
||||
markdown({
|
||||
codeLanguages: languages,
|
||||
base: markdownLanguage,
|
||||
}),
|
||||
theme,
|
||||
placeholder(placeholderText),
|
||||
EditorView.lineWrapping,
|
||||
editableCompartment.of(EditorView.editable.of(true)),
|
||||
EditorView.domEventHandlers({
|
||||
paste(event) {
|
||||
const clipboard = event.clipboardData as DataTransfer;
|
||||
const htmlStr = clipboard.getData('text/html');
|
||||
const imgRegex =
|
||||
/<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
|
||||
|
||||
return Boolean(htmlStr.match(imgRegex));
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
parent: editorRef.current,
|
||||
state: startState,
|
||||
});
|
||||
|
||||
const cm = createCodeMirrorAdapter(view as Editor);
|
||||
|
||||
cm.setReadOnly = (readOnly: boolean) => {
|
||||
cm.dispatch({
|
||||
effects: editableCompartment.reconfigure(
|
||||
EditorView.editable.of(!readOnly),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (autoFocus) {
|
||||
setTimeout(() => {
|
||||
cm.focus();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
const originalSetValue = cm.setValue;
|
||||
cm.setValue = (newValue: string) => {
|
||||
isInternalUpdateRef.current = true;
|
||||
originalSetValue.call(cm, newValue);
|
||||
setTimeout(() => {
|
||||
isInternalUpdateRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
cm.on('change', () => {
|
||||
if (!isInternalUpdateRef.current && onChange) {
|
||||
const newValue = cm.getValue();
|
||||
onChange(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
cm.on('focus', () => {
|
||||
onFocus?.();
|
||||
});
|
||||
|
||||
cm.on('blur', () => {
|
||||
onBlur?.();
|
||||
});
|
||||
|
||||
setEditor(cm);
|
||||
|
||||
return cm;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
if (editorRef.current.children.length > 0 || editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
init();
|
||||
}, [editor]);
|
||||
return editor;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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, memo, ReactNode } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
const Index: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="text-center py-5">
|
||||
{children || (
|
||||
<Trans i18nKey="personal.list_empty">
|
||||
We couldn't find anything. <br /> Try different or less specific
|
||||
keywords.
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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, memo, useState } from 'react';
|
||||
import { Card, Button } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TagSelector, Tag } from '@/components';
|
||||
import { tryLoggedAndActivated } from '@/utils/guard';
|
||||
import { useFollowingTags, followTags } from '@/services';
|
||||
|
||||
const Index: FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'question' });
|
||||
const [isEdit, setEditState] = useState(false);
|
||||
const { data: followingTags, mutate } = useFollowingTags();
|
||||
|
||||
const newTags: any = followingTags?.map((item) => {
|
||||
if (item.slug_name) {
|
||||
return item.slug_name;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
const handleFollowTags = () => {
|
||||
followTags({
|
||||
slug_name_list: newTags,
|
||||
});
|
||||
setEditState(false);
|
||||
};
|
||||
|
||||
const handleTagsChange = (value) => {
|
||||
mutate([...value], {
|
||||
revalidate: false,
|
||||
});
|
||||
};
|
||||
|
||||
if (!tryLoggedAndActivated().ok) {
|
||||
return null;
|
||||
}
|
||||
return isEdit ? (
|
||||
<Card className="mb-4">
|
||||
<Card.Header className="text-nowrap d-flex justify-content-between">
|
||||
{t('following_tags')}
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 m-0 btn-no-border"
|
||||
onClick={handleFollowTags}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<TagSelector
|
||||
value={followingTags}
|
||||
onChange={handleTagsChange}
|
||||
hiddenDescription
|
||||
hiddenCreateBtn
|
||||
autoFocus
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="mb-4">
|
||||
<Card.Header className="text-nowrap d-flex justify-content-between text-capitalize">
|
||||
{t('following_tags')}
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 btn-no-border text-capitalize"
|
||||
onClick={() => setEditState(true)}>
|
||||
{t('edit')}
|
||||
</Button>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{followingTags?.length ? (
|
||||
<div className="m-n1">
|
||||
{followingTags.map((item) => {
|
||||
const slugName = item?.slug_name;
|
||||
return <Tag key={slugName} className="m-1" data={item} />;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted">{t('follow_tag_tip')}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
variant="outline-primary"
|
||||
onClick={() => setEditState(true)}>
|
||||
{t('follow_a_tag')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { siteInfoStore } from '@/stores';
|
||||
|
||||
const Index = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'footer' }); // Scoped translations for footer
|
||||
const fullYear = dayjs().format('YYYY');
|
||||
const siteName = siteInfoStore((state) => state.siteInfo.name);
|
||||
const cc = `${siteName} © ${fullYear}`;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<footer className="py-3 d-flex flex-wrap align-items-center justify-content-between text-secondary small">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="me-3">{cc}</div>
|
||||
|
||||
<Link to="/tos" className="me-3 link-secondary">
|
||||
{t('terms', { keyPrefix: 'nav_menus' })}
|
||||
</Link>
|
||||
|
||||
{/* Link to Privacy Policy with right margin for spacing */}
|
||||
<Link to="/privacy" className="link-secondary">
|
||||
{t('privacy', { keyPrefix: 'nav_menus' })}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Trans i18nKey="footer.build_on" values={{ cc }}>
|
||||
Powered by
|
||||
<a
|
||||
href="https://answer.apache.org"
|
||||
target="_blank"
|
||||
className="link-secondary"
|
||||
rel="noreferrer">
|
||||
Apache Answer
|
||||
</a>
|
||||
</Trans>
|
||||
</div>
|
||||
</footer>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Index);
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
time: number;
|
||||
className?: string;
|
||||
preFix?: string;
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({ time, preFix, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const formatTime = (from) => {
|
||||
const now = Math.floor(dayjs().valueOf() / 1000);
|
||||
const between = now > from ? now - from : 0;
|
||||
|
||||
if (between <= 1) {
|
||||
return t('dates.now');
|
||||
}
|
||||
if (between > 1 && between < 60) {
|
||||
return t('dates.x_seconds_ago', { count: between });
|
||||
}
|
||||
|
||||
if (between >= 60 && between < 3600) {
|
||||
const min = Math.floor(between / 60);
|
||||
return t('dates.x_minutes_ago', { count: min });
|
||||
}
|
||||
if (between >= 3600 && between < 3600 * 24) {
|
||||
const h = Math.floor(between / 3600);
|
||||
return t('dates.x_hours_ago', { count: h });
|
||||
}
|
||||
|
||||
if (
|
||||
between >= 3600 * 24 &&
|
||||
between < 3600 * 24 * 366 &&
|
||||
dayjs.unix(from).format('YYYY') === dayjs.unix(now).format('YYYY')
|
||||
) {
|
||||
return dayjs.unix(from).tz().format(t('dates.long_date'));
|
||||
}
|
||||
|
||||
return dayjs.unix(from).tz().format(t('dates.long_date_with_year'));
|
||||
};
|
||||
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<time
|
||||
className={classNames('', className)}
|
||||
dateTime={dayjs.unix(time).tz().toISOString()}
|
||||
title={dayjs.unix(time).tz().format(t('dates.long_date_with_time'))}>
|
||||
{preFix ? `${preFix} ` : ''}
|
||||
{formatTime(time)}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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, memo } from 'react';
|
||||
import { Nav, Dropdown } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
|
||||
import type * as Type from '@/common/interface';
|
||||
import { Avatar, Icon } from '@/components';
|
||||
import { floppyNavigation, isDarkTheme } from '@/utils';
|
||||
import { userCenterStore } from '@/stores';
|
||||
import { REACT_BASE_PATH } from '@/router/alias';
|
||||
|
||||
interface Props {
|
||||
redDot: Type.NotificationStatus | undefined;
|
||||
userInfo: Type.UserInfoRes;
|
||||
logOut: (e) => void;
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { agent: ucAgent } = userCenterStore();
|
||||
const handleLinkClick = (evt) => {
|
||||
if (floppyNavigation.shouldProcessLinkClick(evt)) {
|
||||
evt.preventDefault();
|
||||
const href = evt.currentTarget.getAttribute('href');
|
||||
floppyNavigation.navigate(href, {
|
||||
handler: navigate,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Nav className="flex-row">
|
||||
<NavLink
|
||||
to="/users/notifications/inbox"
|
||||
title={t('inbox', { keyPrefix: 'notifications' })}
|
||||
className="icon-link nav-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
|
||||
<Icon name="bell-fill" className="fs-4" />
|
||||
{(redDot?.inbox || 0) > 0 && (
|
||||
<div className="unread-dot bg-danger">
|
||||
<span className="visually-hidden">
|
||||
{t('new_alerts', { keyPrefix: 'notifications' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/users/notifications/achievement"
|
||||
title={t('achievement', { keyPrefix: 'notifications' })}
|
||||
className="icon-link nav-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
|
||||
<Icon name="trophy-fill" className="fs-4" />
|
||||
{(redDot?.achievement || 0) > 0 && (
|
||||
<div className="unread-dot bg-danger">
|
||||
<span className="visually-hidden">
|
||||
{t('new_alerts', { keyPrefix: 'notifications' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
</Nav>
|
||||
|
||||
<Dropdown align="end" data-bs-theme={isDarkTheme() ? 'dark' : 'light'}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
id="dropdown-basic"
|
||||
as="a"
|
||||
role="button"
|
||||
className="no-toggle pointer">
|
||||
<Avatar
|
||||
size="36px"
|
||||
avatar={userInfo?.avatar}
|
||||
alt={userInfo?.display_name}
|
||||
searchStr="s=96"
|
||||
/>
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu className="position-absolute">
|
||||
<Dropdown.Item
|
||||
href={`${REACT_BASE_PATH}/users/${userInfo.username}`}
|
||||
onClick={handleLinkClick}>
|
||||
{t('header.nav.profile')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
href={`${REACT_BASE_PATH}/users/${userInfo.username}/bookmarks`}
|
||||
onClick={handleLinkClick}>
|
||||
{t('header.nav.bookmark')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
href={`${REACT_BASE_PATH}/users/settings/profile`}
|
||||
onClick={handleLinkClick}>
|
||||
{t('header.nav.setting')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item
|
||||
href={`${REACT_BASE_PATH}/users/logout`}
|
||||
onClick={(e) => logOut(e)}>
|
||||
{t('header.nav.logout')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
{/* Dropdown for user center agent info */}
|
||||
{ucAgent?.enabled &&
|
||||
(ucAgent?.agent_info?.url ||
|
||||
ucAgent?.agent_info?.control_center?.length) ? (
|
||||
<Dropdown align="end" data-bs-theme={isDarkTheme() ? 'dark' : 'light'}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
id="dropdown-uca"
|
||||
as="span"
|
||||
className="no-toggle">
|
||||
<Nav>
|
||||
<Icon
|
||||
name="grid-3x3-gap-fill"
|
||||
className="nav-link pointer p-0 fs-4 ms-3"
|
||||
/>
|
||||
</Nav>
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu className="position-absolute">
|
||||
{ucAgent.agent_info.url ? (
|
||||
<Dropdown.Item href={ucAgent.agent_info.url}>
|
||||
{ucAgent.agent_info.name}
|
||||
</Dropdown.Item>
|
||||
) : null}
|
||||
{ucAgent.agent_info.url &&
|
||||
ucAgent.agent_info.control_center?.length ? (
|
||||
<Dropdown.Divider />
|
||||
) : null}
|
||||
{ucAgent.agent_info.control_center?.map((ctrl) => {
|
||||
return (
|
||||
<Dropdown.Item key={ctrl.name} href={ctrl.url}>
|
||||
{ctrl.label}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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, useState, useEffect } from 'react';
|
||||
import { Form, FormControl } from 'react-bootstrap';
|
||||
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
|
||||
const SearchInput: FC<{ className?: string }> = ({ className }) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'header' });
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [urlSearch] = useSearchParams();
|
||||
const q = urlSearch.get('q');
|
||||
const [searchStr, setSearch] = useState('');
|
||||
const handleInput = (val) => {
|
||||
setSearch(val);
|
||||
};
|
||||
const handleSearch = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (!searchStr) {
|
||||
return;
|
||||
}
|
||||
const searchUrl = `/search?q=${encodeURIComponent(searchStr)}`;
|
||||
navigate(searchUrl);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (q && location.pathname === '/search') {
|
||||
handleInput(q);
|
||||
}
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => {
|
||||
// clear search input when navigate to other page
|
||||
if (location.pathname !== '/search' && searchStr) {
|
||||
setSearch('');
|
||||
}
|
||||
}, [location.pathname]);
|
||||
return (
|
||||
<Form
|
||||
action="/search"
|
||||
className={`w-100 position-relative mx-auto ${className}`}
|
||||
onSubmit={handleSearch}>
|
||||
<div className="search-wrap" onClick={handleSearch}>
|
||||
<Icon name="search" className="search-icon" />
|
||||
</div>
|
||||
<FormControl
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
className="placeholder-search"
|
||||
value={searchStr}
|
||||
name="q"
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||