Merge branch 'dev' into fix/search

This commit is contained in:
kumfo 2022-11-17 12:12:23 +08:00
commit 7bdb6d2016
87 changed files with 2263 additions and 1943 deletions

View File

@ -17,6 +17,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
with: with:

View File

@ -1,4 +1,4 @@
FROM node:16 AS node-builder FROM amd64/node AS node-builder
LABEL maintainer="mingcheng<mc@sf.com>" LABEL maintainer="mingcheng<mc@sf.com>"

View File

@ -235,7 +235,7 @@ ui:
show_more: Show more show_more: Show more
suspended: suspended:
title: Your Account has been Suspended title: Your Account has been Suspended
until_time: 'Your account was suspended until {{ time }}.' until_time: "Your account was suspended until {{ time }}."
forever: This user was suspended forever. forever: This user was suspended forever.
end: You don't meet a community guideline. end: You don't meet a community guideline.
editor: editor:
@ -420,12 +420,12 @@ ui:
btn_cancel: Cancel btn_cancel: Cancel
dates: dates:
long_date: MMM D long_date: MMM D
long_date_with_year: 'MMM D, YYYY' long_date_with_year: "MMM D, YYYY"
long_date_with_time: 'MMM D, YYYY [at] HH:mm' long_date_with_time: "MMM D, YYYY [at] HH:mm"
now: now now: now
x_seconds_ago: '{{count}}s ago' x_seconds_ago: "{{count}}s ago"
x_minutes_ago: '{{count}}m ago' x_minutes_ago: "{{count}}m ago"
x_hours_ago: '{{count}}h ago' x_hours_ago: "{{count}}h ago"
hour: hour hour: hour
day: day day: day
comment: comment:
@ -507,7 +507,7 @@ ui:
add_btn: Add tag add_btn: Add tag
create_btn: Create new tag create_btn: Create new tag
search_tag: Search tag search_tag: Search tag
hint: 'Describe what your question is about, at least one tag is required.' hint: "Describe what your question is about, at least one tag is required."
no_result: No tags matched no_result: No tags matched
header: header:
nav: nav:
@ -536,7 +536,7 @@ ui:
first: >- first: >-
You're almost done! We sent an activation mail to <bold>{{mail}}</bold>. You're almost done! We sent an activation mail to <bold>{{mail}}</bold>.
Please follow the instructions in the mail to activate your account. Please follow the instructions in the mail to activate your account.
info: 'If it doesn''t arrive, check your spam folder.' info: "If it doesn't arrive, check your spam folder."
another: >- another: >-
We sent another activation email to you at <bold>{{mail}}</bold>. It might We sent another activation email to you at <bold>{{mail}}</bold>. It might
take a few minutes for it to arrive; be sure to check your spam folder. take a few minutes for it to arrive; be sure to check your spam folder.
@ -548,6 +548,7 @@ ui:
page_title: Welcome to Answer page_title: Welcome to Answer
info_sign: Don't have an account? <1>Sign up</1> info_sign: Don't have an account? <1>Sign up</1>
info_login: Already have an account? <1>Log in</1> info_login: Already have an account? <1>Log in</1>
agreements: By registering, you agree to the <1>privacy policy</1> and <3>terms of service</3>.
forgot_pass: Forgot password? forgot_pass: Forgot password?
name: name:
label: Name label: Name
@ -634,15 +635,15 @@ ui:
label: About Me (optional) label: About Me (optional)
website: website:
label: Website (optional) label: Website (optional)
placeholder: 'https://example.com' placeholder: "https://example.com"
msg: Website incorrect format msg: Website incorrect format
location: location:
label: Location (optional) label: Location (optional)
placeholder: 'City, Country' placeholder: "City, Country"
notification: notification:
email: email:
label: Email Notifications label: Email Notifications
radio: 'Answers to your questions, comments, and more' radio: "Answers to your questions, comments, and more"
account: account:
change_email_btn: Change email change_email_btn: Change email
change_pass_btn: Change password change_pass_btn: Change password
@ -732,7 +733,7 @@ ui:
options: Options options: Options
follow: Follow follow: Follow
following: Following following: Following
counts: '{{count}} Results' counts: "{{count}} Results"
more: More more: More
sort_btns: sort_btns:
relevance: Relevance relevance: Relevance
@ -742,12 +743,12 @@ ui:
more: More more: More
tips: tips:
title: Advanced Search Tips title: Advanced Search Tips
tag: '<1>[tag]</1> search withing a tag' tag: "<1>[tag]</1> search withing a tag"
user: '<1>user:username</1> search by author' user: "<1>user:username</1> search by author"
answer: '<1>answers:0</1> unanswered questions' answer: "<1>answers:0</1> unanswered questions"
score: '<1>score:3</1> posts with a 3+ score' score: "<1>score:3</1> posts with a 3+ score"
question: '<1>is:question</1> search questions' question: "<1>is:question</1> search questions"
is_answer: '<1>is:answer</1> search answers' is_answer: "<1>is:answer</1> search answers"
empty: We couldn't find anything. <br /> Try different or less specific keywords. empty: We couldn't find anything. <br /> Try different or less specific keywords.
share: share:
name: Share name: Share
@ -777,8 +778,8 @@ ui:
follow_tag_tip: Follow tags to curate your list of questions. follow_tag_tip: Follow tags to curate your list of questions.
hot_questions: Hot Questions hot_questions: Hot Questions
all_questions: All Questions all_questions: All Questions
x_questions: '{{ count }} Questions' x_questions: "{{ count }} Questions"
x_answers: '{{ count }} answers' x_answers: "{{ count }} answers"
questions: Questions questions: Questions
answers: Answers answers: Answers
newest: Newest newest: Newest
@ -805,12 +806,12 @@ ui:
newest: Newest newest: Newest
score: Score score: Score
edit_profile: Edit Profile edit_profile: Edit Profile
visited_x_days: 'Visited {{ count }} days' visited_x_days: "Visited {{ count }} days"
viewed: Viewed viewed: Viewed
joined: Joined joined: Joined
last_login: Seen last_login: Seen
about_me: About Me about_me: About Me
about_me_empty: '// Hello, World !' about_me_empty: "// Hello, World !"
top_answers: Top Answers top_answers: Top Answers
top_questions: Top Questions top_questions: Top Questions
stats: Stats stats: Stats
@ -845,7 +846,7 @@ ui:
msg: Password cannot be empty. msg: Password cannot be empty.
db_host: db_host:
label: Database Host label: Database Host
placeholder: 'db:3306' placeholder: "db:3306"
msg: Database Host cannot be empty. msg: Database Host cannot be empty.
db_name: db_name:
label: Database Name label: Database Name
@ -861,7 +862,7 @@ ui:
description: >- description: >-
You can create the <1>config.yaml</1> file manually in the You can create the <1>config.yaml</1> file manually in the
<1>/var/wwww/xxx/</1> directory and paste the following text into it. <1>/var/wwww/xxx/</1> directory and paste the following text into it.
info: 'After youve done that, click “Next” button.' info: "After youve done that, click “Next” button."
site_information: Site Information site_information: Site Information
admin_account: Admin Account admin_account: Admin Account
site_name: site_name:
@ -898,7 +899,7 @@ ui:
ready_description: >- ready_description: >-
If you ever feel like changing more settings, visit <1>admin section</1>; If you ever feel like changing more settings, visit <1>admin section</1>;
find it in the site menu. find it in the site menu.
good_luck: 'Have fun, and good luck!' good_luck: "Have fun, and good luck!"
warn_title: Warning warn_title: Warning
warn_description: >- warn_description: >-
The file <1>config.yaml</1> already exists. If you need to reset any of the The file <1>config.yaml</1> already exists. If you need to reset any of the
@ -913,46 +914,51 @@ ui:
This either means that the database information in your <1>config.yaml</1> file is incorrect or that contact with the database server could not be established. This could mean your hosts database server is down. This either means that the database information in your <1>config.yaml</1> file is incorrect or that contact with the database server could not be established. This could mean your hosts database server is down.
page_404: page_404:
description: 'Unfortunately, this page doesn''t exist.' description: "Unfortunately, this page doesn't exist."
back_home: Back to homepage back_home: Back to homepage
page_50X: page_50X:
description: The server encountered an error and could not complete your request. description: The server encountered an error and could not complete your request.
back_home: Back to homepage back_home: Back to homepage
page_maintenance: page_maintenance:
description: 'We are under maintenance, well be back soon.' description: "We are under maintenance, well be back soon."
nav_menus:
dashboard: Dashboard
contents: Contents
questions: Questions
answers: Answers
users: Users
flags: Flags
settings: Settings
general: General
interface: Interface
smtp: SMTP
branding: Branding
legal: Legal
write: Write
tos: Terms of Service
privacy: Privacy
admin: admin:
admin_header: admin_header:
title: Admin title: Admin
nav_menus:
dashboard: Dashboard
contents: Contents
questions: Questions
answers: Answers
users: Users
flags: Flags
settings: Settings
general: General
interface: Interface
smtp: SMTP
dashboard: dashboard:
title: Dashboard title: Dashboard
welcome: Welcome to Answer Admin! welcome: Welcome to Answer Admin!
site_statistics: Site Statistics site_statistics: Site Statistics
questions: 'Questions:' questions: "Questions:"
answers: 'Answers:' answers: "Answers:"
comments: 'Comments:' comments: "Comments:"
votes: 'Votes:' votes: "Votes:"
active_users: 'Active users:' active_users: "Active users:"
flags: 'Flags:' flags: "Flags:"
site_health_status: Site Health Status site_health_status: Site Health Status
version: 'Version:' version: "Version:"
https: 'HTTPS:' https: "HTTPS:"
uploading_files: 'Uploading files:' uploading_files: "Uploading files:"
smtp: 'SMTP:' smtp: "SMTP:"
timezone: 'Timezone:' timezone: "Timezone:"
system_info: System Info system_info: System Info
storage_used: 'Storage used:' storage_used: "Storage used:"
uptime: 'Uptime:' uptime: "Uptime:"
answer_links: Answer Links answer_links: Answer Links
documents: Documents documents: Documents
feedback: Feedback feedback: Feedback
@ -961,8 +967,8 @@ ui:
update_to: Update to update_to: Update to
latest: Latest latest: Latest
check_failed: Check failed check_failed: Check failed
'yes': 'Yes' "yes": "Yes"
'no': 'No' "no": "No"
not_allowed: Not allowed not_allowed: Not allowed
allowed: Allowed allowed: Allowed
enabled: Enabled enabled: Enabled
@ -984,7 +990,7 @@ ui:
suspended_name: suspended suspended_name: suspended
suspended_description: A suspended user can't log in. suspended_description: A suspended user can't log in.
deleted_name: deleted deleted_name: deleted
deleted_description: 'Delete profile, authentication associations.' deleted_description: "Delete profile, authentication associations."
inactive_name: inactive inactive_name: inactive
inactive_description: An inactive user must re-validate their email. inactive_description: An inactive user must re-validate their email.
confirm_title: Delete this user confirm_title: Delete this user
@ -993,11 +999,11 @@ ui:
msg: msg:
empty: Please select a reason. empty: Please select a reason.
status_modal: status_modal:
title: 'Change {{ type }} status to...' title: "Change {{ type }} status to..."
normal_name: normal normal_name: normal
normal_description: A normal post available to everyone. normal_description: A normal post available to everyone.
closed_name: closed closed_name: closed
closed_description: 'A closed question can''t answer, but still can edit, vote and comment.' closed_description: "A closed question can't answer, but still can edit, vote and comment."
deleted_name: deleted deleted_name: deleted
deleted_description: All reputation gained and lost will be restored. deleted_description: All reputation gained and lost will be restored.
btn_cancel: Cancel btn_cancel: Cancel
@ -1020,7 +1026,7 @@ ui:
deleted: Deleted deleted: Deleted
normal: Normal normal: Normal
filter: filter:
placeholder: 'Filter by name, user:id' placeholder: "Filter by name, user:id"
questions: questions:
page_title: Questions page_title: Questions
normal: Normal normal: Normal
@ -1034,7 +1040,7 @@ ui:
action: Action action: Action
change: Change change: Change
filter: filter:
placeholder: 'Filter by title, question:id' placeholder: "Filter by title, question:id"
answers: answers:
page_title: Answers page_title: Answers
normal: Normal normal: Normal
@ -1046,13 +1052,13 @@ ui:
action: Action action: Action
change: Change change: Change
filter: filter:
placeholder: 'Filter by title, answer:id' placeholder: "Filter by title, answer:id"
general: general:
page_title: General page_title: General
name: name:
label: Site Name label: Site Name
msg: Site name cannot be empty. msg: Site name cannot be empty.
text: 'The name of this site, as used in the title tag.' text: "The name of this site, as used in the title tag."
site_url: site_url:
label: Site URL label: Site URL
msg: Site url cannot be empty. msg: Site url cannot be empty.
@ -1061,11 +1067,11 @@ ui:
short_description: short_description:
label: Short Site Description (optional) label: Short Site Description (optional)
msg: Short site description cannot be empty. msg: Short site description cannot be empty.
text: 'Short description, as used in the title tag on homepage.' text: "Short description, as used in the title tag on homepage."
description: description:
label: Site Description (optional) label: Site Description (optional)
msg: Site description cannot be empty. msg: Site description cannot be empty.
text: 'Describe this site in one sentence, as used in the meta description tag.' text: "Describe this site in one sentence, as used in the meta description tag."
contact_email: contact_email:
label: Contact Email label: Contact Email
msg: Contact email cannot be empty. msg: Contact email cannot be empty.
@ -1126,5 +1132,45 @@ ui:
smtp_authentication: smtp_authentication:
label: SMTP Authentication label: SMTP Authentication
msg: SMTP authentication cannot be empty. msg: SMTP authentication cannot be empty.
'yes': 'Yes' "yes": "Yes"
'no': 'No' "no": "No"
branding:
page_title: Branding
logo:
label: Logo
msg: Logo cannot be empty.
text: The logo image at the top left of your site. Use a wide rectangular image with a height of 56 and an aspect ratio greater than 3:1. If left blank, the site title text will be shown.
mobile_logo:
label: Mobile Logo (optional)
text: The logo used on mobile version of your site. Use a wide rectangular image with a height of 56. If left blank, the image from the “logo” setting will be used.
square_icon:
label: Square Icon
msg: Square icon cannot be empty.
text: Image used as the base for metadata icons. Should ideally be larger than 512x512.
favicon:
label: Favicon (optional)
text: A favicon for your site. To work correctly over a CDN it must be a png. Will be resized to 32x32. If left blank, “square icon” will be used.
legal:
page_title: Legal
terms_of_service:
label: Terms of Service
text: "You can add terms of service content here. If you already have a document hosted elsewhere, provide the full URL here."
privacy_policy:
label: Privacy Policy
text: "You can add privacy policy content here. If you already have a document hosted elsewhere, provide the full URL here."
write:
page_title: Write
recommend_tags:
label: Recommend Tags
text: "Please input tag slug above, one tag per line."
required_tag:
label: Required Tag
text: "Every new question must have at least one recommend tag"
reserved_tags:
label: Reserved Tags
text: "Reserved tags can only be added to a post by moderator."
form:
empty: cannot be empty
invalid: is invalid
btn_submit: Save
not_found_props: "Required property {{ key }} not found."

View File

@ -759,20 +759,20 @@ ui:
page_50X: page_50X:
description: 服务器遇到了一个错误,无法完成你的请求。 description: 服务器遇到了一个错误,无法完成你的请求。
back_home: 回到主页 back_home: 回到主页
nav_menus:
dashboard: 后台管理
contents: 内容管理
questions: 问题
answers: 回答
users: 用户管理
flags: 举报管理
settings: 站点设置
general: 一般
interface: 界面
smtp: SMTP
admin: admin:
admin_header: admin_header:
title: 后台管理 title: 后台管理
nav_menus:
dashboard: 后台管理
contents: 内容管理
questions: 问题
answers: 回答
users: 用户管理
flags: 举报管理
settings: 站点设置
general: 一般
interface: 界面
smtp: SMTP
dashboard: dashboard:
title: 后台管理 title: 后台管理
welcome: 欢迎来到 Answer 后台管理! welcome: 欢迎来到 Answer 后台管理!

View File

@ -1 +0,0 @@
PUBLIC_URL=

View File

@ -1 +1,2 @@
REACT_APP_API_URL=http://10.0.10.98:2060 PUBLIC_URL
REACT_APP_API_URL = http://127.0.0.1

View File

@ -1,3 +1,2 @@
REACT_APP_API_URL=/ PUBLIC_URL = /
REACT_APP_PUBLIC_PATH=/ REACT_APP_API_URL = /
REACT_APP_VERSION=

View File

5
ui/.gitignore vendored
View File

@ -13,10 +13,7 @@
# misc # misc
.DS_Store .DS_Store
.env.local .env*.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View File

@ -1,93 +0,0 @@
include:
- project: 'segmentfault/devops/templates'
file:
- .deploy-cdn.yml
- .deploy-helm.yml
variables:
FF_USE_FASTZIP: 'true'
PROJECT_NAME: 'answer_static'
stages:
- install
- publish
- deploy
# 静态资源构建
install:
image: dockerhub.qingcloud.com/sf_base/node-build:14
stage: install
allow_failure: false
cache:
- key:
files:
- pnpm-lock.yml
paths:
- node_modules/
policy: pull-push
script:
- pnpm install
- if [ "$CI_COMMIT_BRANCH" = "dev" ]; then
sed -i "s/<projectName>/$PROJECT_NAME/g" .env.development;
sed -i "s/<version>/$CI_COMMIT_SHORT_SHA/g" .env.development;
pnpm run build:dev;
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
sed -i "s/<projectName>/$PROJECT_NAME/g" .env.test;
sed -i "s/<version>/$CI_COMMIT_SHORT_SHA/g" .env.test;
pnpm run build:test;
elif [ "$CI_COMMIT_BRANCH" = "release" ]; then
sed -i "s/<projectName>/$PROJECT_NAME/g" .env.production;
sed -i "s/<version>/$CI_COMMIT_SHORT_SHA/g" .env.production;
pnpm run build:prod;
fi
artifacts:
paths:
- build/
publish:cdn:dev:
extends: .deploy-cdn
stage: publish
only:
- dev
variables:
AssetsPath: ./build
Project: $PROJECT_NAME
Version: $CI_COMMIT_SHORT_SHA
Destination: dev
publish:cdn:test:
extends: .deploy-cdn
stage: publish
only:
- main
variables:
AssetsPath: ./build
Project: $PROJECT_NAME
Version: $CI_COMMIT_SHORT_SHA
Destination: test
publish:cdn:prod:
extends: .deploy-cdn
stage: publish
only:
- release
variables:
AssetsPath: ./build
Project: $PROJECT_NAME
Version: $CI_COMMIT_SHORT_SHA
Destination: prod
deploy:dev:
extends: .deploy-helm
stage: deploy
only:
- dev
needs:
- publish:cdn:dev
variables:
KubernetesCluster: dev
KubernetesNamespace: 'sf-test'
DockerTag: $CI_COMMIT_SHORT_SHA
ChartName: answer-web
InstallPolicy: replace

2
ui/.npmrc Normal file
View File

@ -0,0 +1,2 @@
strict-peer-dependencies = true
auto-install-peers = true

View File

@ -30,7 +30,7 @@ clone the repo locally and run following command in your terminal:
$ git clone git@github.com:answerdev/answer.git answer $ git clone git@github.com:answerdev/answer.git answer
$ cd answer/ui $ cd answer/ui
$ pnpm install $ pnpm install
$ pnpm run start $ pnpm start
``` ```
now, your browser should already open automatically, and autoload `http://localhost:3000`. now, your browser should already open automatically, and autoload `http://localhost:3000`.
@ -41,11 +41,9 @@ you can also manually visit it.
when cloning repo, and run `pnpm install` to init dependencies. you can use project commands below: when cloning repo, and run `pnpm install` to init dependencies. you can use project commands below:
- `pnpm run start` run Answer web locally. - `pnpm run start` run Answer web locally.
- `pnpm run build:dev` build code for environment `dev` - `pnpm run build` build Answer for production
- `pnpm run build:test` build code for environment `test`
- `pnpm run build:prod` build code for environment `prod`
- `pnpm run lint` lint and fix the code style - `pnpm run lint` lint and fix the code style
- `pnpm run cz` run `git commit` by `commitizen`
## 🖥 Environment Support ## 🖥 Environment Support

View File

@ -8,12 +8,8 @@ const i18nPath = path.resolve(__dirname, "../i18n");
module.exports = { module.exports = {
webpack: function(config, env) { webpack: function(config, env) {
if (env === "production") {
config.output.publicPath = process.env.REACT_APP_PUBLIC_PATH;
}
addWebpackAlias({ addWebpackAlias({
["@"]: path.resolve(__dirname, "src"), "@": path.resolve(__dirname, "src"),
"@i18n": i18nPath "@i18n": i18nPath
})(config); })(config);
@ -34,16 +30,16 @@ module.exports = {
return function(proxy, allowedHost) { return function(proxy, allowedHost) {
const config = configFunction(proxy, allowedHost); const config = configFunction(proxy, allowedHost);
config.proxy = { config.proxy = {
"/answer": { '/answer': {
target: "http://10.0.10.98:2060", target: process.env.REACT_APP_API_URL,
changeOrigin: true, changeOrigin: true,
secure: false secure: false,
}, },
"/installation": { '/installation': {
target: "http://10.0.10.98:2060", target: process.env.REACT_APP_API_URL,
changeOrigin: true, changeOrigin: true,
secure: false secure: false,
} },
}; };
return config; return config;
}; };

View File

@ -5,26 +5,13 @@
"homepage": "/", "homepage": "/",
"scripts": { "scripts": {
"start": "react-app-rewired start", "start": "react-app-rewired start",
"build:dev": "env-cmd -f .env.development react-app-rewired build", "build": "react-app-rewired build",
"build:test": "env-cmd -f .env.test react-app-rewired build",
"build:prod": "env-cmd -f .env.production react-app-rewired build",
"build": "env-cmd -f .env react-app-rewired build",
"test": "react-app-rewired test",
"lint": "eslint . --cache --fix --ext .ts,.tsx", "lint": "eslint . --cache --fix --ext .ts,.tsx",
"prepare": "cd .. && husky install",
"cz": "cz",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"prepare": "cd .. && husky install",
"preinstall": "node ./scripts/preinstall.js" "preinstall": "node ./scripts/preinstall.js"
}, },
"config": {
"commitizen": {
"path": "ui/node_modules/cz-conventional-changelog"
}
},
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"ahooks": "^3.7.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"bootstrap": "^5.2.0", "bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1", "bootstrap-icons": "^1.9.1",
@ -32,11 +19,7 @@
"codemirror": "5.65.0", "codemirror": "5.65.0",
"copy-to-clipboard": "^3.3.2", "copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"highlight.js": "^11.6.0",
"i18next": "^21.9.0", "i18next": "^21.9.0",
"i18next-chained-backend": "^3.0.2",
"i18next-http-backend": "^1.4.1",
"i18next-localstorage-backend": "^3.1.3",
"katex": "^0.16.2", "katex": "^0.16.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "^4.0.19", "marked": "^4.0.19",
@ -54,13 +37,10 @@
"zustand": "^4.1.1" "zustand": "^4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.18.10",
"@babel/plugin-syntax-flow": "^7.18.6",
"@babel/plugin-transform-react-jsx": "^7.14.9",
"@commitlint/cli": "^17.0.3", "@commitlint/cli": "^17.0.3",
"@commitlint/config-conventional": "^17.0.3",
"@fullhuman/postcss-purgecss": "^4.1.3", "@fullhuman/postcss-purgecss": "^4.1.3",
"@popperjs/core": "^2.11.5", "purgecss-webpack-plugin": "^4.1.3",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/dom": "^8.17.1", "@testing-library/dom": "^8.17.1",
"@testing-library/react": "^13.3.0", "@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@ -74,11 +54,7 @@
"@types/react-helmet": "^6.1.5", "@types/react-helmet": "^6.1.5",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.33.0", "@typescript-eslint/parser": "^5.33.0",
"commitizen": "^4.2.5",
"conventional-changelog-cli": "^2.2.2",
"customize-cra": "^1.0.0", "customize-cra": "^1.0.0",
"cz-conventional-changelog": "^3.3.0",
"env-cmd": "^10.1.0",
"eslint": "^8.0.1", "eslint": "^8.0.1",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-airbnb-typescript": "^17.0.0",
@ -95,13 +71,10 @@
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"postcss": "^8.0.0", "postcss": "^8.0.0",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"purgecss-webpack-plugin": "^4.1.3",
"react-app-rewired": "^2.2.1", "react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sass": "^1.54.4", "sass": "^1.54.4",
"tsconfig-paths-webpack-plugin": "^4.0.0", "typescript": "^4.8.3",
"typescript": "*",
"web-vitals": "^2.1.4",
"yaml-loader": "^0.8.0" "yaml-loader": "^0.8.0"
}, },
"packageManager": "pnpm@7.9.5", "packageManager": "pnpm@7.9.5",
@ -110,4 +83,4 @@
"pnpm": ">=7" "pnpm": ">=7"
}, },
"license": "MIT" "license": "MIT"
} }

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<!-- <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />--> <!-- <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />-->
@ -24,7 +23,34 @@
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root">
<style>
@keyframes _doc-spin {
to { transform: rotate(360deg) }
}
#doc-spinner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#doc-spinner .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;
}
</style>
<div id="doc-spinner">
<div class="spinner"></div>
</div>
</div>
<!-- <!--
This HTML file is a template. This HTML file is a template.
If you open it directly in the browser, you will see an empty page. If you open it directly in the browser, you will see an empty page.

View File

@ -1,5 +1,7 @@
// 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. // 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.
if (!/pnpm/.test(process.env.npm_execpath)) { 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`) console.warn(
process.exit(1) `\u001b[33mThis repository requires using pnpm as the package manager for scripts to work properly.\u001b[39m\n`,
} );
process.exit(1);
}

View File

@ -43,7 +43,7 @@ export const ADMIN_NAV_MENUS = [
}, },
{ {
name: 'contents', name: 'contents',
child: [{ name: 'questions' }, { name: 'answers' }], children: [{ name: 'questions' }, { name: 'answers' }],
}, },
{ {
name: 'users', name: 'users',
@ -54,10 +54,19 @@ export const ADMIN_NAV_MENUS = [
}, },
{ {
name: 'settings', name: 'settings',
child: [{ name: 'general' }, { name: 'interface' }, { name: 'smtp' }], children: [
{ name: 'general' },
{ name: 'interface' },
{ name: 'branding' },
{ name: 'smtp' },
{ name: 'legal' },
{ name: 'write' },
],
}, },
]; ];
export const ADMIN_LEGAL_MENUS = [{ name: 'tos' }, { name: 'privacy' }];
export const TIMEZONES = [ export const TIMEZONES = [
{ {
label: 'Africa', label: 'Africa',

View File

@ -24,6 +24,8 @@ export interface ReportParams {
export interface TagBase { export interface TagBase {
display_name: string; display_name: string;
slug_name: string; slug_name: string;
recommend: boolean;
reserved: boolean;
} }
export interface Tag extends TagBase { export interface Tag extends TagBase {
@ -126,7 +128,8 @@ export interface UserInfoRes extends UserInfoBase {
[prop: string]: any; [prop: string]: any;
} }
export interface AvatarUploadReq { export type UploadType = 'post' | 'avatar' | 'branding';
export interface UploadReq {
file: FormData; file: FormData;
} }
@ -266,7 +269,6 @@ export interface AdminSettingsGeneral {
} }
export interface AdminSettingsInterface { export interface AdminSettingsInterface {
logo: string;
language: string; language: string;
theme: string; theme: string;
time_zone?: string; time_zone?: string;
@ -285,10 +287,31 @@ export interface AdminSettingsSmtp {
} }
export interface SiteSettings { export interface SiteSettings {
branding: AdmingSettingBranding;
general: AdminSettingsGeneral; general: AdminSettingsGeneral;
interface: AdminSettingsInterface; interface: AdminSettingsInterface;
} }
export interface AdmingSettingBranding {
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 {
recommend_tags: string[];
required_tag: string;
reserved_tags: string[];
}
/** /**
* @description interface for Activity * @description interface for Activity
*/ */

View File

@ -8,7 +8,7 @@ import { useAccordionButton } from 'react-bootstrap/AccordionButton';
import { Icon } from '@/components'; import { Icon } from '@/components';
function MenuNode({ menu, callback, activeKey, isLeaf = false }) { function MenuNode({ menu, callback, activeKey, isLeaf = false }) {
const { t } = useTranslation('translation', { keyPrefix: 'admin.nav_menus' }); const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
const accordionClick = useAccordionButton(menu.name); const accordionClick = useAccordionButton(menu.name);
const menuOnClick = (evt) => { const menuOnClick = (evt) => {
evt.preventDefault(); evt.preventDefault();
@ -44,30 +44,45 @@ function MenuNode({ menu, callback, activeKey, isLeaf = false }) {
interface AccordionProps { interface AccordionProps {
menus: any[]; menus: any[];
path?: string;
} }
const AccordionNav: FC<AccordionProps> = ({ menus }) => { const AccordionNav: FC<AccordionProps> = ({ menus, path = '/' }) => {
const navigate = useNavigate(); const navigate = useNavigate();
let activeKey = menus[0].name; const pathMatch = useMatch(`${path}*`);
const pathMatch = useMatch('/admin/*'); if (!menus.length) {
return null;
}
// auto set menu fields
menus.forEach((m) => {
if (!Array.isArray(m.children)) {
m.children = [];
}
m.children.forEach((sm) => {
if (!Array.isArray(sm.children)) {
sm.children = [];
}
});
});
const splat = pathMatch && pathMatch.params['*']; const splat = pathMatch && pathMatch.params['*'];
let activeKey = menus[0].name;
if (splat) { if (splat) {
activeKey = splat; activeKey = splat;
} }
const menuClick = (clickedMenu) => { const menuClick = (clickedMenu) => {
const menuKey = clickedMenu.name; const menuKey = clickedMenu.name;
if (Array.isArray(clickedMenu.child) && clickedMenu.child.length) { if (clickedMenu.children.length) {
return; return;
} }
if (activeKey !== menuKey) { if (activeKey !== menuKey) {
const routePath = `/admin/${menuKey}`; const routePath = `${path}${menuKey}`;
navigate(routePath); navigate(routePath);
} }
}; };
let defaultOpenKey; let defaultOpenKey;
menus.forEach((li) => { menus.forEach((li) => {
if (Array.isArray(li.child) && li.child.length) { if (li.children.length) {
const matchedChild = li.child.find((el) => { const matchedChild = li.children.find((el) => {
return el.name === activeKey; return el.name === activeKey;
}); });
if (matchedChild) { if (matchedChild) {
@ -83,10 +98,10 @@ const AccordionNav: FC<AccordionProps> = ({ menus }) => {
return ( return (
<React.Fragment key={li.name}> <React.Fragment key={li.name}>
<MenuNode menu={li} callback={menuClick} activeKey={activeKey} /> <MenuNode menu={li} callback={menuClick} activeKey={activeKey} />
{Array.isArray(li.child) ? ( {li.children.length ? (
<Accordion.Collapse eventKey={li.name} className="ms-4"> <Accordion.Collapse eventKey={li.name} className="ms-4">
<Stack direction="vertical" gap={1}> <Stack direction="vertical" gap={1}>
{li.child?.map((leaf) => { {li.children.map((leaf) => {
return ( return (
<MenuNode <MenuNode
menu={leaf} menu={leaf}

View File

@ -1,23 +0,0 @@
import { FC, memo } from 'react';
import { Container } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useMatch } from 'react-router-dom';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.admin_header',
});
const adminPathMatch = useMatch('/admin/*');
if (!adminPathMatch) {
return null;
}
return (
<div className="bg-light py-2">
<Container className="py-1">
<h6 className="mb-0 fw-bold lh-base">{t('title')}</h6>
</Container>
</div>
);
};
export default memo(Index);

View File

@ -0,0 +1,44 @@
import { FC } from 'react';
import { ButtonGroup, Button } from 'react-bootstrap';
import { Icon, UploadImg } from '@/components';
import { UploadType } from '@/common/interface';
interface Props {
type: UploadType;
value: string;
onChange: (value: string) => void;
acceptType?: string;
}
const Index: FC<Props> = ({ type = 'post', value, onChange, acceptType }) => {
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 src={value} alt="" height={100} />
</div>
<ButtonGroup vertical className="fit-content">
<UploadImg
type={type}
uploadCallback={onUpload}
className="mb-0"
acceptType={acceptType}>
<Icon name="cloud-upload" />
</UploadImg>
<Button variant="outline-secondary" onClick={onRemove}>
<Icon name="trash" />
</Button>
</ButtonGroup>
</div>
);
};
export default Index;

View File

@ -61,7 +61,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
files: FileList, files: FileList,
): Promise<{ url: string; name: string }[]> => { ): Promise<{ url: string; name: string }[]> => {
const promises = Array.from(files).map(async (file) => { const promises = Array.from(files).map(async (file) => {
const url = await uploadImage(file); const url = await uploadImage({ file, type: 'post' });
return { return {
name: file.name, name: file.name,
@ -209,7 +209,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
return; return;
} }
uploadImage(e.target.files[0]).then((url) => { uploadImage({ file: e.target.files[0], type: 'post' }).then((url) => {
setLink({ ...link, value: url }); setLink({ ...link, value: url });
}); });
}; };

View File

@ -1,6 +1,5 @@
import type { Editor, Position } from 'codemirror'; import type { Editor, Position } from 'codemirror';
import type CodeMirror from 'codemirror'; import type CodeMirror from 'codemirror';
// import 'highlight.js/styles/github.css';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
export function createEditorUtils( export function createEditorUtils(
@ -114,9 +113,4 @@ export function htmlRender(el: HTMLElement | null) {
}); });
}, },
); );
// import('highlight.js').then(({ default: highlight }) => {
// el.querySelectorAll('pre code').forEach((code) => {
// highlight.highlightElement(code as HTMLElement);
// });
// });
} }

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { TagSelector, Tag } from '@/components'; import { TagSelector, Tag } from '@/components';
import { tryLoggedAndActicevated } from '@/utils/guard'; import { tryLoggedAndActivated } from '@/utils/guard';
import { useFollowingTags, followTags } from '@/services'; import { useFollowingTags, followTags } from '@/services';
const Index: FC = () => { const Index: FC = () => {
@ -32,7 +32,7 @@ const Index: FC = () => {
}); });
}; };
if (!tryLoggedAndActicevated().ok) { if (!tryLoggedAndActivated().ok) {
return null; return null;
} }
@ -73,11 +73,7 @@ const Index: FC = () => {
<> <>
{followingTags.map((item) => { {followingTags.map((item) => {
const slugName = item?.slug_name; const slugName = item?.slug_name;
return ( return <Tag key={slugName} className="m-1" data={item} />;
<Tag key={slugName} className="m-1" href={`/tags/${slugName}`}>
{slugName}
</Tag>
);
})} })}
</> </>
) : ( ) : (

View File

@ -17,7 +17,7 @@ import {
useLocation, useLocation,
} from 'react-router-dom'; } from 'react-router-dom';
import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores'; import { loggedUserInfoStore, siteInfoStore, brandingStore } from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services'; import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias'; import { RouteAlias } from '@/router/alias';
@ -33,7 +33,7 @@ const Header: FC = () => {
const q = urlSearch.get('q'); const q = urlSearch.get('q');
const [searchStr, setSearch] = useState(''); const [searchStr, setSearch] = useState('');
const siteInfo = siteInfoStore((state) => state.siteInfo); const siteInfo = siteInfoStore((state) => state.siteInfo);
const { interface: interfaceInfo } = interfaceStore(); const brandingInfo = brandingStore((state) => state.branding);
const { data: redDot } = useQueryNotificationStatus(); const { data: redDot } = useQueryNotificationStatus();
const location = useLocation(); const location = useLocation();
const handleInput = (val) => { const handleInput = (val) => {
@ -73,10 +73,10 @@ const Header: FC = () => {
<div className="d-flex justify-content-between align-items-center nav-grow flex-nowrap"> <div className="d-flex justify-content-between align-items-center nav-grow flex-nowrap">
<Navbar.Brand to="/" as={Link} className="lh-1 me-0 me-sm-3"> <Navbar.Brand to="/" as={Link} className="lh-1 me-0 me-sm-3">
{interfaceInfo.logo ? ( {brandingInfo.logo ? (
<img <img
className="logo rounded-1 me-0" className="logo rounded-1 me-0"
src={interfaceInfo.logo} src={brandingInfo.logo}
alt="" alt=""
/> />
) : ( ) : (

View File

@ -0,0 +1,27 @@
import React, { memo, FC } from 'react';
import classNames from 'classnames';
import { labelStyle } from '@/utils';
interface IProps {
className?: string;
children?: React.ReactNode;
color: string;
}
const Index: FC<IProps> = ({ className = '', children, color }) => {
// hover
const [hover, setHover] = React.useState(false);
return (
<span
className={classNames('badge-label rounded-1', className)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={labelStyle(color, hover)}>
{children}
</span>
);
};
export default memo(Index);

View File

@ -0,0 +1,33 @@
import { Card, Dropdown, Form } from 'react-bootstrap';
import Label from '../Label';
const Labels = ({ className }) => {
return (
<Card className={className}>
<Card.Header className="d-flex justify-content-between align-items-center">
<Card.Title className="mb-0">Labels</Card.Title>
<Dropdown align="end">
<Dropdown.Toggle variant="link" className="no-toggle">
Edit
</Dropdown.Toggle>
<Dropdown.Menu className="p-3">
<Form.Check className="mb-2" type="checkbox" label="featured" />
<Form.Check className="mb-2" type="checkbox" label="featured" />
<Form.Check className="mb-2" type="checkbox" label="featured" />
<Form.Check type="checkbox" label="featured" />
</Dropdown.Menu>
</Dropdown>
</Card.Header>
<Card.Body>
<Label className="badge-label" color="#DC3545">
featured
</Label>
</Card.Body>
</Card>
);
};
export default Labels;

View File

@ -158,14 +158,7 @@ const QuestionList: FC<Props> = ({ source }) => {
{Array.isArray(li.tags) {Array.isArray(li.tags)
? li.tags.map((tag) => { ? li.tags.map((tag) => {
return ( return (
<Tag <Tag key={tag.slug_name} className="m-1" data={tag} />
key={tag.slug_name}
className="m-1"
href={`/tags/${
tag.main_tag_slug_name || tag.slug_name
}`}>
{tag.slug_name}
</Tag>
); );
}) })
: null} : null}

View File

@ -0,0 +1,79 @@
# SchemaForm User Guide
## Introduction
SchemaForm is a component that can be used to render a form based on a [JSON schema](https://json-schema.org/understanding-json-schema/index.html).
## Usage
### Basic Usage
```tsx
import React from 'react';
import { SchemaForm, initFormData, JSONSchema, UISchema } from '@/components';
const schema: JSONSchema = {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Name',
},
age: {
type: 'number',
title: 'Age',
},
sex:{
type: 'boolean',
title: 'sex',
enum: [1, 2],
enumNames: ['male', 'female'],
}
},
};
const uiSchema: UISchema = {
name: {
'ui:widget': 'input',
},
age: {
'ui:widget': 'input',
'ui:options': {
type: 'number'
}
},
sex: {
'ui:widget': 'radio',
}
};
// form component
const Form = () => {
const [formData, setFormData] = useState(initFormData(schema));
return (
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={console.log}
/>
);
};
```
## Props
| Property | Description | Type | Default |
| -------- | ---------------------------------------- | ----------------------------------------- | ------- |
| schema | JSON schema | [JSONSchema](index.tsx#L9) | - |
| uiSchema | UI schema | [UISchema](index.tsx#L24) | - |
| formData | Form data | [FormData](index.tsx#L66) | - |
| onChange | Callback function when form data changes | (data: [FormData](index.tsx#L66)) => void | - |
| onSubmit | Callback function when form is submitted | (data: React.FormEvent) => void | - |
## reference
- [json schema](https://json-schema.org/understanding-json-schema/index.html)
- [react-jsonschema-form](http://rjsf-team.github.io/react-jsonschema-form/)
- [vue-json-schema-form](https://github.com/lljj-x/vue-json-schema-form/)

View File

@ -0,0 +1,415 @@
import { FC } from 'react';
import { Form, Button, Stack } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import BrandUpload from '../BrandUpload';
import TimeZonePicker from '../TimeZonePicker';
import type * as Type from '@/common/interface';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean';
title: string;
description?: string;
enum?: Array<string | boolean>;
enumNames?: string[];
default?: string | boolean;
};
};
}
export interface UISchema {
[key: string]: {
'ui:widget'?:
| 'textarea'
| 'text'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
'ui:options'?: {
rows?: number;
placeholder?: string;
type?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
empty?: string;
validator?: (value) => Promise<string | true | void> | true | string;
textRender?: () => React.ReactElement;
imageType?: Type.UploadType;
acceptType?: string;
};
};
}
interface IProps {
schema: JSONSchema;
uiSchema?: UISchema;
formData?: Type.FormDataType;
onChange?: (data: Type.FormDataType) => void;
onSubmit: (e: React.FormEvent) => void;
}
/**
* json schema form
* @param schema json schema
* @param uiSchema ui schema
* @param formData form data
* @param onChange change event
* @param onSubmit submit event
*/
const SchemaForm: FC<IProps> = ({
schema,
uiSchema = {},
formData = {},
onChange,
onSubmit,
}) => {
const { t } = useTranslation('translation', {
keyPrefix: 'form',
});
const { required = [], properties } = schema;
// check required field
const excludes = required.filter((key) => !properties[key]);
if (excludes.length > 0) {
console.error(t('not_found_props', { key: excludes.join(', ') }));
}
const keys = Object.keys(properties);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const data = {
...formData,
[name]: { ...formData[name], value, isInvalid: false },
};
if (onChange instanceof Function) {
onChange(data);
}
};
const handleSwitchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target;
const data = {
...formData,
[name]: { ...formData[name], value: checked, isInvalid: false },
};
if (onChange instanceof Function) {
onChange(data);
}
};
const requiredValidator = () => {
const errors: string[] = [];
required.forEach((key) => {
if (!formData[key] || !formData[key].value) {
errors.push(key);
}
});
return errors;
};
const syncValidator = () => {
const errors: Array<{ key: string; msg: string }> = [];
const promises: Array<{
key: string;
promise;
}> = [];
keys.forEach((key) => {
const { validator } = uiSchema[key]?.['ui:options'] || {};
if (validator instanceof Function) {
const value = formData[key]?.value;
promises.push({
key,
promise: validator(value),
});
}
});
return Promise.allSettled(promises.map((item) => item.promise)).then(
(results) => {
results.forEach((result, index) => {
const { key } = promises[index];
if (result.status === 'rejected') {
errors.push({
key,
msg: result.reason.message,
});
}
if (result.status === 'fulfilled') {
const msg = result.value;
if (typeof msg === 'string') {
errors.push({
key,
msg,
});
}
}
});
return errors;
},
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors = requiredValidator();
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
console.log('schema.properties[cur]', cur);
acc[cur] = {
...formData[cur],
isInvalid: true,
errorMsg:
uiSchema[cur]?.['ui:options']?.empty ||
`${schema.properties[cur]?.title} ${t('empty')}`,
};
return acc;
}, formData);
if (onChange instanceof Function) {
onChange({ ...formData });
}
return;
}
const syncErrors = await syncValidator();
if (syncErrors.length > 0) {
formData = syncErrors.reduce((acc, cur) => {
acc[cur.key] = {
...formData[cur.key],
isInvalid: true,
errorMsg:
cur.msg || `${schema.properties[cur.key].title} ${t('invalid')}`,
};
return acc;
}, formData);
if (onChange instanceof Function) {
onChange({ ...formData });
}
return;
}
Object.keys(formData).forEach((key) => {
formData[key].isInvalid = false;
formData[key].errorMsg = '';
});
if (onChange instanceof Function) {
onChange(formData);
}
onSubmit(e);
};
const handleUploadChange = (name: string, value: string) => {
const data = { ...formData, [name]: { ...formData[name], value } };
if (onChange instanceof Function) {
onChange(data);
}
};
return (
<Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => {
const { title, description } = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': options = {} } =
uiSchema[key] || {};
if (widget === 'select') {
return (
<Form.Group key={title} controlId={key} className="mb-3">
<Form.Label>{title}</Form.Label>
<Form.Select
aria-label={description}
isInvalid={formData[key].isInvalid}>
{properties[key].enum?.map((item, index) => {
return (
<option value={String(item)} key={String(item)}>
{properties[key].enumNames?.[index]}
</option>
);
})}
</Form.Select>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
}
if (widget === 'checkbox' || widget === 'radio') {
return (
<Form.Group key={title} className="mb-3" controlId={key}>
<Form.Label>{title}</Form.Label>
<Stack direction="horizontal">
{properties[key].enum?.map((item, index) => {
return (
<Form.Check
key={String(item)}
inline
required
type={widget}
name={title}
id={String(item)}
label={properties[key].enumNames?.[index]}
checked={formData[key]?.value === item}
feedback={formData[key]?.errorMsg}
feedbackType="invalid"
isInvalid={formData[key].isInvalid}
/>
);
})}
</Stack>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
}
if (widget === 'switch') {
console.log(formData[key]?.value, 'switch=====');
return (
<Form.Group key={title} className="mb-3" controlId={key}>
<Form.Label>{title}</Form.Label>
<Form.Check
required
id={title}
name={key}
type="switch"
label={title}
checked={formData[key]?.value}
feedback={formData[key]?.errorMsg}
feedbackType="invalid"
isInvalid={formData[key].isInvalid}
onChange={handleSwitchChange}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
}
if (widget === 'timezone') {
return (
<Form.Group key={title} className="mb-3" controlId={key}>
<Form.Label>{title}</Form.Label>
<TimeZonePicker
value={formData[key]?.value}
onChange={handleInputChange}
/>
<Form.Control
name={key}
className="d-none"
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
}
if (widget === 'upload') {
return (
<Form.Group key={title} className="mb-3" controlId={key}>
<Form.Label>{title}</Form.Label>
<BrandUpload
type={options.imageType || 'avatar'}
acceptType={options.acceptType || ''}
value={formData[key]?.value}
onChange={(value) => handleUploadChange(key, value)}
/>
<Form.Control
name={key}
className="d-none"
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
}
if (widget === 'textarea') {
return (
<Form.Group controlId={key} key={key} className="mb-3">
<Form.Label>{title}</Form.Label>
<Form.Control
as="textarea"
name={key}
placeholder={options?.placeholder || ''}
type={options?.type || 'text'}
value={formData[key]?.value}
onChange={handleInputChange}
isInvalid={formData[key].isInvalid}
rows={options?.rows || 3}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
}
return (
<Form.Group controlId={key} key={key} className="mb-3">
<Form.Label>{title}</Form.Label>
<Form.Control
name={key}
placeholder={options?.placeholder || ''}
type={options?.type || 'text'}
value={formData[key]?.value}
onChange={handleInputChange}
style={options?.type === 'color' ? { width: '6rem' } : {}}
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
<Form.Text className="text-muted">{description}</Form.Text>
</Form.Group>
);
})}
<Button variant="primary" type="submit">
{t('btn_submit')}
</Button>
</Form>
);
};
export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {};
Object.keys(schema.properties).forEach((key) => {
formData[key] = {
value: '',
isInvalid: false,
errorMsg: '',
};
});
return formData;
};
export default SchemaForm;

View File

@ -2,17 +2,27 @@ import React, { memo, FC } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Tag } from '@/common/interface';
interface IProps { interface IProps {
data: Tag;
href?: string;
className?: string; className?: string;
children?: React.ReactNode;
href: string;
} }
const Index: FC<IProps> = ({ className = '', children, href }) => { const Index: FC<IProps> = ({ className = '', href, data }) => {
href = href.toLowerCase(); href =
href || `/tags/${data.main_tag_slug_name || data.slug_name}`.toLowerCase();
return ( return (
<a href={href} className={classNames('badge-tag rounded-1', className)}> <a
{children} href={href}
className={classNames(
'badge-tag rounded-1',
data.reserved && 'badge-tag-reserved',
data.recommend && 'badge-tag-required',
className,
)}>
{data.slug_name}
</a> </a>
); );
}; };

View File

@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { FC, useState, useEffect } from 'react'; import { FC, useState, useEffect } from 'react';
import { Dropdown, FormControl, Button, Form } from 'react-bootstrap'; import { Dropdown, FormControl, Button, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -95,17 +96,16 @@ const TagSelector: FC<IProps> = ({
} }
}, [value]); }, [value]);
useEffect(() => { const fetchTags = (str) => {
if (!tag) { queryTags(str).then((res) => {
setTags(null);
return;
}
queryTags(tag).then((res) => {
const tagArray: Type.Tag[] = filterTags(res || []); const tagArray: Type.Tag[] = filterTags(res || []);
setTags(tagArray); setTags(tagArray);
}); });
}, [tag]); };
useEffect(() => {
fetchTags(tag);
}, [visibleMenu]);
const handleClick = (val: Type.Tag) => { const handleClick = (val: Type.Tag) => {
const findIndex = initialValue.findIndex( const findIndex = initialValue.findIndex(
@ -143,7 +143,9 @@ const TagSelector: FC<IProps> = ({
}; };
const handleSearch = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearch = async (e: React.ChangeEvent<HTMLInputElement>) => {
setTag(e.currentTarget.value.replace(';', '')); const searchStr = e.currentTarget.value.replace(';', '');
setTag(searchStr);
fetchTags(searchStr);
}; };
const handleSelect = (eventKey) => { const handleSelect = (eventKey) => {
@ -186,7 +188,9 @@ const TagSelector: FC<IProps> = ({
'm-1 text-nowrap d-flex align-items-center', 'm-1 text-nowrap d-flex align-items-center',
index === repeatIndex && 'warning', index === repeatIndex && 'warning',
)} )}
variant="outline-secondary" variant={`outline-${
item.reserved ? 'danger' : item.recommend ? 'dark' : 'secondary'
}`}
size="sm"> size="sm">
{item.slug_name} {item.slug_name}
<span className="ms-1" onMouseUp={() => handleRemove(item)}> <span className="ms-1" onMouseUp={() => handleRemove(item)}>
@ -220,6 +224,14 @@ const TagSelector: FC<IProps> = ({
</Form> </Form>
</Dropdown.Header> </Dropdown.Header>
)} )}
{tags && tags.filter((v) => v.recommend)?.length > 0 && (
<Dropdown.Item
disabled
style={{ fontWeight: 500 }}
className="text-secondary">
Required tag (at least one)
</Dropdown.Item>
)}
{tags?.map((item, index) => { {tags?.map((item, index) => {
return ( return (

View File

@ -0,0 +1,25 @@
import { Form } from 'react-bootstrap';
import { TIMEZONES } from '@/common/constants';
const TimeZonePicker = (props) => {
return (
<Form.Select {...props}>
{TIMEZONES?.map((item) => {
return (
<optgroup label={item.label} key={item.label}>
{item.options.map((option) => {
return (
<option value={option.value} key={option.value}>
{option.label}
</option>
);
})}
</optgroup>
);
})}
</Form.Select>
);
};
export default TimeZonePicker;

View File

@ -1,16 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { uploadImage } from '@/services';
import * as Type from '@/common/interface';
interface IProps { interface IProps {
type: string; type: Type.UploadType;
upload: (data: FormData) => Promise<any>; className?: string;
children?: React.ReactNode;
acceptType?: string;
uploadCallback: (img: string) => void;
} }
const Index: React.FC<IProps> = ({ type, upload }) => { const Index: React.FC<IProps> = ({
type,
uploadCallback,
children,
acceptType = '',
className,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [status, setStatus] = useState(false); const [status, setStatus] = useState(false);
const onChange = (e: any) => { const onChange = (e: any) => {
console.log('uploading', e);
if (status) { if (status) {
return; return;
} }
@ -24,25 +37,25 @@ const Index: React.FC<IProps> = ({ type, upload }) => {
// return; // return;
// } // }
setStatus(true); setStatus(true);
const data = new FormData(); console.log('uploading', e.target.files);
uploadImage({ file: e.target.files[0], type })
data.append('file', e.target.files[0]); .then((res) => {
// do uploadCallback(res);
upload(data).finally(() => { })
setStatus(false); .finally(() => {
}); setStatus(false);
});
} }
}; };
return ( return (
<label className="mb-2 btn btn-outline-secondary uploadBtn"> <label className={`btn btn-outline-secondary uploadBtn ${className}`}>
{status ? t('upload_img.loading') : t('upload_img.name')} {children || (status ? t('upload_img.loading') : t('upload_img.name'))}
<input <input
type="file" type="file"
className="d-none" className="d-none"
accept="image/jpeg,image/jpg,image/png,image/webp" accept={`image/jpeg,image/jpg,image/png,image/webp${acceptType}`}
onChange={onChange} onChange={onChange}
id={type}
/> />
</label> </label>
); );

View File

@ -18,13 +18,15 @@ import TextArea from './TextArea';
import Mentions from './Mentions'; import Mentions from './Mentions';
import FormatTime from './FormatTime'; import FormatTime from './FormatTime';
import Toast from './Toast'; import Toast from './Toast';
import AdminHeader from './AdminHeader';
import AccordionNav from './AccordionNav'; import AccordionNav from './AccordionNav';
import PageTitle from './PageTitle'; import PageTitle from './PageTitle';
import Empty from './Empty'; import Empty from './Empty';
import BaseUserCard from './BaseUserCard'; import BaseUserCard from './BaseUserCard';
import FollowingTags from './FollowingTags'; import FollowingTags from './FollowingTags';
import QueryGroup from './QueryGroup'; import QueryGroup from './QueryGroup';
import BrandUpload from './BrandUpload';
import SchemaForm, { JSONSchema, UISchema, initFormData } from './SchemaForm';
import Labels from './LabelsCard';
export { export {
Avatar, Avatar,
@ -47,7 +49,6 @@ export {
Mentions, Mentions,
FormatTime, FormatTime,
Toast, Toast,
AdminHeader,
AccordionNav, AccordionNav,
PageTitle, PageTitle,
Empty, Empty,
@ -55,5 +56,9 @@ export {
FollowingTags, FollowingTags,
htmlRender, htmlRender,
QueryGroup, QueryGroup,
BrandUpload,
SchemaForm,
initFormData,
Labels,
}; };
export type { EditorRef }; export type { EditorRef, JSONSchema, UISchema };

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useLayoutEffect, useState } from 'react';
import { Modal, Form, Button, FormCheck } from 'react-bootstrap'; import { Modal, Form, Button, FormCheck } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -141,69 +141,70 @@ const useChangeModal = ({ callback }: Props) => {
setDefaultType(params.type); setDefaultType(params.type);
setShow(true); setShow(true);
}; };
useLayoutEffect(() => {
root.render(
<Modal show={show} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title as="h5">{t('title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
{list.map((item) => {
if (
defaultType === 'inactive' &&
(item.type === 'suspended' || item.type === 'deleted')
) {
return null;
}
root.render( if (defaultType === 'suspended' && item.type === 'inactive') {
<Modal show={show} onHide={onClose}> return null;
<Modal.Header closeButton> }
<Modal.Title as="h5">{t('title')}</Modal.Title> return (
</Modal.Header> <div key={item?.type}>
<Modal.Body> <Form.Group
<Form> controlId={item.type}
{list.map((item) => { className={`${
if ( item.have_content && changeType === item.type
defaultType === 'inactive' && ? 'mb-2'
(item.type === 'suspended' || item.type === 'deleted') : 'mb-3'
) { }`}>
return null; <FormCheck>
} <FormCheck.Input
id={item.type}
if (defaultType === 'suspended' && item.type === 'inactive') { type="radio"
return null; checked={changeType.type === item.type}
} onChange={() => handleRadio(item)}
return ( isInvalid={isInvalid}
<div key={item?.type}> />
<Form.Group <FormCheck.Label htmlFor={item.type}>
controlId={item.type} <span className="fw-bold">{item?.name}</span>
className={`${ <br />
item.have_content && changeType === item.type <span className="text-secondary">
? 'mb-2' {item?.description}
: 'mb-3' </span>
}`}> </FormCheck.Label>
<FormCheck> <Form.Control.Feedback type="invalid">
<FormCheck.Input {t('msg.empty')}
id={item.type} </Form.Control.Feedback>
type="radio" </FormCheck>
checked={changeType.type === item.type} </Form.Group>
onChange={() => handleRadio(item)} </div>
isInvalid={isInvalid} );
/> })}
<FormCheck.Label htmlFor={item.type}> </Form>
<span className="fw-bold">{item?.name}</span> </Modal.Body>
<br /> <Modal.Footer>
<span className="text-secondary"> <Button variant="link" onClick={() => onClose()}>
{item?.description} {t('btn_cancel')}
</span> </Button>
</FormCheck.Label> <Button variant="primary" onClick={handleSubmit}>
<Form.Control.Feedback type="invalid"> {t('btn_submit')}
{t('msg.empty')} </Button>
</Form.Control.Feedback> </Modal.Footer>
</FormCheck> </Modal>,
</Form.Group> );
</div> });
);
})}
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="link" onClick={() => onClose()}>
{t('btn_cancel')}
</Button>
<Button variant="primary" onClick={handleSubmit}>
{t('btn_submit')}
</Button>
</Modal.Footer>
</Modal>,
);
return { return {
onClose, onClose,

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useLayoutEffect, useState } from 'react';
import { Modal, Form, Button, FormCheck } from 'react-bootstrap'; import { Modal, Form, Button, FormCheck } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -73,56 +73,57 @@ const useEditStatusModal = ({
setDefaultType(params.type); setDefaultType(params.type);
setShow(true); setShow(true);
}; };
useLayoutEffect(() => {
root.render( root.render(
<Modal show={show} onHide={onClose}> <Modal show={show} onHide={onClose}>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title as="h5">{t('title', { type: editType })}</Modal.Title> <Modal.Title as="h5">{t('title', { type: editType })}</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Form> <Form>
{list.map((item) => { {list.map((item) => {
if (editType === 'answer' && item.type === 'closed') { if (editType === 'answer' && item.type === 'closed') {
return null; return null;
} }
return ( return (
<div key={item?.type}> <div key={item?.type}>
<Form.Group controlId={item.type} className="mb-3"> <Form.Group controlId={item.type} className="mb-3">
<FormCheck> <FormCheck>
<FormCheck.Input <FormCheck.Input
id={item.type} id={item.type}
type="radio" type="radio"
checked={changeType === item.type} checked={changeType === item.type}
onChange={() => handleRadio(item)} onChange={() => handleRadio(item)}
isInvalid={isInvalid} isInvalid={isInvalid}
/> />
<FormCheck.Label htmlFor={item.type}> <FormCheck.Label htmlFor={item.type}>
<span className="fw-bold">{item.name}</span> <span className="fw-bold">{item.name}</span>
<br /> <br />
<span className="fs-14 text-secondary"> <span className="fs-14 text-secondary">
{item.description} {item.description}
</span> </span>
</FormCheck.Label> </FormCheck.Label>
<Form.Control.Feedback type="invalid"> <Form.Control.Feedback type="invalid">
{t('msg.empty')} {t('msg.empty')}
</Form.Control.Feedback> </Form.Control.Feedback>
</FormCheck> </FormCheck>
</Form.Group> </Form.Group>
</div> </div>
); );
})} })}
</Form> </Form>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="link" onClick={() => onClose()}> <Button variant="link" onClick={() => onClose()}>
{t('btn_cancel')} {t('btn_cancel')}
</Button> </Button>
<Button variant="primary" onClick={handleSubmit}> <Button variant="primary" onClick={handleSubmit}>
{changeType !== 'normal' ? t('btn_next') : t('btn_submit')} {changeType !== 'normal' ? t('btn_next') : t('btn_submit')}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal>, </Modal>,
); );
});
return { return {
onClose, onClose,

View File

@ -1,15 +1,12 @@
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import i18next from 'i18next'; import i18next from 'i18next';
import Backend from 'i18next-http-backend';
import en_US from '@i18n/en_US.yaml'; import en_US from '@i18n/en_US.yaml';
import zh_CN from '@i18n/zh_CN.yaml'; import zh_CN from '@i18n/zh_CN.yaml';
import { DEFAULT_LANG } from '@/common/constants'; import { DEFAULT_LANG } from '@/common/constants';
i18next i18next
// load translation using http
.use(Backend)
// pass the i18n instance to react-i18next. // pass the i18n instance to react-i18next.
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
@ -31,12 +28,6 @@ i18next
// allow <br/> and simple html elements in translations // allow <br/> and simple html elements in translations
transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'], transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
}, },
// backend: {
// loadPath: (lngs, namespace) => {
// console.log(lngs, namespace);
// return 'https://cdn.jsdelivr.net/npm/echarts@4.8.0/map/js/china.js';
// },
// },
}); });
export default i18next; export default i18next;

View File

@ -2,6 +2,10 @@
@import '~bootstrap/scss/bootstrap'; @import '~bootstrap/scss/bootstrap';
@import '~bootstrap-icons'; @import '~bootstrap-icons';
.bg-gray-300 {
background-color: $gray-300;
}
.focus { .focus {
color: $input-focus-color !important; color: $input-focus-color !important;
background-color: $input-focus-bg !important; background-color: $input-focus-bg !important;
@ -65,11 +69,32 @@ a {
padding: 1px 0.5rem 2px; padding: 1px 0.5rem 2px;
color: $blue-700; color: $blue-700;
height: 24px; height: 24px;
border: 1px solid rgba($blue-100, 0.5);
&:hover { &:hover {
background: rgba($blue-100, 1); background: rgba($blue-100, 1);
} }
} }
.badge-tag-required {
background: rgba($gray-200, 0.5);
color: $gray-700;
border: 1px solid $gray-400;
&:hover {
color: $gray-700;
background: rgba($gray-400, 1);
}
}
.badge-tag-reserved {
background: rgba($orange-100, 0.5);
color: $orange-700;
border: 1px solid $orange-400;
&:hover {
color: $orange-700;
background: rgba($orange-400, 1);
}
}
.divide-line { .divide-line {
border-bottom: 1px solid rgba(33, 37, 41, 0.25); border-bottom: 1px solid rgba(33, 37, 41, 0.25);
} }
@ -143,6 +168,7 @@ a {
.fit-content { .fit-content {
height: fit-content; height: fit-content;
width: fit-content;
flex: none; flex: none;
} }
@ -210,3 +236,16 @@ a {
} }
} }
} }
.upload-img-wrap {
flex-grow: 1;
height: 128px;
}
.badge-label {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
padding: 1px 0.5rem 2px;
height: 24px;
}

View File

@ -0,0 +1,154 @@
import { FC, memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { FormDataType } from '@/common/interface';
import { brandSetting, getBrandSetting } from '@/services';
import { brandingStore } from '@/stores';
import { useToast } from '@/hooks';
const uploadType = 'branding';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.branding',
});
const { branding: brandingInfo, update } = brandingStore();
const Toast = useToast();
const [formData, setFormData] = useState<FormDataType>({
logo: {
value: brandingInfo.logo,
isInvalid: false,
errorMsg: '',
},
mobile_logo: {
value: '',
isInvalid: false,
errorMsg: '',
},
square_icon: {
value: '',
isInvalid: false,
errorMsg: '',
},
favicon: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
const schema: JSONSchema = {
title: t('page_title'),
properties: {
logo: {
type: 'string',
title: t('logo.label'),
description: t('logo.text'),
},
mobile_logo: {
type: 'string',
title: t('mobile_logo.label'),
description: t('mobile_logo.text'),
},
square_icon: {
type: 'string',
title: t('square_icon.label'),
description: t('square_icon.text'),
},
favicon: {
type: 'string',
title: t('favicon.label'),
description: t('favicon.text'),
},
},
};
const uiSchema: UISchema = {
logo: {
'ui:widget': 'upload',
'ui:options': {
imageType: uploadType,
},
},
mobile_logo: {
'ui:widget': 'upload',
'ui:options': {
imageType: uploadType,
},
},
square_icon: {
'ui:widget': 'upload',
'ui:options': {
imageType: uploadType,
},
},
favicon: {
'ui:widget': 'upload',
'ui:options': {
acceptType: ',image/x-icon,image/vnd.microsoft.icon',
imageType: uploadType,
},
},
};
const handleOnChange = (data) => {
setFormData(data);
};
const onSubmit = () => {
const params = {
logo: formData.logo.value,
mobile_logo: formData.mobile_logo.value,
square_icon: formData.square_icon.value,
favicon: formData.favicon.value,
};
brandSetting(params)
.then((res) => {
console.log(res);
update(params);
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
console.log(err);
if (err.key) {
formData[err.key].isInvalid = true;
formData[err.key].errorMsg = err.value;
setFormData({ ...formData });
}
});
};
const getBrandData = async () => {
const res = await getBrandSetting();
if (res) {
formData.logo.value = res.logo;
formData.mobile_logo.value = res.mobile_logo;
formData.square_icon.value = res.square_icon;
formData.favicon.value = res.favicon;
setFormData({ ...formData });
}
};
useEffect(() => {
getBrandData();
}, []);
return (
<div>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</div>
);
};
export default memo(Index);

View File

@ -1,11 +1,12 @@
import React, { FC, useEffect, useState } from 'react'; import React, { FC, useEffect, useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import { useToast } from '@/hooks'; import { useToast } from '@/hooks';
import { siteInfoStore } from '@/stores'; import { siteInfoStore } from '@/stores';
import { useGeneralSetting, updateGeneralSetting } from '@/services'; import { useGeneralSetting, updateGeneralSetting } from '@/services';
import Pattern from '@/common/pattern';
import '../index.scss'; import '../index.scss';
@ -17,91 +18,77 @@ const General: FC = () => {
const updateSiteInfo = siteInfoStore((state) => state.update); const updateSiteInfo = siteInfoStore((state) => state.update);
const { data: setting } = useGeneralSetting(); const { data: setting } = useGeneralSetting();
const [formData, setFormData] = useState<Type.FormDataType>({ const schema: JSONSchema = {
name: { title: t('page_title'),
value: '', required: ['name', 'site_url', 'contact_email'],
isInvalid: false, properties: {
errorMsg: '', name: {
type: 'string',
title: t('name.label'),
description: t('name.text'),
},
site_url: {
type: 'string',
title: t('site_url.label'),
description: t('site_url.text'),
},
short_description: {
type: 'string',
title: t('short_description.label'),
description: t('short_description.text'),
},
description: {
type: 'string',
title: t('description.label'),
description: t('description.text'),
},
contact_email: {
type: 'string',
title: t('contact_email.label'),
description: t('contact_email.text'),
},
}, },
};
const uiSchema: UISchema = {
site_url: { site_url: {
value: '', 'ui:options': {
isInvalid: false, validator: (value) => {
errorMsg: '', let url: URL | undefined;
}, try {
short_description: { url = new URL(value);
value: '', } catch (ex) {
isInvalid: false, return t('site_url.validate');
errorMsg: '', }
}, if (
description: { !url ||
value: '', /^https?:$/.test(url.protocol) === false ||
isInvalid: false, url.pathname !== '/' ||
errorMsg: '', url.search !== '' ||
url.hash !== ''
) {
return t('site_url.validate');
}
return true;
},
},
}, },
contact_email: { contact_email: {
value: '', 'ui:options': {
isInvalid: false, validator: (value) => {
errorMsg: '', if (!Pattern.email.test(value)) {
return t('contact_email.validate');
}
return true;
},
},
}, },
});
const checkValidated = (): boolean => {
let ret = true;
const { name, site_url, contact_email } = formData;
if (!name.value) {
ret = false;
formData.name = {
value: '',
isInvalid: true,
errorMsg: t('name.msg'),
};
}
if (!site_url.value) {
ret = false;
formData.site_url = {
value: '',
isInvalid: true,
errorMsg: t('site_url.msg'),
};
} else if (!/^(https?):\/\/([\w.]+\/?)\S*$/.test(site_url.value)) {
ret = false;
formData.site_url = {
value: formData.site_url.value,
isInvalid: true,
errorMsg: t('site_url.validate'),
};
}
if (!contact_email.value) {
ret = false;
formData.contact_email = {
value: '',
isInvalid: true,
errorMsg: t('contact_email.msg'),
};
} else if (
!/^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(
contact_email.value,
)
) {
ret = false;
formData.contact_email = {
value: formData.contact_email.value,
isInvalid: true,
errorMsg: t('contact_email.validate'),
};
}
setFormData({
...formData,
});
return ret;
}; };
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => { const onSubmit = (evt) => {
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
if (checkValidated() === false) {
return;
}
const reqParams: Type.AdminSettingsGeneral = { const reqParams: Type.AdminSettingsGeneral = {
name: formData.name.value, name: formData.name.value,
description: formData.description.value, description: formData.description.value,
@ -126,19 +113,7 @@ const General: FC = () => {
setFormData({ ...formData }); setFormData({ ...formData });
}); });
}; };
const onFieldChange = (fieldName, fieldValue) => {
if (!formData[fieldName]) {
return;
}
const fieldData: Type.FormDataType = {
[fieldName]: {
value: fieldValue,
isInvalid: false,
errorMsg: '',
},
};
setFormData({ ...formData, ...fieldData });
};
useEffect(() => { useEffect(() => {
if (!setting) { if (!setting) {
return; return;
@ -149,87 +124,21 @@ const General: FC = () => {
}); });
setFormData({ ...formData, ...formMeta }); setFormData({ ...formData, ...formMeta });
}, [setting]); }, [setting]);
const handleOnChange = (data) => {
setFormData(data);
};
return ( return (
<> <>
<h3 className="mb-4">{t('page_title')}</h3> <h3 className="mb-4">{t('page_title')}</h3>
<Form noValidate onSubmit={onSubmit}> <SchemaForm
<Form.Group controlId="siteName" className="mb-3"> schema={schema}
<Form.Label>{t('name.label')}</Form.Label> formData={formData}
<Form.Control onSubmit={onSubmit}
required uiSchema={uiSchema}
type="text" onChange={handleOnChange}
value={formData.name.value} />
isInvalid={formData.name.isInvalid}
onChange={(evt) => onFieldChange('name', evt.target.value)}
/>
<Form.Text as="div">{t('name.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="siteUrl" className="mb-3">
<Form.Label>{t('site_url.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.site_url.value}
isInvalid={formData.site_url.isInvalid}
onChange={(evt) => onFieldChange('site_url', evt.target.value)}
/>
<Form.Text as="div">{t('site_url.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.site_url.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="shortDescription" className="mb-3">
<Form.Label>{t('short_description.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.short_description.value}
isInvalid={formData.short_description.isInvalid}
onChange={(evt) =>
onFieldChange('short_description', evt.target.value)
}
/>
<Form.Text as="div">{t('short_description.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.short_description.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="description" className="mb-3">
<Form.Label>{t('description.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.description.value}
isInvalid={formData.description.isInvalid}
onChange={(evt) => onFieldChange('description', evt.target.value)}
/>
<Form.Text as="div">{t('description.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.description.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="contact_email" className="mb-3">
<Form.Label>{t('contact_email.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.contact_email.value}
isInvalid={formData.contact_email.isInvalid}
onChange={(evt) => onFieldChange('contact_email', evt.target.value)}
/>
<Form.Text as="div">{t('contact_email.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.contact_email.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Button variant="primary" type="submit">
{t('save', { keyPrefix: 'btns' })}
</Button>
</Form>
</> </>
); );
}; };

View File

@ -1,6 +1,5 @@
import React, { FC, FormEvent, useEffect, useState } from 'react'; import { FC, FormEvent, useEffect, useState } from 'react';
import { Form, Button, Image, Stack } from 'react-bootstrap'; import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { useToast } from '@/hooks'; import { useToast } from '@/hooks';
import { import {
@ -9,10 +8,9 @@ import {
AdminSettingsInterface, AdminSettingsInterface,
} from '@/common/interface'; } from '@/common/interface';
import { interfaceStore } from '@/stores'; import { interfaceStore } from '@/stores';
import { UploadImg } from '@/components'; import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { TIMEZONES, DEFAULT_TIMEZONE } from '@/common/constants'; import { DEFAULT_TIMEZONE } from '@/common/constants';
import { import {
uploadAvatar,
updateInterfaceSetting, updateInterfaceSetting,
useInterfaceSetting, useInterfaceSetting,
useThemeOptions, useThemeOptions,
@ -33,12 +31,32 @@ const Interface: FC = () => {
const [langs, setLangs] = useState<LangsType[]>(); const [langs, setLangs] = useState<LangsType[]>();
const { data: setting } = useInterfaceSetting(); const { data: setting } = useInterfaceSetting();
const [formData, setFormData] = useState<FormDataType>({ const schema: JSONSchema = {
logo: { title: t('page_title'),
value: setting?.logo || storeInterface.logo, properties: {
isInvalid: false, theme: {
errorMsg: '', type: 'string',
title: t('theme.label'),
description: t('theme.text'),
enum: themes?.map((theme) => theme.value) || [],
enumNames: themes?.map((theme) => theme.label) || [],
},
language: {
type: 'string',
title: t('language.label'),
description: t('language.text'),
enum: langs?.map((lang) => lang.value),
enumNames: langs?.map((lang) => lang.label),
},
time_zone: {
type: 'string',
title: t('time_zone.label'),
description: t('time_zone.text'),
},
}, },
};
const [formData, setFormData] = useState<FormDataType>({
theme: { theme: {
value: setting?.theme || storeInterface.theme, value: setting?.theme || storeInterface.theme,
isInvalid: false, isInvalid: false,
@ -55,6 +73,31 @@ const Interface: FC = () => {
errorMsg: '', errorMsg: '',
}, },
}); });
// const onChange = (fieldName, fieldValue) => {
// if (!formData[fieldName]) {
// return;
// }
// const fieldData: FormDataType = {
// [fieldName]: {
// value: fieldValue,
// isInvalid: false,
// errorMsg: '',
// },
// };
// setFormData({ ...formData, ...fieldData });
// };
const uiSchema: UISchema = {
theme: {
'ui:widget': 'select',
},
language: {
'ui:widget': 'select',
},
time_zone: {
'ui:widget': 'timezone',
},
};
const getLangs = async () => { const getLangs = async () => {
const res: LangsType[] = await loadLanguageOptions(true); const res: LangsType[] = await loadLanguageOptions(true);
setLangs(res); setLangs(res);
@ -103,7 +146,6 @@ const Interface: FC = () => {
return; return;
} }
const reqParams: AdminSettingsInterface = { const reqParams: AdminSettingsInterface = {
logo: formData.logo.value,
theme: formData.theme.value, theme: formData.theme.value,
language: formData.language.value, language: formData.language.value,
time_zone: formData.time_zone.value, time_zone: formData.time_zone.value,
@ -111,13 +153,13 @@ const Interface: FC = () => {
updateInterfaceSetting(reqParams) updateInterfaceSetting(reqParams)
.then(() => { .then(() => {
interfaceStore.getState().update(reqParams);
setupAppLanguage();
setupAppTimeZone();
Toast.onShow({ Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }), msg: t('update', { keyPrefix: 'toast' }),
variant: 'success', variant: 'success',
}); });
interfaceStore.getState().update(reqParams);
setupAppLanguage();
setupAppTimeZone();
}) })
.catch((err) => { .catch((err) => {
if (err.isError && err.key) { if (err.isError && err.key) {
@ -127,34 +169,22 @@ const Interface: FC = () => {
setFormData({ ...formData }); setFormData({ ...formData });
}); });
}; };
const imgUpload = (file: any) => { // const imgUpload = (file: any) => {
return new Promise((resolve) => { // return new Promise((resolve) => {
uploadAvatar(file).then((res) => { // uploadAvatar(file).then((res) => {
setFormData({ // setFormData({
...formData, // ...formData,
logo: { // logo: {
value: res, // value: res,
isInvalid: false, // isInvalid: false,
errorMsg: '', // errorMsg: '',
}, // },
}); // });
resolve(true); // resolve(true);
}); // });
}); // });
}; // };
const onChange = (fieldName, fieldValue) => {
if (!formData[fieldName]) {
return;
}
const fieldData: FormDataType = {
[fieldName]: {
value: fieldValue,
isInvalid: false,
errorMsg: '',
},
};
setFormData({ ...formData, ...fieldData });
};
useEffect(() => { useEffect(() => {
if (setting) { if (setting) {
const formMeta = {}; const formMeta = {};
@ -167,10 +197,21 @@ const Interface: FC = () => {
useEffect(() => { useEffect(() => {
getLangs(); getLangs();
}, []); }, []);
const handleOnChange = (data) => {
setFormData(data);
};
return ( return (
<> <>
<h3 className="mb-4">{t('page_title')}</h3> <h3 className="mb-4">{t('page_title')}</h3>
<Form noValidate onSubmit={onSubmit}> <SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
{/* <Form noValidate onSubmit={onSubmit}>
<Form.Group controlId="logo" className="mb-3"> <Form.Group controlId="logo" className="mb-3">
<Form.Label>{t('logo.label')}</Form.Label> <Form.Label>{t('logo.label')}</Form.Label>
<Stack gap={2}> <Stack gap={2}>
@ -187,7 +228,7 @@ const Interface: FC = () => {
) : null} ) : null}
</div> </div>
<div className="d-inline-flex"> <div className="d-inline-flex">
<UploadImg type="logo" upload={imgUpload} /> <UploadImg type="logo" upload={imgUpload} className="mb-2" />
</div> </div>
</Stack> </Stack>
<Form.Text as="div" className="text-muted"> <Form.Text as="div" className="text-muted">
@ -282,7 +323,7 @@ const Interface: FC = () => {
<Button variant="primary" type="submit"> <Button variant="primary" type="submit">
{t('save', { keyPrefix: 'btns' })} {t('save', { keyPrefix: 'btns' })}
</Button> </Button>
</Form> </Form> */}
</> </>
); );
}; };

View File

@ -0,0 +1,107 @@
import React, { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import type * as Type from '@/common/interface';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useToast } from '@/hooks';
import { getLegalSetting, putLegalSetting } from '@/services';
import '../index.scss';
const Legal: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.legal',
});
const Toast = useToast();
const schema: JSONSchema = {
title: t('page_title'),
required: ['terms_of_service', 'privacy_policy'],
properties: {
terms_of_service: {
type: 'string',
title: t('terms_of_service.label'),
description: t('terms_of_service.text'),
},
privacy_policy: {
type: 'string',
title: t('privacy_policy.label'),
description: t('privacy_policy.text'),
},
},
};
const uiSchema: UISchema = {
terms_of_service: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
privacy_policy: {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
},
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsLegal = {
terms_of_service_original_text: formData.terms_of_service.value,
terms_of_service_parsed_text: marked.parse(
formData.terms_of_service.value,
),
privacy_policy_original_text: formData.privacy_policy.value,
privacy_policy_parsed_text: marked.parse(formData.privacy_policy.value),
};
putLegalSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError && err.key) {
formData[err.key].isInvalid = true;
formData[err.key].errorMsg = err.value;
}
setFormData({ ...formData });
});
};
useEffect(() => {
getLegalSetting().then((setting) => {
const formMeta = { ...formData };
formMeta.terms_of_service.value = setting.terms_of_service_original_text;
formMeta.privacy_policy.value = setting.privacy_policy_original_text;
setFormData(formMeta);
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
formData={formData}
onSubmit={onSubmit}
uiSchema={uiSchema}
onChange={handleOnChange}
/>
</>
);
};
export default Legal;

View File

@ -15,11 +15,7 @@ import {
import { ADMIN_LIST_STATUS } from '@/common/constants'; import { ADMIN_LIST_STATUS } from '@/common/constants';
import { useEditStatusModal, useReportModal } from '@/hooks'; import { useEditStatusModal, useReportModal } from '@/hooks';
import * as Type from '@/common/interface'; import * as Type from '@/common/interface';
import { import { useQuestionSearch, changeQuestionStatus } from '@/services';
useQuestionSearch,
changeQuestionStatus,
deleteQuestion,
} from '@/services';
import '../index.scss'; import '../index.scss';
@ -76,9 +72,7 @@ const Questions: FC = () => {
confirmBtnVariant: 'danger', confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }), confirmText: t('delete', { keyPrefix: 'btns' }),
onConfirm: () => { onConfirm: () => {
deleteQuestion({ changeQuestionStatus(id, 'deleted').then(() => {
id,
}).then(() => {
refreshList(); refreshList();
}); });
}, },

View File

@ -1,11 +1,12 @@
import React, { FC, useEffect, useState } from 'react'; import React, { FC, useEffect, useState } from 'react';
import { Form, Button, Stack } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import { useToast } from '@/hooks'; import { useToast } from '@/hooks';
import { useSmtpSetting, updateSmtpSetting } from '@/services'; import { useSmtpSetting, updateSmtpSetting } from '@/services';
import pattern from '@/common/pattern'; import pattern from '@/common/pattern';
import { SchemaForm, JSONSchema, UISchema } from '@/components';
import { initFormData } from '../../../components/SchemaForm/index';
const Smtp: FC = () => { const Smtp: FC = () => {
const { t } = useTranslation('translation', { const { t } = useTranslation('translation', {
@ -13,90 +14,100 @@ const Smtp: FC = () => {
}); });
const Toast = useToast(); const Toast = useToast();
const { data: setting } = useSmtpSetting(); const { data: setting } = useSmtpSetting();
const [formData, setFormData] = useState<Type.FormDataType>({ const schema: JSONSchema = {
from_email: { title: t('page_title'),
value: '', properties: {
isInvalid: false, from_email: {
errorMsg: '', type: 'string',
}, title: t('from_email.label'),
from_name: { description: t('from_email.text'),
value: '', },
isInvalid: false, from_name: {
errorMsg: '', type: 'string',
}, title: t('from_name.label'),
smtp_host: { description: t('from_name.text'),
value: '', },
isInvalid: false, smtp_host: {
errorMsg: '', type: 'string',
title: t('smtp_host.label'),
description: t('smtp_host.text'),
},
encryption: {
type: 'boolean',
title: t('encryption.label'),
description: t('encryption.text'),
enum: [true, false],
enumNames: ['SSL', ''],
},
smtp_port: {
type: 'string',
title: t('smtp_port.label'),
description: t('smtp_port.text'),
},
smtp_authentication: {
type: 'boolean',
title: t('smtp_authentication.label'),
enum: [true, false],
enumNames: [t('smtp_authentication.yes'), t('smtp_authentication.no')],
},
smtp_username: {
type: 'string',
title: t('smtp_username.label'),
description: t('smtp_username.text'),
},
smtp_password: {
type: 'string',
title: t('smtp_password.label'),
description: t('smtp_password.text'),
},
test_email_recipient: {
type: 'string',
title: t('test_email_recipient.label'),
description: t('test_email_recipient.text'),
},
}, },
};
const uiSchema: UISchema = {
encryption: { encryption: {
value: '', 'ui:widget': 'radio',
isInvalid: false,
errorMsg: '',
},
smtp_port: {
value: '',
isInvalid: false,
errorMsg: '',
},
smtp_authentication: {
value: 'yes',
isInvalid: false,
errorMsg: '',
},
smtp_username: {
value: '',
isInvalid: false,
errorMsg: '',
}, },
smtp_password: { smtp_password: {
value: '', 'ui:options': {
isInvalid: false, type: 'password',
errorMsg: '', },
},
smtp_authentication: {
'ui:widget': 'radio',
},
smtp_port: {
'ui:options': {
validator: (value) => {
if (!/^[1-9][0-9]*$/.test(value) || Number(value) > 65535) {
return t('smtp_port.msg');
}
return true;
},
},
}, },
test_email_recipient: { test_email_recipient: {
value: '', 'ui:options': {
isInvalid: false, validator: (value) => {
errorMsg: '', if (value && !pattern.email.test(value)) {
return t('test_email_recipient.msg');
}
return true;
},
},
}, },
});
const checkValidated = (): boolean => {
let ret = true;
const { smtp_port, test_email_recipient } = formData;
if (
!/^[1-9][0-9]*$/.test(smtp_port.value) ||
Number(smtp_port.value) > 65535
) {
ret = false;
formData.smtp_port = {
value: smtp_port.value,
isInvalid: true,
errorMsg: t('smtp_port.msg'),
};
}
if (
test_email_recipient.value &&
!pattern.email.test(test_email_recipient.value)
) {
ret = false;
formData.test_email_recipient = {
value: test_email_recipient.value,
isInvalid: true,
errorMsg: t('test_email_recipient.msg'),
};
}
setFormData({
...formData,
});
return ret;
}; };
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
);
const onSubmit = (evt) => { const onSubmit = (evt) => {
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();
if (!checkValidated()) {
return;
}
const reqParams: Type.AdminSettingsSmtp = { const reqParams: Type.AdminSettingsSmtp = {
from_email: formData.from_email.value, from_email: formData.from_email.value,
from_name: formData.from_name.value, from_name: formData.from_name.value,
@ -124,19 +135,7 @@ const Smtp: FC = () => {
setFormData({ ...formData }); setFormData({ ...formData });
}); });
}; };
const onFieldChange = (fieldName, fieldValue) => {
if (!formData[fieldName]) {
return;
}
const fieldData: Type.FormDataType = {
[fieldName]: {
value: fieldValue,
isInvalid: false,
errorMsg: '',
},
};
setFormData({ ...formData, ...fieldData });
};
useEffect(() => { useEffect(() => {
if (!setting) { if (!setting) {
return; return;
@ -152,166 +151,19 @@ const Smtp: FC = () => {
setFormData(formState); setFormData(formState);
}, [setting]); }, [setting]);
const handleOnChange = (data) => {
setFormData(data);
};
return ( return (
<> <>
<h3 className="mb-4">{t('page_title')}</h3> <h3 className="mb-4">{t('page_title')}</h3>
<Form noValidate onSubmit={onSubmit}> <SchemaForm
<Form.Group controlId="fromEmail" className="mb-3"> schema={schema}
<Form.Label>{t('from_email.label')}</Form.Label> uiSchema={uiSchema}
<Form.Control formData={formData}
required onChange={handleOnChange}
type="text" onSubmit={onSubmit}
value={formData.from_email.value} />
isInvalid={formData.from_email.isInvalid}
onChange={(evt) => onFieldChange('from_email', evt.target.value)}
/>
<Form.Text as="div">{t('from_email.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.from_email.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="fromName" className="mb-3">
<Form.Label>{t('from_name.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.from_name.value}
isInvalid={formData.from_name.isInvalid}
onChange={(evt) => onFieldChange('from_name', evt.target.value)}
/>
<Form.Text as="div">{t('from_name.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.from_name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="smtpHost" className="mb-3">
<Form.Label>{t('smtp_host.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.smtp_host.value}
isInvalid={formData.smtp_host.isInvalid}
onChange={(evt) => onFieldChange('smtp_host', evt.target.value)}
/>
<Form.Text as="div">{t('smtp_host.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.smtp_host.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="encryption" className="mb-3">
<Form.Label>{t('encryption.label')}</Form.Label>
<Stack direction="horizontal">
<Form.Check
inline
label={t('encryption.ssl')}
name="smtp_encryption"
id="smtp_encryption_ssl"
checked={formData.encryption.value === 'SSL'}
onChange={() => onFieldChange('encryption', 'SSL')}
type="radio"
/>
<Form.Check
inline
label={t('encryption.none')}
name="smtp_encryption"
id="smtp_encryption_none"
checked={!formData.encryption.value}
onChange={() => onFieldChange('encryption', '')}
type="radio"
/>
</Stack>
<Form.Text as="div">{t('encryption.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.encryption.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="smtpPort" className="mb-3">
<Form.Label>{t('smtp_port.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.smtp_port.value}
isInvalid={formData.smtp_port.isInvalid}
onChange={(evt) => onFieldChange('smtp_port', evt.target.value)}
/>
<Form.Text as="div">{t('smtp_port.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.smtp_port.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="smtpAuthentication" className="mb-3">
<Form.Label>{t('smtp_authentication.label')}</Form.Label>
<Stack direction="horizontal">
<Form.Check
inline
label={t('smtp_authentication.yes')}
name="smtp_authentication"
id="smtp_authentication_yes"
checked={!!formData.smtp_authentication.value}
onChange={() => onFieldChange('smtp_authentication', true)}
type="radio"
/>
<Form.Check
inline
label={t('smtp_authentication.no')}
name="smtp_authentication"
id="smtp_authentication_no"
checked={!formData.smtp_authentication.value}
onChange={() => onFieldChange('smtp_authentication', false)}
type="radio"
/>
</Stack>
<Form.Control.Feedback type="invalid">
{formData.smtp_authentication.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="smtpUsername" className="mb-3">
<Form.Label>{t('smtp_username.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.smtp_username.value}
isInvalid={formData.smtp_username.isInvalid}
onChange={(evt) => onFieldChange('smtp_username', evt.target.value)}
/>
<Form.Control.Feedback type="invalid">
{formData.smtp_username.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="smtpPassword" className="mb-3">
<Form.Label>{t('smtp_password.label')}</Form.Label>
<Form.Control
required
type="password"
value={formData.smtp_password.value}
isInvalid={formData.smtp_password.isInvalid}
onChange={(evt) => onFieldChange('smtp_password', evt.target.value)}
/>
<Form.Control.Feedback type="invalid">
{formData.smtp_password.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="testEmailRecipient" className="mb-3">
<Form.Label>{t('test_email_recipient.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.test_email_recipient.value}
isInvalid={formData.test_email_recipient.isInvalid}
onChange={(evt) =>
onFieldChange('test_email_recipient', evt.target.value)
}
/>
<Form.Text as="div">{t('test_email_recipient.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.test_email_recipient.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Button variant="primary" type="submit">
{t('save', { keyPrefix: 'btns' })}
</Button>
</Form>
</> </>
); );
}; };

View File

@ -0,0 +1,118 @@
import React, { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import type * as Type from '@/common/interface';
import { useToast } from '@/hooks';
import {
getRequireAndReservedTag,
postRequireAndReservedTag,
} from '@/services';
import '../index.scss';
const Legal: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.write',
});
const Toast = useToast();
// const updateSiteInfo = siteInfoStore((state) => state.update);
const schema: JSONSchema = {
title: t('page_title'),
properties: {
recommend_tags: {
type: 'string',
title: t('recommend_tags.label'),
description: t('recommend_tags.text'),
},
required_tag: {
type: 'boolean',
title: t('required_tag.label'),
description: t('required_tag.text'),
},
reserved_tags: {
type: 'string',
title: t('reserved_tags.label'),
description: t('reserved_tags.text'),
},
},
};
const uiSchema: UISchema = {
recommend_tags: {
'ui:widget': 'textarea',
'ui:options': {
rows: 5,
},
},
required_tag: {
'ui:widget': 'switch',
},
reserved_tags: {
'ui:widget': 'textarea',
'ui:options': {
rows: 5,
},
},
};
const [formData, setFormData] = useState(initFormData(schema));
const onSubmit = (evt) => {
evt.preventDefault();
evt.stopPropagation();
const reqParams: Type.AdminSettingsWrite = {
recommend_tags: formData.recommend_tags.value.trim().split('\n'),
required_tag: formData.required_tag.value,
reserved_tags: formData.reserved_tags.value.trim().split('\n'),
};
console.log(reqParams);
postRequireAndReservedTag(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError && err.key) {
formData[err.key].isInvalid = true;
formData[err.key].errorMsg = err.value;
}
setFormData({ ...formData });
});
};
const initData = () => {
getRequireAndReservedTag().then((res) => {
formData.recommend_tags.value = res.recommend_tags.join('\n');
formData.required_tag.value = res.required_tag;
formData.reserved_tags.value = res.reserved_tags.join('\n');
setFormData({ ...formData });
});
};
useEffect(() => {
initData();
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
formData={formData}
onSubmit={onSubmit}
uiSchema={uiSchema}
onChange={handleOnChange}
/>
</>
);
};
export default Legal;

View File

@ -1,25 +1,42 @@
import { FC } from 'react'; import { FC } from 'react';
import { Container, Row, Col } from 'react-bootstrap'; import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { AccordionNav, AdminHeader, PageTitle } from '@/components'; import { AccordionNav, PageTitle } from '@/components';
import { ADMIN_NAV_MENUS } from '@/common/constants'; import { ADMIN_NAV_MENUS } from '@/common/constants';
import './index.scss'; import './index.scss';
const Dashboard: FC = () => { const formPaths = [
'general',
'smtp',
'interface',
'branding',
'legal',
'write',
];
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const { pathname } = useLocation();
return ( return (
<> <>
<PageTitle title={t('admin')} /> <PageTitle title={t('admin')} />
<AdminHeader /> <div className="bg-light py-2">
<Container className="py-1">
<h6 className="mb-0 fw-bold lh-base">
{t('title', { keyPrefix: 'admin.admin_header' })}
</h6>
</Container>
</div>
<Container className="admin-container"> <Container className="admin-container">
<Row> <Row>
<Col lg={2}> <Col lg={2}>
<AccordionNav menus={ADMIN_NAV_MENUS} /> <AccordionNav menus={ADMIN_NAV_MENUS} path="/admin/" />
</Col> </Col>
<Col lg={10}> <Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}>
<Outlet /> <Outlet />
</Col> </Col>
</Row> </Row>
@ -28,4 +45,4 @@ const Dashboard: FC = () => {
); );
}; };
export default Dashboard; export default Index;

View File

@ -4,12 +4,13 @@ import { Helmet, HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr'; import { SWRConfig } from 'swr';
import { siteInfoStore, toastStore } from '@/stores'; import { siteInfoStore, toastStore, brandingStore } from '@/stores';
import { Header, Footer, Toast } from '@/components'; import { Header, Footer, Toast } from '@/components';
const Layout: FC = () => { const Layout: FC = () => {
const { msg: toastMsg, variant, clear: toastClear } = toastStore(); const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const { siteInfo } = siteInfoStore.getState(); const { siteInfo } = siteInfoStore.getState();
const { favicon } = brandingStore((state) => state.branding);
const closeToast = () => { const closeToast = () => {
toastClear(); toastClear();
}; };
@ -17,6 +18,7 @@ const Layout: FC = () => {
return ( return (
<HelmetProvider> <HelmetProvider>
<Helmet> <Helmet>
<link rel="icon" href={favicon || '/favicon.ico'} />
{siteInfo && <meta name="description" content={siteInfo.description} />} {siteInfo && <meta name="description" content={siteInfo.description} />}
</Helmet> </Helmet>
<SWRConfig <SWRConfig

View File

@ -0,0 +1,17 @@
import { FC } from 'react';
import { useLegalPrivacy } from '@/services';
const Index: FC = () => {
const { data: privacy } = useLegalPrivacy();
return (
<div
className="fmt fs-14"
dangerouslySetInnerHTML={{
__html: privacy?.privacy_policy_parsed_text || '',
}}
/>
);
};
export default Index;

View File

@ -0,0 +1,17 @@
import { FC } from 'react';
import { useLegalTos } from '@/services';
const Index: FC = () => {
const { data: tos } = useLegalTos();
return (
<div
className="fmt fs-14"
dangerouslySetInnerHTML={{
__html: tos?.terms_of_service_parsed_text || '',
}}
/>
);
};
export default Index;

View File

@ -0,0 +1,4 @@
.sub-container {
padding-top: 2rem;
padding-bottom: 2rem;
}

View File

@ -0,0 +1,25 @@
import { FC } from 'react';
import { Container, Row, Col } from 'react-bootstrap';
import { Outlet } from 'react-router-dom';
import { AccordionNav } from '@/components';
import { ADMIN_LEGAL_MENUS } from '@/common/constants';
import './index.scss';
const Index: FC = () => {
return (
<Container className="sub-container">
<Row>
<Col lg={2}>
<AccordionNav menus={ADMIN_LEGAL_MENUS} />
</Col>
<Col lg={6}>
<Outlet />
</Col>
</Row>
</Container>
);
};
export default Index;

View File

@ -123,7 +123,7 @@ const Ask = () => {
isInvalid: true, isInvalid: true,
errorMsg: t('form.fields.title.msg.empty'), errorMsg: t('form.fields.title.msg.empty'),
}; };
} else if ([...title.value].length > 150) { } else if (Array.from(title.value).length > 150) {
bol = false; bol = false;
formData.title = { formData.title = {
value: title.value, value: title.value,

View File

@ -93,14 +93,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
</div> </div>
<div className="m-n1"> <div className="m-n1">
{data?.tags?.map((item: any) => { {data?.tags?.map((item: any) => {
return ( return <Tag className="m-1" key={item.slug_name} data={item} />;
<Tag
className="m-1"
href={`/tags/${item.main_tag_slug_name || item.slug_name}`}
key={item.slug_name}>
{item.slug_name}
</Tag>
);
})} })}
</div> </div>
<article <article

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Container, Row, Col } from 'react-bootstrap'; import { Container, Row, Col } from 'react-bootstrap';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { Pagination, PageTitle } from '@/components'; import { Pagination, PageTitle, Labels } from '@/components';
import { loggedUserInfoStore } from '@/stores'; import { loggedUserInfoStore } from '@/stores';
import { scrollTop } from '@/utils'; import { scrollTop } from '@/utils';
import { usePageUsers } from '@/hooks'; import { usePageUsers } from '@/hooks';
@ -167,6 +167,7 @@ const Index = () => {
)} )}
</Col> </Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0"> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Labels className="mb-4" />
<RelatedQuestions id={question?.id || ''} /> <RelatedQuestions id={question?.id || ''} />
</Col> </Col>
</Row> </Row>

View File

@ -67,11 +67,7 @@ const Index: FC<Props> = ({ data }) => {
)} )}
{data.object?.tags?.map((item) => { {data.object?.tags?.map((item) => {
return ( return <Tag key={item.slug_name} className="me-1" data={item} />;
<Tag href={`/tags/${item.slug_name}`} className="me-1">
{item.slug_name}
</Tag>
);
})} })}
</ListGroupItem> </ListGroupItem>
); );

View File

@ -152,9 +152,17 @@ const TagIntroduction = () => {
<> <>
<div className="mb-3"> <div className="mb-3">
{t('synonyms.text')}{' '} {t('synonyms.text')}{' '}
<Tag className="me-2 mb-2" href="#"> <Tag
{tagName} className="me-2 mb-2"
</Tag> href="#"
data={{
slug_name: tagName || '',
main_tag_slug_name: '',
display_name: '',
recommend: false,
reserved: false,
}}
/>
</div> </div>
<TagSelector <TagSelector
value={synonymsTags} value={synonymsTags}
@ -170,9 +178,8 @@ const TagIntroduction = () => {
<Tag <Tag
key={item.tag_id} key={item.tag_id}
className="me-2 mb-2" className="me-2 mb-2"
href={`/tags/${item.slug_name}`}> data={item}
{item.slug_name} />
</Tag>
); );
}) })
) : ( ) : (

View File

@ -77,9 +77,8 @@ const Tags = () => {
className="mb-4"> className="mb-4">
<Card className="h-100"> <Card className="h-100">
<Card.Body className="d-flex flex-column align-items-start"> <Card.Body className="d-flex flex-column align-items-start">
<Tag className="mb-3" href={`/tags/${tag.slug_name}`}> <Tag className="mb-3" data={tag} />
{tag.slug_name}
</Tag>
<p className="fs-14 flex-fill text-break text-wrap text-truncate-4"> <p className="fs-14 flex-fill text-break text-wrap text-truncate-4">
{tag.original_text} {tag.original_text}
</p> </p>

View File

@ -1,16 +1,17 @@
import { FC, memo, useEffect } from 'react'; import { FC, memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { loggedUserInfoStore } from '@/stores'; import { loggedUserInfoStore } from '@/stores';
import { getQueryString } from '@/utils';
import { activateAccount } from '@/services'; import { activateAccount } from '@/services';
import { PageTitle } from '@/components'; import { PageTitle } from '@/components';
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const [searchParams] = useSearchParams();
const updateUser = loggedUserInfoStore((state) => state.update); const updateUser = loggedUserInfoStore((state) => state.update);
useEffect(() => { useEffect(() => {
const code = getQueryString('code'); const code = searchParams.get('code');
if (code) { if (code) {
activateAccount(encodeURIComponent(code)).then((res) => { activateAccount(encodeURIComponent(code)).then((res) => {

View File

@ -1,6 +1,6 @@
import React, { FormEvent, useState, useEffect } from 'react'; import React, { FormEvent, useState, useEffect } from 'react';
import { Container, Form, Button, Col } from 'react-bootstrap'; import { Container, Form, Button, Col } from 'react-bootstrap';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import type { import type {
@ -10,7 +10,7 @@ import type {
} from '@/common/interface'; } from '@/common/interface';
import { PageTitle, Unactivate } from '@/components'; import { PageTitle, Unactivate } from '@/components';
import { loggedUserInfoStore } from '@/stores'; import { loggedUserInfoStore } from '@/stores';
import { getQueryString, guard, floppyNavigation } from '@/utils'; import { guard, floppyNavigation } from '@/utils';
import { login, checkImgCode } from '@/services'; import { login, checkImgCode } from '@/services';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants'; import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
import { RouteAlias } from '@/router/alias'; import { RouteAlias } from '@/router/alias';
@ -20,6 +20,7 @@ import Storage from '@/utils/storage';
const Index: React.FC = () => { const Index: React.FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'login' }); const { t } = useTranslation('translation', { keyPrefix: 'login' });
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
const updateUser = loggedUserInfoStore((state) => state.update); const updateUser = loggedUserInfoStore((state) => state.update);
const storeUser = loggedUserInfoStore((state) => state.user); const storeUser = loggedUserInfoStore((state) => state.user);
@ -154,7 +155,7 @@ const Index: React.FC = () => {
}, [refresh]); }, [refresh]);
useEffect(() => { useEffect(() => {
const isInactive = getQueryString('status'); const isInactive = searchParams.get('status');
if ((storeUser.id && storeUser.mail_status === 2) || isInactive) { if ((storeUser.id && storeUser.mail_status === 2) || isInactive) {
setStep(2); setStep(2);

View File

@ -1,17 +1,16 @@
import React, { FormEvent, useState } from 'react'; import React, { FormEvent, useState } from 'react';
import { Container, Col, Form, Button } from 'react-bootstrap'; import { Container, Col, Form, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { loggedUserInfoStore } from '@/stores'; import { loggedUserInfoStore } from '@/stores';
import { getQueryString } from '@/utils';
import type { FormDataType } from '@/common/interface'; import type { FormDataType } from '@/common/interface';
import { replacementPassword } from '@/services'; import { replacementPassword } from '@/services';
import { PageTitle } from '@/components'; import { PageTitle } from '@/components';
const Index: React.FC = () => { const Index: React.FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'password_reset' }); const { t } = useTranslation('translation', { keyPrefix: 'password_reset' });
const [searchParams] = useSearchParams();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const clearUser = loggedUserInfoStore((state) => state.clear); const clearUser = loggedUserInfoStore((state) => state.clear);
const [formData, setFormData] = useState<FormDataType>({ const [formData, setFormData] = useState<FormDataType>({
@ -91,7 +90,7 @@ const Index: React.FC = () => {
if (checkValidated() === false) { if (checkValidated() === false) {
return; return;
} }
const code = getQueryString('code'); const code = searchParams.get('code');
if (!code) { if (!code) {
console.error('code is required'); console.error('code is required');
return; return;

View File

@ -46,14 +46,7 @@ const Index: FC<Props> = ({ visible, data }) => {
</div> </div>
<div> <div>
{item.question_info?.tags?.map((tag) => { {item.question_info?.tags?.map((tag) => {
return ( return <Tag key={tag.slug_name} className="me-1" data={tag} />;
<Tag
href={`/t/${tag.main_tag_slug_name || tag.slug_name}`}
key={tag.slug_name}
className="me-1">
{tag.slug_name}
</Tag>
);
})} })}
</div> </div>
</ListGroupItem> </ListGroupItem>

View File

@ -73,14 +73,7 @@ const Index: FC<Props> = ({ visible, tabName, data }) => {
</div> </div>
<div> <div>
{item.tags?.map((tag) => { {item.tags?.map((tag) => {
return ( return <Tag className="me-1" key={tag.slug_name} data={tag} />;
<Tag
href={`/t/${tag.main_tag_slug_name || tag.slug_name}`}
className="me-1"
key={tag.slug_name}>
{tag.slug_name}
</Tag>
);
})} })}
</div> </div>
</ListGroupItem> </ListGroupItem>

View File

@ -1,10 +1,10 @@
import React, { FormEvent, useState } from 'react'; import React, { FormEvent, MouseEvent, useState } from 'react';
import { Form, Button, Col } from 'react-bootstrap'; import { Form, Button, Col } from 'react-bootstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import type { FormDataType } from '@/common/interface'; import type { FormDataType } from '@/common/interface';
import { register } from '@/services'; import { register, useLegalTos, useLegalPrivacy } from '@/services';
import userStore from '@/stores/userInfo'; import userStore from '@/stores/userInfo';
interface Props { interface Props {
@ -82,6 +82,26 @@ const Index: React.FC<Props> = ({ callback }) => {
}); });
return bol; return bol;
}; };
const { data: tos } = useLegalTos();
const { data: privacy } = useLegalPrivacy();
const argumentClick = (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());
}
};
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
@ -185,7 +205,29 @@ const Index: React.FC<Props> = ({ callback }) => {
</Button> </Button>
</div> </div>
</Form> </Form>
<div className="text-center fs-14 mt-3">
<Trans i18nKey="login.agreements" ns="translation">
By registering, you agree to the
<Link
to="/privacy"
onClick={(evt) => {
argumentClick(evt, 'privacy');
}}
target="_blank">
privacy policy
</Link>
and
<Link
to="/tos"
onClick={(evt) => {
argumentClick(evt, 'tos');
}}
target="_blank">
terms of service
</Link>
.
</Trans>
</div>
<div className="text-center mt-5"> <div className="text-center mt-5">
<Trans i18nKey="login.info_login" ns="translation"> <Trans i18nKey="login.info_login" ns="translation">
Already have an account? <Link to="/users/login">Log in</Link> Already have an account? <Link to="/users/login">Log in</Link>

View File

@ -9,7 +9,7 @@ import type { FormDataType } from '@/common/interface';
import { UploadImg, Avatar } from '@/components'; import { UploadImg, Avatar } from '@/components';
import { loggedUserInfoStore } from '@/stores'; import { loggedUserInfoStore } from '@/stores';
import { useToast } from '@/hooks'; import { useToast } from '@/hooks';
import { modifyUserInfo, uploadAvatar, getLoggedUserInfo } from '@/services'; import { modifyUserInfo, getLoggedUserInfo } from '@/services';
const Index: React.FC = () => { const Index: React.FC = () => {
const { t } = useTranslation('translation', { const { t } = useTranslation('translation', {
@ -60,21 +60,16 @@ const Index: React.FC = () => {
setFormData({ ...formData, ...params }); setFormData({ ...formData, ...params });
}; };
const avatarUpload = (file: any) => { const avatarUpload = (path: string) => {
return new Promise((resolve) => { setFormData({
uploadAvatar(file).then((res) => { ...formData,
setFormData({ avatar: {
...formData, ...formData.avatar,
avatar: { type: 'custom',
...formData.avatar, custom: path,
type: 'custom', isInvalid: false,
custom: res, errorMsg: '',
isInvalid: false, },
errorMsg: '',
},
});
resolve(true);
});
}); });
}; };
@ -364,7 +359,11 @@ const Index: React.FC = () => {
className="me-3 rounded" className="me-3 rounded"
/> />
<div> <div>
<UploadImg type="avatar" upload={avatarUpload} /> <UploadImg
type="avatar"
uploadCallback={avatarUpload}
className="mb-2"
/>
<div> <div>
<Form.Text className="text-muted mt-0"> <Form.Text className="text-muted mt-0">
<Trans i18nKey="settings.profile.avatar.text"> <Trans i18nKey="settings.profile.avatar.text">

View File

@ -1,2 +1,4 @@
/// <reference types="react-scripts" /> /// <reference types="react-scripts" />
declare module '*.yaml'; declare module '*.yaml';
declare module '*.ico';

View File

@ -10,7 +10,7 @@ const routes: RouteObject[] = [];
const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => { const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
routeNodes.forEach((rn) => { routeNodes.forEach((rn) => {
if (rn.path === '/') { if (rn.page === 'pages/Layout') {
rn.element = <Layout />; rn.element = <Layout />;
rn.errorElement = <ErrorBoundary />; rn.errorElement = <ErrorBoundary />;
} else { } else {
@ -31,7 +31,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
const refLoader = rn.loader; const refLoader = rn.loader;
const refGuard = rn.guard; const refGuard = rn.guard;
rn.loader = async (args) => { rn.loader = async (args) => {
const gr = await refGuard(); const gr = await refGuard(args);
if (gr?.redirect && floppyNavigation.differentCurrent(gr.redirect)) { if (gr?.redirect && floppyNavigation.differentCurrent(gr.redirect)) {
return redirect(gr.redirect); return redirect(gr.redirect);
} }

View File

@ -1,4 +1,4 @@
import { RouteObject } from 'react-router-dom'; import { LoaderFunctionArgs, RouteObject } from 'react-router-dom';
import { guard } from '@/utils'; import { guard } from '@/utils';
import type { TGuardResult } from '@/utils/guard'; import type { TGuardResult } from '@/utils/guard';
@ -13,7 +13,7 @@ export interface RouteNode extends RouteObject {
* if guard returned the `TGuardResult` has `redirect` field, * if guard returned the `TGuardResult` has `redirect` field,
* then auto redirect route to the `redirect` target. * then auto redirect route to the `redirect` target.
*/ */
guard?: () => Promise<TGuardResult>; guard?: (args: LoaderFunctionArgs) => Promise<TGuardResult>;
} }
const routes: RouteNode[] = [ const routes: RouteNode[] = [
@ -31,7 +31,6 @@ const routes: RouteNode[] = [
}, },
{ {
path: 'questions', path: 'questions',
index: true,
page: 'pages/Questions', page: 'pages/Questions',
}, },
{ {
@ -251,6 +250,18 @@ const routes: RouteNode[] = [
path: 'smtp', path: 'smtp',
page: 'pages/Admin/Smtp', page: 'pages/Admin/Smtp',
}, },
{
path: 'branding',
page: 'pages/Admin/Branding',
},
{
path: 'legal',
page: 'pages/Admin/Legal',
},
{
path: 'write',
page: 'pages/Admin/Write',
},
], ],
}, },
{ {
@ -263,6 +274,25 @@ const routes: RouteNode[] = [
}, },
], ],
}, },
{
path: '/',
page: 'pages/Layout',
children: [
{
page: 'pages/Legal',
children: [
{
path: 'tos',
page: 'pages/Legal/Tos',
},
{
path: 'privacy',
page: 'pages/Legal/Privacy',
},
],
},
],
},
{ {
path: '/install', path: '/install',
page: 'pages/Install', page: 'pages/Install',

View File

@ -0,0 +1,17 @@
import useSWR from 'swr';
import * as Type from '@/common/interface';
import request from '@/utils/request';
export const useDashBoard = () => {
const apiUrl = `/answer/admin/api/dashboard`;
const { data, error } = useSWR<Type.AdminDashboard, Error>(
[apiUrl],
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
};
};

View File

@ -2,3 +2,5 @@ export * from './answer';
export * from './flag'; export * from './flag';
export * from './question'; export * from './question';
export * from './settings'; export * from './settings';
export * from './users';
export * from './dashboard';

View File

@ -4,24 +4,6 @@ import useSWR from 'swr';
import request from '@/utils/request'; import request from '@/utils/request';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
export const changeUserStatus = (params) => {
return request.put('/answer/admin/api/user/status', params);
};
export const useQueryUsers = (params) => {
const apiUrl = `/answer/admin/api/users/page?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Type.ListResult, Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const useQuestionSearch = (params: Type.AdminContentsReq) => { export const useQuestionSearch = (params: Type.AdminContentsReq) => {
const apiUrl = `/answer/admin/api/question/page?${qs.stringify(params)}`; const apiUrl = `/answer/admin/api/question/page?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Type.ListResult, Error>( const { data, error, mutate } = useSWR<Type.ListResult, Error>(

View File

@ -71,20 +71,33 @@ export const updateSmtpSetting = (params: Type.AdminSettingsSmtp) => {
return request.put(apiUrl, params); return request.put(apiUrl, params);
}; };
export const useDashBoard = () => {
const apiUrl = `/answer/admin/api/dashboard`;
const { data, error } = useSWR<Type.AdminDashboard, Error>(
[apiUrl],
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
};
};
export const getAdminLanguageOptions = () => { export const getAdminLanguageOptions = () => {
const apiUrl = `/answer/admin/api/language/options`; const apiUrl = `/answer/admin/api/language/options`;
return request.get<Type.LangsType[]>(apiUrl); return request.get<Type.LangsType[]>(apiUrl);
}; };
export const getBrandSetting = () => {
return request.get('/answer/admin/api/siteinfo/branding');
};
export const brandSetting = (params: Type.AdmingSettingBranding) => {
return request.put('/answer/admin/api/siteinfo/branding', params);
};
export const getRequireAndReservedTag = () => {
return request.get('/answer/admin/api/siteinfo/write');
};
export const postRequireAndReservedTag = (params) => {
return request.put('/answer/admin/api/siteinfo/write', params);
};
export const getLegalSetting = () => {
return request.get<Type.AdminSettingsLegal>(
'/answer/admin/api/siteinfo/legal',
);
};
export const putLegalSetting = (params: Type.AdminSettingsLegal) => {
return request.put('/answer/admin/api/siteinfo/legal', params);
};

View File

@ -0,0 +1,23 @@
import qs from 'qs';
import useSWR from 'swr';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export const changeUserStatus = (params) => {
return request.put('/answer/admin/api/user/status', params);
};
export const useQueryUsers = (params) => {
const apiUrl = `/answer/admin/api/users/page?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Type.ListResult, Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};

View File

@ -5,3 +5,4 @@ export * from './question';
export * from './search'; export * from './search';
export * from './tag'; export * from './tag';
export * from './settings'; export * from './settings';
export * from './legal';

View File

@ -0,0 +1,32 @@
import useSWR from 'swr';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export const useLegalTos = () => {
const apiUrl = '/answer/api/v1/siteinfo/legal?info_type=tos';
const { data, error, mutate } = useSWR<Type.AdminSettingsLegal, Error>(
[apiUrl],
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const useLegalPrivacy = () => {
const apiUrl = '/answer/api/v1/siteinfo/legal?info_type=privacy';
const { data, error, mutate } = useSWR<Type.AdminSettingsLegal, Error>(
[apiUrl],
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};

View File

@ -3,7 +3,7 @@ import qs from 'qs';
import request from '@/utils/request'; import request from '@/utils/request';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import { tryLoggedAndActicevated } from '@/utils/guard'; import { tryLoggedAndActivated } from '@/utils/guard';
export const useQueryNotifications = (params) => { export const useQueryNotifications = (params) => {
const apiUrl = `/answer/api/v1/notification/page?${qs.stringify(params, { const apiUrl = `/answer/api/v1/notification/page?${qs.stringify(params, {
@ -33,7 +33,7 @@ export const useQueryNotificationStatus = () => {
const apiUrl = '/answer/api/v1/notification/status'; const apiUrl = '/answer/api/v1/notification/status';
return useSWR<{ inbox: number; achievement: number }>( return useSWR<{ inbox: number; achievement: number }>(
tryLoggedAndActicevated().ok ? apiUrl : null, tryLoggedAndActivated().ok ? apiUrl : null,
request.instance.get, request.instance.get,
{ {
refreshInterval: 3000, refreshInterval: 3000,

View File

@ -2,7 +2,7 @@ import useSWR from 'swr';
import request from '@/utils/request'; import request from '@/utils/request';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import { tryLoggedAndActicevated } from '@/utils/guard'; import { tryLoggedAndActivated } from '@/utils/guard';
export const deleteTag = (id) => { export const deleteTag = (id) => {
return request.delete('/answer/api/v1/tag', { return request.delete('/answer/api/v1/tag', {
@ -24,7 +24,7 @@ export const saveSynonymsTags = (params) => {
export const useFollowingTags = () => { export const useFollowingTags = () => {
let apiUrl = ''; let apiUrl = '';
if (tryLoggedAndActicevated().ok) { if (tryLoggedAndActivated().ok) {
apiUrl = '/answer/api/v1/tags/following'; apiUrl = '/answer/api/v1/tags/following';
} }
const { data, error, mutate } = useSWR<any[]>(apiUrl, request.instance.get); const { data, error, mutate } = useSWR<any[]>(apiUrl, request.instance.get);

View File

@ -4,12 +4,13 @@ import useSWR from 'swr';
import request from '@/utils/request'; import request from '@/utils/request';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
export const uploadImage = (file) => { export const uploadImage = (params: { file: File; type: Type.UploadType }) => {
const form = new FormData(); const form = new FormData();
form.append('source', String(params.type));
form.append('file', file); form.append('file', params.file);
return request.post('/answer/api/v1/user/post/file', form); return request.post('/answer/api/v1/file', form);
}; };
export const useQueryQuestionByTitle = (title) => { export const useQueryQuestionByTitle = (title) => {
return useSWR<Record<string, any>>( return useSWR<Record<string, any>>(
title ? `/answer/api/v1/question/similar?title=${title}` : '', title ? `/answer/api/v1/question/similar?title=${title}` : '',
@ -127,10 +128,6 @@ export const modifyUserInfo = (params: Type.ModifyUserReq) => {
return request.put('/answer/api/v1/user/info', params); return request.put('/answer/api/v1/user/info', params);
}; };
export const uploadAvatar = (params: Type.AvatarUploadReq) => {
return request.post('/answer/api/v1/user/avatar/upload', params);
};
export const resetPassword = (params: Type.PasswordResetReq) => { export const resetPassword = (params: Type.PasswordResetReq) => {
return request.post('/answer/api/v1/user/password/reset', params); return request.post('/answer/api/v1/user/password/reset', params);
}; };

31
ui/src/stores/branding.ts Normal file
View File

@ -0,0 +1,31 @@
import create from 'zustand';
import { AdmingSettingBranding } from '@/common/interface';
import { DEFAULT_LANG } from '@/common/constants';
interface InterfaceType {
branding: AdmingSettingBranding;
update: (params: AdmingSettingBranding) => void;
}
const interfaceSetting = create<InterfaceType>((set) => ({
branding: {
logo: '',
square_icon: '',
mobile_logo: '',
favicon: '',
},
interface: {
theme: '',
language: DEFAULT_LANG,
time_zone: '',
},
update: (params) =>
set(() => {
return {
branding: params,
};
}),
}));
export default interfaceSetting;

View File

@ -3,6 +3,7 @@ import loggedUserInfoStore from './userInfo';
import globalStore from './global'; import globalStore from './global';
import siteInfoStore from './siteInfo'; import siteInfoStore from './siteInfo';
import interfaceStore from './interface'; import interfaceStore from './interface';
import brandingStore from './branding';
export { export {
toastStore, toastStore,
@ -10,4 +11,5 @@ export {
globalStore, globalStore,
siteInfoStore, siteInfoStore,
interfaceStore, interfaceStore,
brandingStore,
}; };

View File

@ -10,7 +10,6 @@ interface InterfaceType {
const interfaceSetting = create<InterfaceType>((set) => ({ const interfaceSetting = create<InterfaceType>((set) => ({
interface: { interface: {
logo: '',
theme: '', theme: '',
language: DEFAULT_LANG, language: DEFAULT_LANG,
time_zone: '', time_zone: '',

View File

@ -1,12 +1,5 @@
import i18next from 'i18next'; import i18next from 'i18next';
function getQueryString(name: string): string {
const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`);
const r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
return '';
}
function thousandthDivision(num) { function thousandthDivision(num) {
const reg = /\d{1,3}(?=(\d{3})+$)/g; const reg = /\d{1,3}(?=(\d{3})+$)/g;
return `${num}`.replace(reg, '$&,'); return `${num}`.replace(reg, '$&,');
@ -101,9 +94,63 @@ function escapeRemove(str) {
return arrEntities[t]; return arrEntities[t];
}); });
} }
function mixColor(color_1, color_2, weight) {
function d2h(d) {
return d.toString(16);
}
function h2d(h) {
return parseInt(h, 16);
}
weight = typeof weight !== 'undefined' ? weight : 50;
let color = '#';
for (let i = 0; i <= 5; i += 2) {
const v1 = h2d(color_1.substr(i, 2));
const v2 = h2d(color_2.substr(i, 2));
let val = d2h(Math.floor(v2 + (v1 - v2) * (weight / 100.0)));
while (val.length < 2) {
val = `0${val}`;
}
color += val;
}
return color;
}
function colorRgb(sColor) {
sColor = sColor.toLowerCase();
const reg = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/;
if (sColor && reg.test(sColor)) {
if (sColor.length === 4) {
let sColorNew = '#';
for (let i = 1; i < 4; i += 1) {
sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1));
}
sColor = sColorNew;
}
const sColorChange: number[] = [];
for (let i = 1; i < 7; i += 2) {
sColorChange.push(parseInt(`0x${sColor.slice(i, i + 2)}`, 16));
}
return sColorChange.join(',');
}
return sColor;
}
function labelStyle(color, hover) {
const textColor = mixColor('000000', color.replace('#', ''), 40);
const backgroundColor = mixColor('ffffff', color.replace('#', ''), 80);
const rgbBackgroundColor = colorRgb(backgroundColor);
return {
color: textColor,
backgroundColor: `rgba(${colorRgb(rgbBackgroundColor)},${hover ? 1 : 0.5})`,
};
}
export { export {
getQueryString,
thousandthDivision, thousandthDivision,
formatCount, formatCount,
scrollTop, scrollTop,
@ -111,4 +158,7 @@ export {
parseUserInfo, parseUserInfo,
formatUptime, formatUptime,
escapeRemove, escapeRemove,
mixColor,
colorRgb,
labelStyle,
}; };

View File

@ -1,5 +1,10 @@
import { getLoggedUserInfo, getAppSettings } from '@/services'; import { getLoggedUserInfo, getAppSettings } from '@/services';
import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores'; import {
loggedUserInfoStore,
siteInfoStore,
interfaceStore,
brandingStore,
} from '@/stores';
import { RouteAlias } from '@/router/alias'; import { RouteAlias } from '@/router/alias';
import Storage from '@/utils/storage'; import Storage from '@/utils/storage';
import { LOGGED_USER_STORAGE_KEY } from '@/common/constants'; import { LOGGED_USER_STORAGE_KEY } from '@/common/constants';
@ -182,9 +187,10 @@ export const tryNormalLogged = (canNavigate: boolean = false) => {
return false; return false;
}; };
export const tryLoggedAndActicevated = () => { export const tryLoggedAndActivated = () => {
const gr: TGuardResult = { ok: true }; const gr: TGuardResult = { ok: true };
const us = deriveLoginState(); const us = deriveLoginState();
if (!us.isLogged || !us.isActivated) { if (!us.isLogged || !us.isActivated) {
gr.ok = false; gr.ok = false;
} }
@ -196,6 +202,7 @@ export const initAppSettingsStore = async () => {
if (appSettings) { if (appSettings) {
siteInfoStore.getState().update(appSettings.general); siteInfoStore.getState().update(appSettings.general);
interfaceStore.getState().update(appSettings.interface); interfaceStore.getState().update(appSettings.interface);
brandingStore.getState().update(appSettings.branding);
} }
}; };

View File

@ -85,6 +85,7 @@ export const getCurrentLang = () => {
export const setupAppLanguage = async () => { export const setupAppLanguage = async () => {
const lang = getCurrentLang(); const lang = getCurrentLang();
console.log(lang);
if (!i18next.getDataByLanguage(lang)) { if (!i18next.getDataByLanguage(lang)) {
await addI18nResource(lang); await addI18nResource(lang);
} }

View File

@ -10,16 +10,8 @@ import { getCurrentLang } from '@/utils/localize';
import Storage from './storage'; import Storage from './storage';
import { floppyNavigation } from './floppyNavigation'; import { floppyNavigation } from './floppyNavigation';
const API = {
development: '',
production: '',
test: '',
};
const baseApiUrl = process.env.REACT_APP_API_URL || API[process.env.NODE_ENV];
const baseConfig = { const baseConfig = {
baseUrl: baseApiUrl, baseUrl: process.env.REACT_APP_API_URL || '',
timeout: 10000, timeout: 10000,
withCredentials: true, withCredentials: true,
}; };
@ -56,8 +48,8 @@ class Request {
return data; return data;
}, },
(error) => { (error) => {
const { status, data: respData, msg: respMsg } = error.response; const { status, data: respData, msg: respMsg } = error.response || {};
const { data, msg = '' } = respData; const { data = {}, msg = '' } = respData || {};
if (status === 400) { if (status === 400) {
// show error message // show error message
if (data instanceof Object && data.err_type) { if (data instanceof Object && data.err_type) {

View File

@ -7,7 +7,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"downlevelIteration": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "esnext", "module": "esnext",
@ -24,5 +23,5 @@
"@i18n/*": ["../i18n/*"] "@i18n/*": ["../i18n/*"]
} }
}, },
"include": ["src", "node_modules/@testing-library/jest-dom" ] "include": ["src", "node_modules/@testing-library/jest-dom", "scripts" ]
} }