mirror of https://gitee.com/answerdev/answer.git
Merge branch 'dev' into fix/search
This commit is contained in:
commit
7bdb6d2016
|
@ -17,6 +17,9 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:16 AS node-builder
|
||||
FROM amd64/node AS node-builder
|
||||
|
||||
LABEL maintainer="mingcheng<mc@sf.com>"
|
||||
|
||||
|
|
174
i18n/en_US.yaml
174
i18n/en_US.yaml
|
@ -235,7 +235,7 @@ ui:
|
|||
show_more: Show more
|
||||
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.
|
||||
end: You don't meet a community guideline.
|
||||
editor:
|
||||
|
@ -420,12 +420,12 @@ ui:
|
|||
btn_cancel: Cancel
|
||||
dates:
|
||||
long_date: MMM D
|
||||
long_date_with_year: 'MMM D, YYYY'
|
||||
long_date_with_time: 'MMM D, YYYY [at] HH:mm'
|
||||
long_date_with_year: "MMM D, YYYY"
|
||||
long_date_with_time: "MMM D, YYYY [at] HH:mm"
|
||||
now: now
|
||||
x_seconds_ago: '{{count}}s ago'
|
||||
x_minutes_ago: '{{count}}m ago'
|
||||
x_hours_ago: '{{count}}h ago'
|
||||
x_seconds_ago: "{{count}}s ago"
|
||||
x_minutes_ago: "{{count}}m ago"
|
||||
x_hours_ago: "{{count}}h ago"
|
||||
hour: hour
|
||||
day: day
|
||||
comment:
|
||||
|
@ -507,7 +507,7 @@ ui:
|
|||
add_btn: Add tag
|
||||
create_btn: Create new 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
|
||||
header:
|
||||
nav:
|
||||
|
@ -536,7 +536,7 @@ ui:
|
|||
first: >-
|
||||
You're almost done! We sent an activation mail to <bold>{{mail}}</bold>.
|
||||
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: >-
|
||||
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.
|
||||
|
@ -548,6 +548,7 @@ ui:
|
|||
page_title: Welcome to Answer
|
||||
info_sign: Don't have an account? <1>Sign up</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?
|
||||
name:
|
||||
label: Name
|
||||
|
@ -634,15 +635,15 @@ ui:
|
|||
label: About Me (optional)
|
||||
website:
|
||||
label: Website (optional)
|
||||
placeholder: 'https://example.com'
|
||||
placeholder: "https://example.com"
|
||||
msg: Website incorrect format
|
||||
location:
|
||||
label: Location (optional)
|
||||
placeholder: 'City, Country'
|
||||
placeholder: "City, Country"
|
||||
notification:
|
||||
email:
|
||||
label: Email Notifications
|
||||
radio: 'Answers to your questions, comments, and more'
|
||||
radio: "Answers to your questions, comments, and more"
|
||||
account:
|
||||
change_email_btn: Change email
|
||||
change_pass_btn: Change password
|
||||
|
@ -732,7 +733,7 @@ ui:
|
|||
options: Options
|
||||
follow: Follow
|
||||
following: Following
|
||||
counts: '{{count}} Results'
|
||||
counts: "{{count}} Results"
|
||||
more: More
|
||||
sort_btns:
|
||||
relevance: Relevance
|
||||
|
@ -742,12 +743,12 @@ ui:
|
|||
more: More
|
||||
tips:
|
||||
title: Advanced Search Tips
|
||||
tag: '<1>[tag]</1> search withing a tag'
|
||||
user: '<1>user:username</1> search by author'
|
||||
answer: '<1>answers:0</1> unanswered questions'
|
||||
score: '<1>score:3</1> posts with a 3+ score'
|
||||
question: '<1>is:question</1> search questions'
|
||||
is_answer: '<1>is:answer</1> search answers'
|
||||
tag: "<1>[tag]</1> search withing a tag"
|
||||
user: "<1>user:username</1> search by author"
|
||||
answer: "<1>answers:0</1> unanswered questions"
|
||||
score: "<1>score:3</1> posts with a 3+ score"
|
||||
question: "<1>is:question</1> search questions"
|
||||
is_answer: "<1>is:answer</1> search answers"
|
||||
empty: We couldn't find anything. <br /> Try different or less specific keywords.
|
||||
share:
|
||||
name: Share
|
||||
|
@ -777,8 +778,8 @@ ui:
|
|||
follow_tag_tip: Follow tags to curate your list of questions.
|
||||
hot_questions: Hot Questions
|
||||
all_questions: All Questions
|
||||
x_questions: '{{ count }} Questions'
|
||||
x_answers: '{{ count }} answers'
|
||||
x_questions: "{{ count }} Questions"
|
||||
x_answers: "{{ count }} answers"
|
||||
questions: Questions
|
||||
answers: Answers
|
||||
newest: Newest
|
||||
|
@ -805,12 +806,12 @@ ui:
|
|||
newest: Newest
|
||||
score: Score
|
||||
edit_profile: Edit Profile
|
||||
visited_x_days: 'Visited {{ count }} days'
|
||||
visited_x_days: "Visited {{ count }} days"
|
||||
viewed: Viewed
|
||||
joined: Joined
|
||||
last_login: Seen
|
||||
about_me: About Me
|
||||
about_me_empty: '// Hello, World !'
|
||||
about_me_empty: "// Hello, World !"
|
||||
top_answers: Top Answers
|
||||
top_questions: Top Questions
|
||||
stats: Stats
|
||||
|
@ -845,7 +846,7 @@ ui:
|
|||
msg: Password cannot be empty.
|
||||
db_host:
|
||||
label: Database Host
|
||||
placeholder: 'db:3306'
|
||||
placeholder: "db:3306"
|
||||
msg: Database Host cannot be empty.
|
||||
db_name:
|
||||
label: Database Name
|
||||
|
@ -861,7 +862,7 @@ ui:
|
|||
description: >-
|
||||
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.
|
||||
info: 'After you’ve done that, click “Next” button.'
|
||||
info: "After you’ve done that, click “Next” button."
|
||||
site_information: Site Information
|
||||
admin_account: Admin Account
|
||||
site_name:
|
||||
|
@ -898,7 +899,7 @@ ui:
|
|||
ready_description: >-
|
||||
If you ever feel like changing more settings, visit <1>admin section</1>;
|
||||
find it in the site menu.
|
||||
good_luck: 'Have fun, and good luck!'
|
||||
good_luck: "Have fun, and good luck!"
|
||||
warn_title: Warning
|
||||
warn_description: >-
|
||||
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 host’s database server is down.
|
||||
|
||||
page_404:
|
||||
description: 'Unfortunately, this page doesn''t exist.'
|
||||
description: "Unfortunately, this page doesn't exist."
|
||||
back_home: Back to homepage
|
||||
page_50X:
|
||||
description: The server encountered an error and could not complete your request.
|
||||
back_home: Back to homepage
|
||||
page_maintenance:
|
||||
description: 'We are under maintenance, we’ll be back soon.'
|
||||
description: "We are under maintenance, we’ll 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_header:
|
||||
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:
|
||||
title: Dashboard
|
||||
welcome: Welcome to Answer Admin!
|
||||
site_statistics: Site Statistics
|
||||
questions: 'Questions:'
|
||||
answers: 'Answers:'
|
||||
comments: 'Comments:'
|
||||
votes: 'Votes:'
|
||||
active_users: 'Active users:'
|
||||
flags: 'Flags:'
|
||||
questions: "Questions:"
|
||||
answers: "Answers:"
|
||||
comments: "Comments:"
|
||||
votes: "Votes:"
|
||||
active_users: "Active users:"
|
||||
flags: "Flags:"
|
||||
site_health_status: Site Health Status
|
||||
version: 'Version:'
|
||||
https: 'HTTPS:'
|
||||
uploading_files: 'Uploading files:'
|
||||
smtp: 'SMTP:'
|
||||
timezone: 'Timezone:'
|
||||
version: "Version:"
|
||||
https: "HTTPS:"
|
||||
uploading_files: "Uploading files:"
|
||||
smtp: "SMTP:"
|
||||
timezone: "Timezone:"
|
||||
system_info: System Info
|
||||
storage_used: 'Storage used:'
|
||||
uptime: 'Uptime:'
|
||||
storage_used: "Storage used:"
|
||||
uptime: "Uptime:"
|
||||
answer_links: Answer Links
|
||||
documents: Documents
|
||||
feedback: Feedback
|
||||
|
@ -961,8 +967,8 @@ ui:
|
|||
update_to: Update to
|
||||
latest: Latest
|
||||
check_failed: Check failed
|
||||
'yes': 'Yes'
|
||||
'no': 'No'
|
||||
"yes": "Yes"
|
||||
"no": "No"
|
||||
not_allowed: Not allowed
|
||||
allowed: Allowed
|
||||
enabled: Enabled
|
||||
|
@ -984,7 +990,7 @@ ui:
|
|||
suspended_name: suspended
|
||||
suspended_description: A suspended user can't log in.
|
||||
deleted_name: deleted
|
||||
deleted_description: 'Delete profile, authentication associations.'
|
||||
deleted_description: "Delete profile, authentication associations."
|
||||
inactive_name: inactive
|
||||
inactive_description: An inactive user must re-validate their email.
|
||||
confirm_title: Delete this user
|
||||
|
@ -993,11 +999,11 @@ ui:
|
|||
msg:
|
||||
empty: Please select a reason.
|
||||
status_modal:
|
||||
title: 'Change {{ type }} status to...'
|
||||
title: "Change {{ type }} status to..."
|
||||
normal_name: normal
|
||||
normal_description: A normal post available to everyone.
|
||||
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_description: All reputation gained and lost will be restored.
|
||||
btn_cancel: Cancel
|
||||
|
@ -1020,7 +1026,7 @@ ui:
|
|||
deleted: Deleted
|
||||
normal: Normal
|
||||
filter:
|
||||
placeholder: 'Filter by name, user:id'
|
||||
placeholder: "Filter by name, user:id"
|
||||
questions:
|
||||
page_title: Questions
|
||||
normal: Normal
|
||||
|
@ -1034,7 +1040,7 @@ ui:
|
|||
action: Action
|
||||
change: Change
|
||||
filter:
|
||||
placeholder: 'Filter by title, question:id'
|
||||
placeholder: "Filter by title, question:id"
|
||||
answers:
|
||||
page_title: Answers
|
||||
normal: Normal
|
||||
|
@ -1046,13 +1052,13 @@ ui:
|
|||
action: Action
|
||||
change: Change
|
||||
filter:
|
||||
placeholder: 'Filter by title, answer:id'
|
||||
placeholder: "Filter by title, answer:id"
|
||||
general:
|
||||
page_title: General
|
||||
name:
|
||||
label: Site Name
|
||||
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:
|
||||
label: Site URL
|
||||
msg: Site url cannot be empty.
|
||||
|
@ -1061,11 +1067,11 @@ ui:
|
|||
short_description:
|
||||
label: Short Site Description (optional)
|
||||
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:
|
||||
label: Site Description (optional)
|
||||
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:
|
||||
label: Contact Email
|
||||
msg: Contact email cannot be empty.
|
||||
|
@ -1126,5 +1132,45 @@ ui:
|
|||
smtp_authentication:
|
||||
label: SMTP Authentication
|
||||
msg: SMTP authentication cannot be empty.
|
||||
'yes': 'Yes'
|
||||
'no': 'No'
|
||||
"yes": "Yes"
|
||||
"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."
|
||||
|
|
|
@ -759,20 +759,20 @@ ui:
|
|||
page_50X:
|
||||
description: 服务器遇到了一个错误,无法完成你的请求。
|
||||
back_home: 回到主页
|
||||
nav_menus:
|
||||
dashboard: 后台管理
|
||||
contents: 内容管理
|
||||
questions: 问题
|
||||
answers: 回答
|
||||
users: 用户管理
|
||||
flags: 举报管理
|
||||
settings: 站点设置
|
||||
general: 一般
|
||||
interface: 界面
|
||||
smtp: SMTP
|
||||
admin:
|
||||
admin_header:
|
||||
title: 后台管理
|
||||
nav_menus:
|
||||
dashboard: 后台管理
|
||||
contents: 内容管理
|
||||
questions: 问题
|
||||
answers: 回答
|
||||
users: 用户管理
|
||||
flags: 举报管理
|
||||
settings: 站点设置
|
||||
general: 一般
|
||||
interface: 界面
|
||||
smtp: SMTP
|
||||
dashboard:
|
||||
title: 后台管理
|
||||
welcome: 欢迎来到 Answer 后台管理!
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
REACT_APP_API_URL=http://10.0.10.98:2060
|
||||
PUBLIC_URL
|
||||
REACT_APP_API_URL = http://127.0.0.1
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
REACT_APP_API_URL=/
|
||||
REACT_APP_PUBLIC_PATH=/
|
||||
REACT_APP_VERSION=
|
||||
PUBLIC_URL = /
|
||||
REACT_APP_API_URL = /
|
||||
|
|
|
@ -13,10 +13,7 @@
|
|||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env*.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
strict-peer-dependencies = true
|
||||
auto-install-peers = true
|
|
@ -30,7 +30,7 @@ clone the repo locally and run following command in your terminal:
|
|||
$ git clone git@github.com:answerdev/answer.git answer
|
||||
$ cd answer/ui
|
||||
$ pnpm install
|
||||
$ pnpm run start
|
||||
$ pnpm start
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
- `pnpm run start` run Answer web locally.
|
||||
- `pnpm run build:dev` build code for environment `dev`
|
||||
- `pnpm run build:test` build code for environment `test`
|
||||
- `pnpm run build:prod` build code for environment `prod`
|
||||
- `pnpm run build` build Answer for production
|
||||
- `pnpm run lint` lint and fix the code style
|
||||
- `pnpm run cz` run `git commit` by `commitizen`
|
||||
|
||||
|
||||
## 🖥 Environment Support
|
||||
|
||||
|
|
|
@ -8,12 +8,8 @@ const i18nPath = path.resolve(__dirname, "../i18n");
|
|||
|
||||
module.exports = {
|
||||
webpack: function(config, env) {
|
||||
if (env === "production") {
|
||||
config.output.publicPath = process.env.REACT_APP_PUBLIC_PATH;
|
||||
}
|
||||
|
||||
addWebpackAlias({
|
||||
["@"]: path.resolve(__dirname, "src"),
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@i18n": i18nPath
|
||||
})(config);
|
||||
|
||||
|
@ -34,16 +30,16 @@ module.exports = {
|
|||
return function(proxy, allowedHost) {
|
||||
const config = configFunction(proxy, allowedHost);
|
||||
config.proxy = {
|
||||
"/answer": {
|
||||
target: "http://10.0.10.98:2060",
|
||||
'/answer': {
|
||||
target: process.env.REACT_APP_API_URL,
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
secure: false,
|
||||
},
|
||||
"/installation": {
|
||||
target: "http://10.0.10.98:2060",
|
||||
'/installation': {
|
||||
target: process.env.REACT_APP_API_URL,
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
secure: false,
|
||||
},
|
||||
};
|
||||
return config;
|
||||
};
|
||||
|
|
|
@ -5,26 +5,13 @@
|
|||
"homepage": "/",
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build:dev": "env-cmd -f .env.development 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",
|
||||
"build": "react-app-rewired build",
|
||||
"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}\"",
|
||||
"prepare": "cd .. && husky install",
|
||||
"preinstall": "node ./scripts/preinstall.js"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "ui/node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"ahooks": "^3.7.0",
|
||||
"axios": "^0.27.2",
|
||||
"bootstrap": "^5.2.0",
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
|
@ -32,11 +19,7 @@
|
|||
"codemirror": "5.65.0",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.5",
|
||||
"highlight.js": "^11.6.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",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^4.0.19",
|
||||
|
@ -54,13 +37,10 @@
|
|||
"zustand": "^4.1.1"
|
||||
},
|
||||
"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/config-conventional": "^17.0.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/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
|
@ -74,11 +54,7 @@
|
|||
"@types/react-helmet": "^6.1.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.33.0",
|
||||
"commitizen": "^4.2.5",
|
||||
"conventional-changelog-cli": "^2.2.2",
|
||||
"customize-cra": "^1.0.0",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
|
@ -95,13 +71,10 @@
|
|||
"lint-staged": "^13.0.3",
|
||||
"postcss": "^8.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"purgecss-webpack-plugin": "^4.1.3",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"sass": "^1.54.4",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"typescript": "*",
|
||||
"web-vitals": "^2.1.4",
|
||||
"typescript": "^4.8.3",
|
||||
"yaml-loader": "^0.8.0"
|
||||
},
|
||||
"packageManager": "pnpm@7.9.5",
|
||||
|
@ -110,4 +83,4 @@
|
|||
"pnpm": ">=7"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -2,7 +2,6 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!-- <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />-->
|
||||
|
@ -24,7 +23,34 @@
|
|||
</head>
|
||||
<body>
|
||||
<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.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
|
|
@ -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.
|
||||
if (!/pnpm/.test(process.env.npm_execpath)) {
|
||||
console.warn(`\u001b[33mThis repository requires using pnpm as the package manager for scripts to work properly.\u001b[39m\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.warn(
|
||||
`\u001b[33mThis repository requires using pnpm as the package manager for scripts to work properly.\u001b[39m\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ export const ADMIN_NAV_MENUS = [
|
|||
},
|
||||
{
|
||||
name: 'contents',
|
||||
child: [{ name: 'questions' }, { name: 'answers' }],
|
||||
children: [{ name: 'questions' }, { name: 'answers' }],
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
|
@ -54,10 +54,19 @@ export const ADMIN_NAV_MENUS = [
|
|||
},
|
||||
{
|
||||
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 = [
|
||||
{
|
||||
label: 'Africa',
|
||||
|
|
|
@ -24,6 +24,8 @@ export interface ReportParams {
|
|||
export interface TagBase {
|
||||
display_name: string;
|
||||
slug_name: string;
|
||||
recommend: boolean;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
export interface Tag extends TagBase {
|
||||
|
@ -126,7 +128,8 @@ export interface UserInfoRes extends UserInfoBase {
|
|||
[prop: string]: any;
|
||||
}
|
||||
|
||||
export interface AvatarUploadReq {
|
||||
export type UploadType = 'post' | 'avatar' | 'branding';
|
||||
export interface UploadReq {
|
||||
file: FormData;
|
||||
}
|
||||
|
||||
|
@ -266,7 +269,6 @@ export interface AdminSettingsGeneral {
|
|||
}
|
||||
|
||||
export interface AdminSettingsInterface {
|
||||
logo: string;
|
||||
language: string;
|
||||
theme: string;
|
||||
time_zone?: string;
|
||||
|
@ -285,10 +287,31 @@ export interface AdminSettingsSmtp {
|
|||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
branding: AdmingSettingBranding;
|
||||
general: AdminSettingsGeneral;
|
||||
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
|
||||
*/
|
||||
|
|
|
@ -8,7 +8,7 @@ import { useAccordionButton } from 'react-bootstrap/AccordionButton';
|
|||
import { Icon } from '@/components';
|
||||
|
||||
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 menuOnClick = (evt) => {
|
||||
evt.preventDefault();
|
||||
|
@ -44,30 +44,45 @@ function MenuNode({ menu, callback, activeKey, isLeaf = false }) {
|
|||
|
||||
interface AccordionProps {
|
||||
menus: any[];
|
||||
path?: string;
|
||||
}
|
||||
const AccordionNav: FC<AccordionProps> = ({ menus }) => {
|
||||
const AccordionNav: FC<AccordionProps> = ({ menus, path = '/' }) => {
|
||||
const navigate = useNavigate();
|
||||
let activeKey = menus[0].name;
|
||||
const pathMatch = useMatch('/admin/*');
|
||||
const pathMatch = useMatch(`${path}*`);
|
||||
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['*'];
|
||||
let activeKey = menus[0].name;
|
||||
if (splat) {
|
||||
activeKey = splat;
|
||||
}
|
||||
const menuClick = (clickedMenu) => {
|
||||
const menuKey = clickedMenu.name;
|
||||
if (Array.isArray(clickedMenu.child) && clickedMenu.child.length) {
|
||||
if (clickedMenu.children.length) {
|
||||
return;
|
||||
}
|
||||
if (activeKey !== menuKey) {
|
||||
const routePath = `/admin/${menuKey}`;
|
||||
const routePath = `${path}${menuKey}`;
|
||||
navigate(routePath);
|
||||
}
|
||||
};
|
||||
|
||||
let defaultOpenKey;
|
||||
menus.forEach((li) => {
|
||||
if (Array.isArray(li.child) && li.child.length) {
|
||||
const matchedChild = li.child.find((el) => {
|
||||
if (li.children.length) {
|
||||
const matchedChild = li.children.find((el) => {
|
||||
return el.name === activeKey;
|
||||
});
|
||||
if (matchedChild) {
|
||||
|
@ -83,10 +98,10 @@ const AccordionNav: FC<AccordionProps> = ({ menus }) => {
|
|||
return (
|
||||
<React.Fragment key={li.name}>
|
||||
<MenuNode menu={li} callback={menuClick} activeKey={activeKey} />
|
||||
{Array.isArray(li.child) ? (
|
||||
{li.children.length ? (
|
||||
<Accordion.Collapse eventKey={li.name} className="ms-4">
|
||||
<Stack direction="vertical" gap={1}>
|
||||
{li.child?.map((leaf) => {
|
||||
{li.children.map((leaf) => {
|
||||
return (
|
||||
<MenuNode
|
||||
menu={leaf}
|
||||
|
|
|
@ -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);
|
|
@ -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;
|
|
@ -61,7 +61,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
|
|||
files: FileList,
|
||||
): Promise<{ url: string; name: string }[]> => {
|
||||
const promises = Array.from(files).map(async (file) => {
|
||||
const url = await uploadImage(file);
|
||||
const url = await uploadImage({ file, type: 'post' });
|
||||
|
||||
return {
|
||||
name: file.name,
|
||||
|
@ -209,7 +209,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
uploadImage(e.target.files[0]).then((url) => {
|
||||
uploadImage({ file: e.target.files[0], type: 'post' }).then((url) => {
|
||||
setLink({ ...link, value: url });
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { Editor, Position } from 'codemirror';
|
||||
import type CodeMirror from 'codemirror';
|
||||
// import 'highlight.js/styles/github.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
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);
|
||||
// });
|
||||
// });
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { TagSelector, Tag } from '@/components';
|
||||
import { tryLoggedAndActicevated } from '@/utils/guard';
|
||||
import { tryLoggedAndActivated } from '@/utils/guard';
|
||||
import { useFollowingTags, followTags } from '@/services';
|
||||
|
||||
const Index: FC = () => {
|
||||
|
@ -32,7 +32,7 @@ const Index: FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
if (!tryLoggedAndActicevated().ok) {
|
||||
if (!tryLoggedAndActivated().ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -73,11 +73,7 @@ const Index: FC = () => {
|
|||
<>
|
||||
{followingTags.map((item) => {
|
||||
const slugName = item?.slug_name;
|
||||
return (
|
||||
<Tag key={slugName} className="m-1" href={`/tags/${slugName}`}>
|
||||
{slugName}
|
||||
</Tag>
|
||||
);
|
||||
return <Tag key={slugName} className="m-1" data={item} />;
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores';
|
||||
import { loggedUserInfoStore, siteInfoStore, brandingStore } from '@/stores';
|
||||
import { logout, useQueryNotificationStatus } from '@/services';
|
||||
import { RouteAlias } from '@/router/alias';
|
||||
|
||||
|
@ -33,7 +33,7 @@ const Header: FC = () => {
|
|||
const q = urlSearch.get('q');
|
||||
const [searchStr, setSearch] = useState('');
|
||||
const siteInfo = siteInfoStore((state) => state.siteInfo);
|
||||
const { interface: interfaceInfo } = interfaceStore();
|
||||
const brandingInfo = brandingStore((state) => state.branding);
|
||||
const { data: redDot } = useQueryNotificationStatus();
|
||||
const location = useLocation();
|
||||
const handleInput = (val) => {
|
||||
|
@ -73,10 +73,10 @@ const Header: FC = () => {
|
|||
|
||||
<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">
|
||||
{interfaceInfo.logo ? (
|
||||
{brandingInfo.logo ? (
|
||||
<img
|
||||
className="logo rounded-1 me-0"
|
||||
src={interfaceInfo.logo}
|
||||
src={brandingInfo.logo}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -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);
|
|
@ -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;
|
|
@ -158,14 +158,7 @@ const QuestionList: FC<Props> = ({ source }) => {
|
|||
{Array.isArray(li.tags)
|
||||
? li.tags.map((tag) => {
|
||||
return (
|
||||
<Tag
|
||||
key={tag.slug_name}
|
||||
className="m-1"
|
||||
href={`/tags/${
|
||||
tag.main_tag_slug_name || tag.slug_name
|
||||
}`}>
|
||||
{tag.slug_name}
|
||||
</Tag>
|
||||
<Tag key={tag.slug_name} className="m-1" data={tag} />
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
|
|
@ -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/)
|
|
@ -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;
|
|
@ -2,17 +2,27 @@ import React, { memo, FC } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Tag } from '@/common/interface';
|
||||
|
||||
interface IProps {
|
||||
data: Tag;
|
||||
href?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const Index: FC<IProps> = ({ className = '', children, href }) => {
|
||||
href = href.toLowerCase();
|
||||
const Index: FC<IProps> = ({ className = '', href, data }) => {
|
||||
href =
|
||||
href || `/tags/${data.main_tag_slug_name || data.slug_name}`.toLowerCase();
|
||||
return (
|
||||
<a href={href} className={classNames('badge-tag rounded-1', className)}>
|
||||
{children}
|
||||
<a
|
||||
href={href}
|
||||
className={classNames(
|
||||
'badge-tag rounded-1',
|
||||
data.reserved && 'badge-tag-reserved',
|
||||
data.recommend && 'badge-tag-required',
|
||||
className,
|
||||
)}>
|
||||
{data.slug_name}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable no-nested-ternary */
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { Dropdown, FormControl, Button, Form } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -95,17 +96,16 @@ const TagSelector: FC<IProps> = ({
|
|||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tag) {
|
||||
setTags(null);
|
||||
return;
|
||||
}
|
||||
|
||||
queryTags(tag).then((res) => {
|
||||
const fetchTags = (str) => {
|
||||
queryTags(str).then((res) => {
|
||||
const tagArray: Type.Tag[] = filterTags(res || []);
|
||||
setTags(tagArray);
|
||||
});
|
||||
}, [tag]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags(tag);
|
||||
}, [visibleMenu]);
|
||||
|
||||
const handleClick = (val: Type.Tag) => {
|
||||
const findIndex = initialValue.findIndex(
|
||||
|
@ -143,7 +143,9 @@ const TagSelector: FC<IProps> = ({
|
|||
};
|
||||
|
||||
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) => {
|
||||
|
@ -186,7 +188,9 @@ const TagSelector: FC<IProps> = ({
|
|||
'm-1 text-nowrap d-flex align-items-center',
|
||||
index === repeatIndex && 'warning',
|
||||
)}
|
||||
variant="outline-secondary"
|
||||
variant={`outline-${
|
||||
item.reserved ? 'danger' : item.recommend ? 'dark' : 'secondary'
|
||||
}`}
|
||||
size="sm">
|
||||
{item.slug_name}
|
||||
<span className="ms-1" onMouseUp={() => handleRemove(item)}>
|
||||
|
@ -220,6 +224,14 @@ const TagSelector: FC<IProps> = ({
|
|||
</Form>
|
||||
</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) => {
|
||||
return (
|
||||
|
|
|
@ -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;
|
|
@ -1,16 +1,29 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { uploadImage } from '@/services';
|
||||
import * as Type from '@/common/interface';
|
||||
|
||||
interface IProps {
|
||||
type: string;
|
||||
upload: (data: FormData) => Promise<any>;
|
||||
type: Type.UploadType;
|
||||
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 [status, setStatus] = useState(false);
|
||||
|
||||
const onChange = (e: any) => {
|
||||
console.log('uploading', e);
|
||||
if (status) {
|
||||
return;
|
||||
}
|
||||
|
@ -24,25 +37,25 @@ const Index: React.FC<IProps> = ({ type, upload }) => {
|
|||
// return;
|
||||
// }
|
||||
setStatus(true);
|
||||
const data = new FormData();
|
||||
|
||||
data.append('file', e.target.files[0]);
|
||||
// do
|
||||
upload(data).finally(() => {
|
||||
setStatus(false);
|
||||
});
|
||||
console.log('uploading', e.target.files);
|
||||
uploadImage({ file: e.target.files[0], type })
|
||||
.then((res) => {
|
||||
uploadCallback(res);
|
||||
})
|
||||
.finally(() => {
|
||||
setStatus(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="mb-2 btn btn-outline-secondary uploadBtn">
|
||||
{status ? t('upload_img.loading') : t('upload_img.name')}
|
||||
<label className={`btn btn-outline-secondary uploadBtn ${className}`}>
|
||||
{children || (status ? t('upload_img.loading') : t('upload_img.name'))}
|
||||
<input
|
||||
type="file"
|
||||
className="d-none"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
accept={`image/jpeg,image/jpg,image/png,image/webp${acceptType}`}
|
||||
onChange={onChange}
|
||||
id={type}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
|
|
@ -18,13 +18,15 @@ import TextArea from './TextArea';
|
|||
import Mentions from './Mentions';
|
||||
import FormatTime from './FormatTime';
|
||||
import Toast from './Toast';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import AccordionNav from './AccordionNav';
|
||||
import PageTitle from './PageTitle';
|
||||
import Empty from './Empty';
|
||||
import BaseUserCard from './BaseUserCard';
|
||||
import FollowingTags from './FollowingTags';
|
||||
import QueryGroup from './QueryGroup';
|
||||
import BrandUpload from './BrandUpload';
|
||||
import SchemaForm, { JSONSchema, UISchema, initFormData } from './SchemaForm';
|
||||
import Labels from './LabelsCard';
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
|
@ -47,7 +49,6 @@ export {
|
|||
Mentions,
|
||||
FormatTime,
|
||||
Toast,
|
||||
AdminHeader,
|
||||
AccordionNav,
|
||||
PageTitle,
|
||||
Empty,
|
||||
|
@ -55,5 +56,9 @@ export {
|
|||
FollowingTags,
|
||||
htmlRender,
|
||||
QueryGroup,
|
||||
BrandUpload,
|
||||
SchemaForm,
|
||||
initFormData,
|
||||
Labels,
|
||||
};
|
||||
export type { EditorRef };
|
||||
export type { EditorRef, JSONSchema, UISchema };
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { Modal, Form, Button, FormCheck } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
@ -141,69 +141,70 @@ const useChangeModal = ({ callback }: Props) => {
|
|||
setDefaultType(params.type);
|
||||
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(
|
||||
<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;
|
||||
}
|
||||
|
||||
if (defaultType === 'suspended' && item.type === 'inactive') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={item?.type}>
|
||||
<Form.Group
|
||||
controlId={item.type}
|
||||
className={`${
|
||||
item.have_content && changeType === item.type
|
||||
? 'mb-2'
|
||||
: 'mb-3'
|
||||
}`}>
|
||||
<FormCheck>
|
||||
<FormCheck.Input
|
||||
id={item.type}
|
||||
type="radio"
|
||||
checked={changeType.type === item.type}
|
||||
onChange={() => handleRadio(item)}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
<FormCheck.Label htmlFor={item.type}>
|
||||
<span className="fw-bold">{item?.name}</span>
|
||||
<br />
|
||||
<span className="text-secondary">
|
||||
{item?.description}
|
||||
</span>
|
||||
</FormCheck.Label>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{t('msg.empty')}
|
||||
</Form.Control.Feedback>
|
||||
</FormCheck>
|
||||
</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>,
|
||||
);
|
||||
if (defaultType === 'suspended' && item.type === 'inactive') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={item?.type}>
|
||||
<Form.Group
|
||||
controlId={item.type}
|
||||
className={`${
|
||||
item.have_content && changeType === item.type
|
||||
? 'mb-2'
|
||||
: 'mb-3'
|
||||
}`}>
|
||||
<FormCheck>
|
||||
<FormCheck.Input
|
||||
id={item.type}
|
||||
type="radio"
|
||||
checked={changeType.type === item.type}
|
||||
onChange={() => handleRadio(item)}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
<FormCheck.Label htmlFor={item.type}>
|
||||
<span className="fw-bold">{item?.name}</span>
|
||||
<br />
|
||||
<span className="text-secondary">
|
||||
{item?.description}
|
||||
</span>
|
||||
</FormCheck.Label>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{t('msg.empty')}
|
||||
</Form.Control.Feedback>
|
||||
</FormCheck>
|
||||
</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 {
|
||||
onClose,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { Modal, Form, Button, FormCheck } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
@ -73,56 +73,57 @@ const useEditStatusModal = ({
|
|||
setDefaultType(params.type);
|
||||
setShow(true);
|
||||
};
|
||||
|
||||
root.render(
|
||||
<Modal show={show} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title as="h5">{t('title', { type: editType })}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form>
|
||||
{list.map((item) => {
|
||||
if (editType === 'answer' && item.type === 'closed') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={item?.type}>
|
||||
<Form.Group controlId={item.type} className="mb-3">
|
||||
<FormCheck>
|
||||
<FormCheck.Input
|
||||
id={item.type}
|
||||
type="radio"
|
||||
checked={changeType === item.type}
|
||||
onChange={() => handleRadio(item)}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
<FormCheck.Label htmlFor={item.type}>
|
||||
<span className="fw-bold">{item.name}</span>
|
||||
<br />
|
||||
<span className="fs-14 text-secondary">
|
||||
{item.description}
|
||||
</span>
|
||||
</FormCheck.Label>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{t('msg.empty')}
|
||||
</Form.Control.Feedback>
|
||||
</FormCheck>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="link" onClick={() => onClose()}>
|
||||
{t('btn_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
{changeType !== 'normal' ? t('btn_next') : t('btn_submit')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>,
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
root.render(
|
||||
<Modal show={show} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title as="h5">{t('title', { type: editType })}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form>
|
||||
{list.map((item) => {
|
||||
if (editType === 'answer' && item.type === 'closed') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={item?.type}>
|
||||
<Form.Group controlId={item.type} className="mb-3">
|
||||
<FormCheck>
|
||||
<FormCheck.Input
|
||||
id={item.type}
|
||||
type="radio"
|
||||
checked={changeType === item.type}
|
||||
onChange={() => handleRadio(item)}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
<FormCheck.Label htmlFor={item.type}>
|
||||
<span className="fw-bold">{item.name}</span>
|
||||
<br />
|
||||
<span className="fs-14 text-secondary">
|
||||
{item.description}
|
||||
</span>
|
||||
</FormCheck.Label>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{t('msg.empty')}
|
||||
</Form.Control.Feedback>
|
||||
</FormCheck>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="link" onClick={() => onClose()}>
|
||||
{t('btn_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
{changeType !== 'normal' ? t('btn_next') : t('btn_submit')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
onClose,
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import i18next from 'i18next';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import en_US from '@i18n/en_US.yaml';
|
||||
import zh_CN from '@i18n/zh_CN.yaml';
|
||||
|
||||
import { DEFAULT_LANG } from '@/common/constants';
|
||||
|
||||
i18next
|
||||
// load translation using http
|
||||
.use(Backend)
|
||||
// pass the i18n instance to react-i18next.
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
|
@ -31,12 +28,6 @@ i18next
|
|||
// allow <br/> and simple html elements in translations
|
||||
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;
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
@import '~bootstrap/scss/bootstrap';
|
||||
@import '~bootstrap-icons';
|
||||
|
||||
.bg-gray-300 {
|
||||
background-color: $gray-300;
|
||||
}
|
||||
|
||||
.focus {
|
||||
color: $input-focus-color !important;
|
||||
background-color: $input-focus-bg !important;
|
||||
|
@ -65,11 +69,32 @@ a {
|
|||
padding: 1px 0.5rem 2px;
|
||||
color: $blue-700;
|
||||
height: 24px;
|
||||
border: 1px solid rgba($blue-100, 0.5);
|
||||
&:hover {
|
||||
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 {
|
||||
border-bottom: 1px solid rgba(33, 37, 41, 0.25);
|
||||
}
|
||||
|
@ -143,6 +168,7 @@ a {
|
|||
|
||||
.fit-content {
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -1,11 +1,12 @@
|
|||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { Form, Button } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
|
||||
import type * as Type from '@/common/interface';
|
||||
import { useToast } from '@/hooks';
|
||||
import { siteInfoStore } from '@/stores';
|
||||
import { useGeneralSetting, updateGeneralSetting } from '@/services';
|
||||
import Pattern from '@/common/pattern';
|
||||
|
||||
import '../index.scss';
|
||||
|
||||
|
@ -17,91 +18,77 @@ const General: FC = () => {
|
|||
const updateSiteInfo = siteInfoStore((state) => state.update);
|
||||
|
||||
const { data: setting } = useGeneralSetting();
|
||||
const [formData, setFormData] = useState<Type.FormDataType>({
|
||||
name: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
const schema: JSONSchema = {
|
||||
title: t('page_title'),
|
||||
required: ['name', 'site_url', 'contact_email'],
|
||||
properties: {
|
||||
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: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
short_description: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
description: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
'ui:options': {
|
||||
validator: (value) => {
|
||||
let url: URL | undefined;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (ex) {
|
||||
return t('site_url.validate');
|
||||
}
|
||||
if (
|
||||
!url ||
|
||||
/^https?:$/.test(url.protocol) === false ||
|
||||
url.pathname !== '/' ||
|
||||
url.search !== '' ||
|
||||
url.hash !== ''
|
||||
) {
|
||||
return t('site_url.validate');
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
contact_email: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
'ui:options': {
|
||||
validator: (value) => {
|
||||
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) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
if (checkValidated() === false) {
|
||||
return;
|
||||
}
|
||||
const reqParams: Type.AdminSettingsGeneral = {
|
||||
name: formData.name.value,
|
||||
description: formData.description.value,
|
||||
|
@ -126,19 +113,7 @@ const General: FC = () => {
|
|||
setFormData({ ...formData });
|
||||
});
|
||||
};
|
||||
const onFieldChange = (fieldName, fieldValue) => {
|
||||
if (!formData[fieldName]) {
|
||||
return;
|
||||
}
|
||||
const fieldData: Type.FormDataType = {
|
||||
[fieldName]: {
|
||||
value: fieldValue,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
};
|
||||
setFormData({ ...formData, ...fieldData });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!setting) {
|
||||
return;
|
||||
|
@ -149,87 +124,21 @@ const General: FC = () => {
|
|||
});
|
||||
setFormData({ ...formData, ...formMeta });
|
||||
}, [setting]);
|
||||
|
||||
const handleOnChange = (data) => {
|
||||
setFormData(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-4">{t('page_title')}</h3>
|
||||
<Form noValidate onSubmit={onSubmit}>
|
||||
<Form.Group controlId="siteName" className="mb-3">
|
||||
<Form.Label>{t('name.label')}</Form.Label>
|
||||
<Form.Control
|
||||
required
|
||||
type="text"
|
||||
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>
|
||||
<SchemaForm
|
||||
schema={schema}
|
||||
formData={formData}
|
||||
onSubmit={onSubmit}
|
||||
uiSchema={uiSchema}
|
||||
onChange={handleOnChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { FC, FormEvent, useEffect, useState } from 'react';
|
||||
import { Form, Button, Image, Stack } from 'react-bootstrap';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useToast } from '@/hooks';
|
||||
import {
|
||||
|
@ -9,10 +8,9 @@ import {
|
|||
AdminSettingsInterface,
|
||||
} from '@/common/interface';
|
||||
import { interfaceStore } from '@/stores';
|
||||
import { UploadImg } from '@/components';
|
||||
import { TIMEZONES, DEFAULT_TIMEZONE } from '@/common/constants';
|
||||
import { JSONSchema, SchemaForm, UISchema } from '@/components';
|
||||
import { DEFAULT_TIMEZONE } from '@/common/constants';
|
||||
import {
|
||||
uploadAvatar,
|
||||
updateInterfaceSetting,
|
||||
useInterfaceSetting,
|
||||
useThemeOptions,
|
||||
|
@ -33,12 +31,32 @@ const Interface: FC = () => {
|
|||
const [langs, setLangs] = useState<LangsType[]>();
|
||||
const { data: setting } = useInterfaceSetting();
|
||||
|
||||
const [formData, setFormData] = useState<FormDataType>({
|
||||
logo: {
|
||||
value: setting?.logo || storeInterface.logo,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
const schema: JSONSchema = {
|
||||
title: t('page_title'),
|
||||
properties: {
|
||||
theme: {
|
||||
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: {
|
||||
value: setting?.theme || storeInterface.theme,
|
||||
isInvalid: false,
|
||||
|
@ -55,6 +73,31 @@ const Interface: FC = () => {
|
|||
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 res: LangsType[] = await loadLanguageOptions(true);
|
||||
setLangs(res);
|
||||
|
@ -103,7 +146,6 @@ const Interface: FC = () => {
|
|||
return;
|
||||
}
|
||||
const reqParams: AdminSettingsInterface = {
|
||||
logo: formData.logo.value,
|
||||
theme: formData.theme.value,
|
||||
language: formData.language.value,
|
||||
time_zone: formData.time_zone.value,
|
||||
|
@ -111,13 +153,13 @@ const Interface: FC = () => {
|
|||
|
||||
updateInterfaceSetting(reqParams)
|
||||
.then(() => {
|
||||
interfaceStore.getState().update(reqParams);
|
||||
setupAppLanguage();
|
||||
setupAppTimeZone();
|
||||
Toast.onShow({
|
||||
msg: t('update', { keyPrefix: 'toast' }),
|
||||
variant: 'success',
|
||||
});
|
||||
interfaceStore.getState().update(reqParams);
|
||||
setupAppLanguage();
|
||||
setupAppTimeZone();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.isError && err.key) {
|
||||
|
@ -127,34 +169,22 @@ const Interface: FC = () => {
|
|||
setFormData({ ...formData });
|
||||
});
|
||||
};
|
||||
const imgUpload = (file: any) => {
|
||||
return new Promise((resolve) => {
|
||||
uploadAvatar(file).then((res) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
logo: {
|
||||
value: res,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
const onChange = (fieldName, fieldValue) => {
|
||||
if (!formData[fieldName]) {
|
||||
return;
|
||||
}
|
||||
const fieldData: FormDataType = {
|
||||
[fieldName]: {
|
||||
value: fieldValue,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
};
|
||||
setFormData({ ...formData, ...fieldData });
|
||||
};
|
||||
// const imgUpload = (file: any) => {
|
||||
// return new Promise((resolve) => {
|
||||
// uploadAvatar(file).then((res) => {
|
||||
// setFormData({
|
||||
// ...formData,
|
||||
// logo: {
|
||||
// value: res,
|
||||
// isInvalid: false,
|
||||
// errorMsg: '',
|
||||
// },
|
||||
// });
|
||||
// resolve(true);
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
useEffect(() => {
|
||||
if (setting) {
|
||||
const formMeta = {};
|
||||
|
@ -167,10 +197,21 @@ const Interface: FC = () => {
|
|||
useEffect(() => {
|
||||
getLangs();
|
||||
}, []);
|
||||
|
||||
const handleOnChange = (data) => {
|
||||
setFormData(data);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<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.Label>{t('logo.label')}</Form.Label>
|
||||
<Stack gap={2}>
|
||||
|
@ -187,7 +228,7 @@ const Interface: FC = () => {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="d-inline-flex">
|
||||
<UploadImg type="logo" upload={imgUpload} />
|
||||
<UploadImg type="logo" upload={imgUpload} className="mb-2" />
|
||||
</div>
|
||||
</Stack>
|
||||
<Form.Text as="div" className="text-muted">
|
||||
|
@ -282,7 +323,7 @@ const Interface: FC = () => {
|
|||
<Button variant="primary" type="submit">
|
||||
{t('save', { keyPrefix: 'btns' })}
|
||||
</Button>
|
||||
</Form>
|
||||
</Form> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -15,11 +15,7 @@ import {
|
|||
import { ADMIN_LIST_STATUS } from '@/common/constants';
|
||||
import { useEditStatusModal, useReportModal } from '@/hooks';
|
||||
import * as Type from '@/common/interface';
|
||||
import {
|
||||
useQuestionSearch,
|
||||
changeQuestionStatus,
|
||||
deleteQuestion,
|
||||
} from '@/services';
|
||||
import { useQuestionSearch, changeQuestionStatus } from '@/services';
|
||||
|
||||
import '../index.scss';
|
||||
|
||||
|
@ -76,9 +72,7 @@ const Questions: FC = () => {
|
|||
confirmBtnVariant: 'danger',
|
||||
confirmText: t('delete', { keyPrefix: 'btns' }),
|
||||
onConfirm: () => {
|
||||
deleteQuestion({
|
||||
id,
|
||||
}).then(() => {
|
||||
changeQuestionStatus(id, 'deleted').then(() => {
|
||||
refreshList();
|
||||
});
|
||||
},
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { Form, Button, Stack } from 'react-bootstrap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type * as Type from '@/common/interface';
|
||||
import { useToast } from '@/hooks';
|
||||
import { useSmtpSetting, updateSmtpSetting } from '@/services';
|
||||
import pattern from '@/common/pattern';
|
||||
import { SchemaForm, JSONSchema, UISchema } from '@/components';
|
||||
import { initFormData } from '../../../components/SchemaForm/index';
|
||||
|
||||
const Smtp: FC = () => {
|
||||
const { t } = useTranslation('translation', {
|
||||
|
@ -13,90 +14,100 @@ const Smtp: FC = () => {
|
|||
});
|
||||
const Toast = useToast();
|
||||
const { data: setting } = useSmtpSetting();
|
||||
const [formData, setFormData] = useState<Type.FormDataType>({
|
||||
from_email: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
from_name: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
smtp_host: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
const schema: JSONSchema = {
|
||||
title: t('page_title'),
|
||||
properties: {
|
||||
from_email: {
|
||||
type: 'string',
|
||||
title: t('from_email.label'),
|
||||
description: t('from_email.text'),
|
||||
},
|
||||
from_name: {
|
||||
type: 'string',
|
||||
title: t('from_name.label'),
|
||||
description: t('from_name.text'),
|
||||
},
|
||||
smtp_host: {
|
||||
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: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
smtp_port: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
smtp_authentication: {
|
||||
value: 'yes',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
smtp_username: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
'ui:widget': 'radio',
|
||||
},
|
||||
smtp_password: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
'ui:options': {
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
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: {
|
||||
value: '',
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
'ui:options': {
|
||||
validator: (value) => {
|
||||
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) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
if (!checkValidated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reqParams: Type.AdminSettingsSmtp = {
|
||||
from_email: formData.from_email.value,
|
||||
from_name: formData.from_name.value,
|
||||
|
@ -124,19 +135,7 @@ const Smtp: FC = () => {
|
|||
setFormData({ ...formData });
|
||||
});
|
||||
};
|
||||
const onFieldChange = (fieldName, fieldValue) => {
|
||||
if (!formData[fieldName]) {
|
||||
return;
|
||||
}
|
||||
const fieldData: Type.FormDataType = {
|
||||
[fieldName]: {
|
||||
value: fieldValue,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
};
|
||||
setFormData({ ...formData, ...fieldData });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!setting) {
|
||||
return;
|
||||
|
@ -152,166 +151,19 @@ const Smtp: FC = () => {
|
|||
setFormData(formState);
|
||||
}, [setting]);
|
||||
|
||||
const handleOnChange = (data) => {
|
||||
setFormData(data);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-4">{t('page_title')}</h3>
|
||||
<Form noValidate onSubmit={onSubmit}>
|
||||
<Form.Group controlId="fromEmail" className="mb-3">
|
||||
<Form.Label>{t('from_email.label')}</Form.Label>
|
||||
<Form.Control
|
||||
required
|
||||
type="text"
|
||||
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>
|
||||
<SchemaForm
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
onChange={handleOnChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -1,25 +1,42 @@
|
|||
import { FC } from 'react';
|
||||
import { Container, Row, Col } from 'react-bootstrap';
|
||||
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 './index.scss';
|
||||
|
||||
const Dashboard: FC = () => {
|
||||
const formPaths = [
|
||||
'general',
|
||||
'smtp',
|
||||
'interface',
|
||||
'branding',
|
||||
'legal',
|
||||
'write',
|
||||
];
|
||||
|
||||
const Index: FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<Row>
|
||||
<Col lg={2}>
|
||||
<AccordionNav menus={ADMIN_NAV_MENUS} />
|
||||
<AccordionNav menus={ADMIN_NAV_MENUS} path="/admin/" />
|
||||
</Col>
|
||||
<Col lg={10}>
|
||||
<Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}>
|
||||
<Outlet />
|
||||
</Col>
|
||||
</Row>
|
||||
|
@ -28,4 +45,4 @@ const Dashboard: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export default Index;
|
||||
|
|
|
@ -4,12 +4,13 @@ import { Helmet, HelmetProvider } from 'react-helmet-async';
|
|||
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { siteInfoStore, toastStore } from '@/stores';
|
||||
import { siteInfoStore, toastStore, brandingStore } from '@/stores';
|
||||
import { Header, Footer, Toast } from '@/components';
|
||||
|
||||
const Layout: FC = () => {
|
||||
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
|
||||
const { siteInfo } = siteInfoStore.getState();
|
||||
const { favicon } = brandingStore((state) => state.branding);
|
||||
const closeToast = () => {
|
||||
toastClear();
|
||||
};
|
||||
|
@ -17,6 +18,7 @@ const Layout: FC = () => {
|
|||
return (
|
||||
<HelmetProvider>
|
||||
<Helmet>
|
||||
<link rel="icon" href={favicon || '/favicon.ico'} />
|
||||
{siteInfo && <meta name="description" content={siteInfo.description} />}
|
||||
</Helmet>
|
||||
<SWRConfig
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,4 @@
|
|||
.sub-container {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
|
@ -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;
|
|
@ -123,7 +123,7 @@ const Ask = () => {
|
|||
isInvalid: true,
|
||||
errorMsg: t('form.fields.title.msg.empty'),
|
||||
};
|
||||
} else if ([...title.value].length > 150) {
|
||||
} else if (Array.from(title.value).length > 150) {
|
||||
bol = false;
|
||||
formData.title = {
|
||||
value: title.value,
|
||||
|
|
|
@ -93,14 +93,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
|||
</div>
|
||||
<div className="m-n1">
|
||||
{data?.tags?.map((item: any) => {
|
||||
return (
|
||||
<Tag
|
||||
className="m-1"
|
||||
href={`/tags/${item.main_tag_slug_name || item.slug_name}`}
|
||||
key={item.slug_name}>
|
||||
{item.slug_name}
|
||||
</Tag>
|
||||
);
|
||||
return <Tag className="m-1" key={item.slug_name} data={item} />;
|
||||
})}
|
||||
</div>
|
||||
<article
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||
import { Container, Row, Col } from 'react-bootstrap';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Pagination, PageTitle } from '@/components';
|
||||
import { Pagination, PageTitle, Labels } from '@/components';
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
import { scrollTop } from '@/utils';
|
||||
import { usePageUsers } from '@/hooks';
|
||||
|
@ -167,6 +167,7 @@ const Index = () => {
|
|||
)}
|
||||
</Col>
|
||||
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||
<Labels className="mb-4" />
|
||||
<RelatedQuestions id={question?.id || ''} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -67,11 +67,7 @@ const Index: FC<Props> = ({ data }) => {
|
|||
)}
|
||||
|
||||
{data.object?.tags?.map((item) => {
|
||||
return (
|
||||
<Tag href={`/tags/${item.slug_name}`} className="me-1">
|
||||
{item.slug_name}
|
||||
</Tag>
|
||||
);
|
||||
return <Tag key={item.slug_name} className="me-1" data={item} />;
|
||||
})}
|
||||
</ListGroupItem>
|
||||
);
|
||||
|
|
|
@ -152,9 +152,17 @@ const TagIntroduction = () => {
|
|||
<>
|
||||
<div className="mb-3">
|
||||
{t('synonyms.text')}{' '}
|
||||
<Tag className="me-2 mb-2" href="#">
|
||||
{tagName}
|
||||
</Tag>
|
||||
<Tag
|
||||
className="me-2 mb-2"
|
||||
href="#"
|
||||
data={{
|
||||
slug_name: tagName || '',
|
||||
main_tag_slug_name: '',
|
||||
display_name: '',
|
||||
recommend: false,
|
||||
reserved: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TagSelector
|
||||
value={synonymsTags}
|
||||
|
@ -170,9 +178,8 @@ const TagIntroduction = () => {
|
|||
<Tag
|
||||
key={item.tag_id}
|
||||
className="me-2 mb-2"
|
||||
href={`/tags/${item.slug_name}`}>
|
||||
{item.slug_name}
|
||||
</Tag>
|
||||
data={item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
|
|
@ -77,9 +77,8 @@ const Tags = () => {
|
|||
className="mb-4">
|
||||
<Card className="h-100">
|
||||
<Card.Body className="d-flex flex-column align-items-start">
|
||||
<Tag className="mb-3" href={`/tags/${tag.slug_name}`}>
|
||||
{tag.slug_name}
|
||||
</Tag>
|
||||
<Tag className="mb-3" data={tag} />
|
||||
|
||||
<p className="fs-14 flex-fill text-break text-wrap text-truncate-4">
|
||||
{tag.original_text}
|
||||
</p>
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { FC, memo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
import { getQueryString } from '@/utils';
|
||||
import { activateAccount } from '@/services';
|
||||
import { PageTitle } from '@/components';
|
||||
|
||||
const Index: FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateUser = loggedUserInfoStore((state) => state.update);
|
||||
useEffect(() => {
|
||||
const code = getQueryString('code');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
if (code) {
|
||||
activateAccount(encodeURIComponent(code)).then((res) => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FormEvent, useState, useEffect } from 'react';
|
||||
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 type {
|
||||
|
@ -10,7 +10,7 @@ import type {
|
|||
} from '@/common/interface';
|
||||
import { PageTitle, Unactivate } from '@/components';
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
import { getQueryString, guard, floppyNavigation } from '@/utils';
|
||||
import { guard, floppyNavigation } from '@/utils';
|
||||
import { login, checkImgCode } from '@/services';
|
||||
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
|
||||
import { RouteAlias } from '@/router/alias';
|
||||
|
@ -20,6 +20,7 @@ import Storage from '@/utils/storage';
|
|||
const Index: React.FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'login' });
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const updateUser = loggedUserInfoStore((state) => state.update);
|
||||
const storeUser = loggedUserInfoStore((state) => state.user);
|
||||
|
@ -154,7 +155,7 @@ const Index: React.FC = () => {
|
|||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const isInactive = getQueryString('status');
|
||||
const isInactive = searchParams.get('status');
|
||||
|
||||
if ((storeUser.id && storeUser.mail_status === 2) || isInactive) {
|
||||
setStep(2);
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
import React, { FormEvent, useState } from 'react';
|
||||
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 { loggedUserInfoStore } from '@/stores';
|
||||
import { getQueryString } from '@/utils';
|
||||
import type { FormDataType } from '@/common/interface';
|
||||
import { replacementPassword } from '@/services';
|
||||
import { PageTitle } from '@/components';
|
||||
|
||||
const Index: React.FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'password_reset' });
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [step, setStep] = useState(1);
|
||||
const clearUser = loggedUserInfoStore((state) => state.clear);
|
||||
const [formData, setFormData] = useState<FormDataType>({
|
||||
|
@ -91,7 +90,7 @@ const Index: React.FC = () => {
|
|||
if (checkValidated() === false) {
|
||||
return;
|
||||
}
|
||||
const code = getQueryString('code');
|
||||
const code = searchParams.get('code');
|
||||
if (!code) {
|
||||
console.error('code is required');
|
||||
return;
|
||||
|
|
|
@ -46,14 +46,7 @@ const Index: FC<Props> = ({ visible, data }) => {
|
|||
</div>
|
||||
<div>
|
||||
{item.question_info?.tags?.map((tag) => {
|
||||
return (
|
||||
<Tag
|
||||
href={`/t/${tag.main_tag_slug_name || tag.slug_name}`}
|
||||
key={tag.slug_name}
|
||||
className="me-1">
|
||||
{tag.slug_name}
|
||||
</Tag>
|
||||
);
|
||||
return <Tag key={tag.slug_name} className="me-1" data={tag} />;
|
||||
})}
|
||||
</div>
|
||||
</ListGroupItem>
|
||||
|
|
|
@ -73,14 +73,7 @@ const Index: FC<Props> = ({ visible, tabName, data }) => {
|
|||
</div>
|
||||
<div>
|
||||
{item.tags?.map((tag) => {
|
||||
return (
|
||||
<Tag
|
||||
href={`/t/${tag.main_tag_slug_name || tag.slug_name}`}
|
||||
className="me-1"
|
||||
key={tag.slug_name}>
|
||||
{tag.slug_name}
|
||||
</Tag>
|
||||
);
|
||||
return <Tag className="me-1" key={tag.slug_name} data={tag} />;
|
||||
})}
|
||||
</div>
|
||||
</ListGroupItem>
|
||||
|
|
|
@ -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 { Link } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FormDataType } from '@/common/interface';
|
||||
import { register } from '@/services';
|
||||
import { register, useLegalTos, useLegalPrivacy } from '@/services';
|
||||
import userStore from '@/stores/userInfo';
|
||||
|
||||
interface Props {
|
||||
|
@ -82,6 +82,26 @@ const Index: React.FC<Props> = ({ callback }) => {
|
|||
});
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
|
@ -185,7 +205,29 @@ const Index: React.FC<Props> = ({ callback }) => {
|
|||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<Trans i18nKey="login.info_login" ns="translation">
|
||||
Already have an account? <Link to="/users/login">Log in</Link>
|
||||
|
|
|
@ -9,7 +9,7 @@ import type { FormDataType } from '@/common/interface';
|
|||
import { UploadImg, Avatar } from '@/components';
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
import { useToast } from '@/hooks';
|
||||
import { modifyUserInfo, uploadAvatar, getLoggedUserInfo } from '@/services';
|
||||
import { modifyUserInfo, getLoggedUserInfo } from '@/services';
|
||||
|
||||
const Index: React.FC = () => {
|
||||
const { t } = useTranslation('translation', {
|
||||
|
@ -60,21 +60,16 @@ const Index: React.FC = () => {
|
|||
setFormData({ ...formData, ...params });
|
||||
};
|
||||
|
||||
const avatarUpload = (file: any) => {
|
||||
return new Promise((resolve) => {
|
||||
uploadAvatar(file).then((res) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
avatar: {
|
||||
...formData.avatar,
|
||||
type: 'custom',
|
||||
custom: res,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
const avatarUpload = (path: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
avatar: {
|
||||
...formData.avatar,
|
||||
type: 'custom',
|
||||
custom: path,
|
||||
isInvalid: false,
|
||||
errorMsg: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -364,7 +359,11 @@ const Index: React.FC = () => {
|
|||
className="me-3 rounded"
|
||||
/>
|
||||
<div>
|
||||
<UploadImg type="avatar" upload={avatarUpload} />
|
||||
<UploadImg
|
||||
type="avatar"
|
||||
uploadCallback={avatarUpload}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div>
|
||||
<Form.Text className="text-muted mt-0">
|
||||
<Trans i18nKey="settings.profile.avatar.text">
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
/// <reference types="react-scripts" />
|
||||
declare module '*.yaml';
|
||||
|
||||
declare module '*.ico';
|
||||
|
|
|
@ -10,7 +10,7 @@ const routes: RouteObject[] = [];
|
|||
|
||||
const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
|
||||
routeNodes.forEach((rn) => {
|
||||
if (rn.path === '/') {
|
||||
if (rn.page === 'pages/Layout') {
|
||||
rn.element = <Layout />;
|
||||
rn.errorElement = <ErrorBoundary />;
|
||||
} else {
|
||||
|
@ -31,7 +31,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => {
|
|||
const refLoader = rn.loader;
|
||||
const refGuard = rn.guard;
|
||||
rn.loader = async (args) => {
|
||||
const gr = await refGuard();
|
||||
const gr = await refGuard(args);
|
||||
if (gr?.redirect && floppyNavigation.differentCurrent(gr.redirect)) {
|
||||
return redirect(gr.redirect);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RouteObject } from 'react-router-dom';
|
||||
import { LoaderFunctionArgs, RouteObject } from 'react-router-dom';
|
||||
|
||||
import { guard } from '@/utils';
|
||||
import type { TGuardResult } from '@/utils/guard';
|
||||
|
@ -13,7 +13,7 @@ export interface RouteNode extends RouteObject {
|
|||
* if guard returned the `TGuardResult` has `redirect` field,
|
||||
* then auto redirect route to the `redirect` target.
|
||||
*/
|
||||
guard?: () => Promise<TGuardResult>;
|
||||
guard?: (args: LoaderFunctionArgs) => Promise<TGuardResult>;
|
||||
}
|
||||
|
||||
const routes: RouteNode[] = [
|
||||
|
@ -31,7 +31,6 @@ const routes: RouteNode[] = [
|
|||
},
|
||||
{
|
||||
path: 'questions',
|
||||
index: true,
|
||||
page: 'pages/Questions',
|
||||
},
|
||||
{
|
||||
|
@ -251,6 +250,18 @@ const routes: RouteNode[] = [
|
|||
path: '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',
|
||||
page: 'pages/Install',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -2,3 +2,5 @@ export * from './answer';
|
|||
export * from './flag';
|
||||
export * from './question';
|
||||
export * from './settings';
|
||||
export * from './users';
|
||||
export * from './dashboard';
|
||||
|
|
|
@ -4,24 +4,6 @@ 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,
|
||||
};
|
||||
};
|
||||
|
||||
export const useQuestionSearch = (params: Type.AdminContentsReq) => {
|
||||
const apiUrl = `/answer/admin/api/question/page?${qs.stringify(params)}`;
|
||||
const { data, error, mutate } = useSWR<Type.ListResult, Error>(
|
||||
|
|
|
@ -71,20 +71,33 @@ export const updateSmtpSetting = (params: Type.AdminSettingsSmtp) => {
|
|||
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 = () => {
|
||||
const apiUrl = `/answer/admin/api/language/options`;
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -5,3 +5,4 @@ export * from './question';
|
|||
export * from './search';
|
||||
export * from './tag';
|
||||
export * from './settings';
|
||||
export * from './legal';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -3,7 +3,7 @@ import qs from 'qs';
|
|||
|
||||
import request from '@/utils/request';
|
||||
import type * as Type from '@/common/interface';
|
||||
import { tryLoggedAndActicevated } from '@/utils/guard';
|
||||
import { tryLoggedAndActivated } from '@/utils/guard';
|
||||
|
||||
export const useQueryNotifications = (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';
|
||||
|
||||
return useSWR<{ inbox: number; achievement: number }>(
|
||||
tryLoggedAndActicevated().ok ? apiUrl : null,
|
||||
tryLoggedAndActivated().ok ? apiUrl : null,
|
||||
request.instance.get,
|
||||
{
|
||||
refreshInterval: 3000,
|
||||
|
|
|
@ -2,7 +2,7 @@ import useSWR from 'swr';
|
|||
|
||||
import request from '@/utils/request';
|
||||
import type * as Type from '@/common/interface';
|
||||
import { tryLoggedAndActicevated } from '@/utils/guard';
|
||||
import { tryLoggedAndActivated } from '@/utils/guard';
|
||||
|
||||
export const deleteTag = (id) => {
|
||||
return request.delete('/answer/api/v1/tag', {
|
||||
|
@ -24,7 +24,7 @@ export const saveSynonymsTags = (params) => {
|
|||
|
||||
export const useFollowingTags = () => {
|
||||
let apiUrl = '';
|
||||
if (tryLoggedAndActicevated().ok) {
|
||||
if (tryLoggedAndActivated().ok) {
|
||||
apiUrl = '/answer/api/v1/tags/following';
|
||||
}
|
||||
const { data, error, mutate } = useSWR<any[]>(apiUrl, request.instance.get);
|
||||
|
|
|
@ -4,12 +4,13 @@ import useSWR from 'swr';
|
|||
import request from '@/utils/request';
|
||||
import type * as Type from '@/common/interface';
|
||||
|
||||
export const uploadImage = (file) => {
|
||||
export const uploadImage = (params: { file: File; type: Type.UploadType }) => {
|
||||
const form = new FormData();
|
||||
|
||||
form.append('file', file);
|
||||
return request.post('/answer/api/v1/user/post/file', form);
|
||||
form.append('source', String(params.type));
|
||||
form.append('file', params.file);
|
||||
return request.post('/answer/api/v1/file', form);
|
||||
};
|
||||
|
||||
export const useQueryQuestionByTitle = (title) => {
|
||||
return useSWR<Record<string, any>>(
|
||||
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);
|
||||
};
|
||||
|
||||
export const uploadAvatar = (params: Type.AvatarUploadReq) => {
|
||||
return request.post('/answer/api/v1/user/avatar/upload', params);
|
||||
};
|
||||
|
||||
export const resetPassword = (params: Type.PasswordResetReq) => {
|
||||
return request.post('/answer/api/v1/user/password/reset', params);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -3,6 +3,7 @@ import loggedUserInfoStore from './userInfo';
|
|||
import globalStore from './global';
|
||||
import siteInfoStore from './siteInfo';
|
||||
import interfaceStore from './interface';
|
||||
import brandingStore from './branding';
|
||||
|
||||
export {
|
||||
toastStore,
|
||||
|
@ -10,4 +11,5 @@ export {
|
|||
globalStore,
|
||||
siteInfoStore,
|
||||
interfaceStore,
|
||||
brandingStore,
|
||||
};
|
||||
|
|
|
@ -10,7 +10,6 @@ interface InterfaceType {
|
|||
|
||||
const interfaceSetting = create<InterfaceType>((set) => ({
|
||||
interface: {
|
||||
logo: '',
|
||||
theme: '',
|
||||
language: DEFAULT_LANG,
|
||||
time_zone: '',
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
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) {
|
||||
const reg = /\d{1,3}(?=(\d{3})+$)/g;
|
||||
return `${num}`.replace(reg, '$&,');
|
||||
|
@ -101,9 +94,63 @@ function escapeRemove(str) {
|
|||
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 {
|
||||
getQueryString,
|
||||
thousandthDivision,
|
||||
formatCount,
|
||||
scrollTop,
|
||||
|
@ -111,4 +158,7 @@ export {
|
|||
parseUserInfo,
|
||||
formatUptime,
|
||||
escapeRemove,
|
||||
mixColor,
|
||||
colorRgb,
|
||||
labelStyle,
|
||||
};
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { getLoggedUserInfo, getAppSettings } from '@/services';
|
||||
import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores';
|
||||
import {
|
||||
loggedUserInfoStore,
|
||||
siteInfoStore,
|
||||
interfaceStore,
|
||||
brandingStore,
|
||||
} from '@/stores';
|
||||
import { RouteAlias } from '@/router/alias';
|
||||
import Storage from '@/utils/storage';
|
||||
import { LOGGED_USER_STORAGE_KEY } from '@/common/constants';
|
||||
|
@ -182,9 +187,10 @@ export const tryNormalLogged = (canNavigate: boolean = false) => {
|
|||
return false;
|
||||
};
|
||||
|
||||
export const tryLoggedAndActicevated = () => {
|
||||
export const tryLoggedAndActivated = () => {
|
||||
const gr: TGuardResult = { ok: true };
|
||||
const us = deriveLoginState();
|
||||
|
||||
if (!us.isLogged || !us.isActivated) {
|
||||
gr.ok = false;
|
||||
}
|
||||
|
@ -196,6 +202,7 @@ export const initAppSettingsStore = async () => {
|
|||
if (appSettings) {
|
||||
siteInfoStore.getState().update(appSettings.general);
|
||||
interfaceStore.getState().update(appSettings.interface);
|
||||
brandingStore.getState().update(appSettings.branding);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -85,6 +85,7 @@ export const getCurrentLang = () => {
|
|||
|
||||
export const setupAppLanguage = async () => {
|
||||
const lang = getCurrentLang();
|
||||
console.log(lang);
|
||||
if (!i18next.getDataByLanguage(lang)) {
|
||||
await addI18nResource(lang);
|
||||
}
|
||||
|
|
|
@ -10,16 +10,8 @@ import { getCurrentLang } from '@/utils/localize';
|
|||
import Storage from './storage';
|
||||
import { floppyNavigation } from './floppyNavigation';
|
||||
|
||||
const API = {
|
||||
development: '',
|
||||
production: '',
|
||||
test: '',
|
||||
};
|
||||
|
||||
const baseApiUrl = process.env.REACT_APP_API_URL || API[process.env.NODE_ENV];
|
||||
|
||||
const baseConfig = {
|
||||
baseUrl: baseApiUrl,
|
||||
baseUrl: process.env.REACT_APP_API_URL || '',
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
};
|
||||
|
@ -56,8 +48,8 @@ class Request {
|
|||
return data;
|
||||
},
|
||||
(error) => {
|
||||
const { status, data: respData, msg: respMsg } = error.response;
|
||||
const { data, msg = '' } = respData;
|
||||
const { status, data: respData, msg: respMsg } = error.response || {};
|
||||
const { data = {}, msg = '' } = respData || {};
|
||||
if (status === 400) {
|
||||
// show error message
|
||||
if (data instanceof Object && data.err_type) {
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
|
@ -24,5 +23,5 @@
|
|||
"@i18n/*": ["../i18n/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "node_modules/@testing-library/jest-dom" ]
|
||||
"include": ["src", "node_modules/@testing-library/jest-dom", "scripts" ]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue