Merge branch 'dev' into fix/search

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

View File

@ -17,6 +17,9 @@ jobs:
- name: Checkout
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:

View File

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

View File

@ -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 youve done that, click “Next” button.'
info: "After youve 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 hosts 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, well be back soon.'
description: "We are under maintenance, well be back soon."
nav_menus:
dashboard: Dashboard
contents: Contents
questions: Questions
answers: Answers
users: Users
flags: Flags
settings: Settings
general: General
interface: Interface
smtp: SMTP
branding: Branding
legal: Legal
write: Write
tos: Terms of Service
privacy: Privacy
admin:
admin_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."

View File

@ -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 后台管理!

View File

@ -1 +0,0 @@
PUBLIC_URL=

View File

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

View File

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

View File

5
ui/.gitignore vendored
View File

@ -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*

View File

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

2
ui/.npmrc Normal file
View File

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

View File

@ -30,7 +30,7 @@ clone the repo locally and run following command in your terminal:
$ git clone git@github.com:answerdev/answer.git answer
$ 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

View File

@ -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;
};

View File

@ -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

View File

@ -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.

View File

@ -1,5 +1,7 @@
// There is a bug when using npm to install: the execution of preinstall is after install, so when this prompt is displayed, the dependent packages have already been installed.
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);
}

View File

@ -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',

View File

@ -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
*/

View File

@ -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}

View File

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

View File

@ -0,0 +1,44 @@
import { FC } from 'react';
import { ButtonGroup, Button } from 'react-bootstrap';
import { Icon, UploadImg } from '@/components';
import { UploadType } from '@/common/interface';
interface Props {
type: UploadType;
value: string;
onChange: (value: string) => void;
acceptType?: string;
}
const Index: FC<Props> = ({ type = 'post', value, onChange, acceptType }) => {
const onUpload = (imgPath: string) => {
onChange(imgPath);
};
const onRemove = () => {
onChange('');
};
return (
<div className="d-flex">
<div className="bg-gray-300 upload-img-wrap me-2 d-flex align-items-center justify-content-center">
<img src={value} alt="" height={100} />
</div>
<ButtonGroup vertical className="fit-content">
<UploadImg
type={type}
uploadCallback={onUpload}
className="mb-0"
acceptType={acceptType}>
<Icon name="cloud-upload" />
</UploadImg>
<Button variant="outline-secondary" onClick={onRemove}>
<Icon name="trash" />
</Button>
</ButtonGroup>
</div>
);
};
export default Index;

View File

@ -61,7 +61,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
files: FileList,
): 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 });
});
};

View File

@ -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);
// });
// });
}

View File

@ -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} />;
})}
</>
) : (

View File

@ -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=""
/>
) : (

View File

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

View File

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

View File

@ -158,14 +158,7 @@ const QuestionList: FC<Props> = ({ source }) => {
{Array.isArray(li.tags)
? 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}

View File

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

View File

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

View File

@ -2,17 +2,27 @@ import React, { memo, FC } from 'react';
import classNames from 'classnames';
import { 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>
);
};

View File

@ -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 (

View File

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

View File

@ -1,16 +1,29 @@
import React, { useState } from 'react';
import { 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>
);

View File

@ -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 };

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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;
}

View File

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

View File

@ -1,11 +1,12 @@
import React, { FC, useEffect, useState } from 'react';
import { 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}
/>
</>
);
};

View File

@ -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> */}
</>
);
};

View File

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

View File

@ -15,11 +15,7 @@ import {
import { ADMIN_LIST_STATUS } from '@/common/constants';
import { 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();
});
},

View File

@ -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}
/>
</>
);
};

View File

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

View File

@ -1,25 +1,42 @@
import { FC } from 'react';
import { 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;

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -123,7 +123,7 @@ const Ask = () => {
isInvalid: true,
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,

View File

@ -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

View File

@ -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>

View File

@ -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>
);

View File

@ -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}
/>
);
})
) : (

View File

@ -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>

View File

@ -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) => {

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -1,10 +1,10 @@
import React, { FormEvent, useState } from 'react';
import React, { FormEvent, MouseEvent, useState } from 'react';
import { Form, Button, Col } from 'react-bootstrap';
import { 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>

View File

@ -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">

View File

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

View File

@ -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);
}

View File

@ -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',

View File

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

View File

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

View File

@ -4,24 +4,6 @@ import useSWR from 'swr';
import request from '@/utils/request';
import 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>(

View File

@ -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);
};

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import qs from 'qs';
import request from '@/utils/request';
import 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,

View File

@ -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);

View File

@ -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);
};

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

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

View File

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

View File

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

View File

@ -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,
};

View File

@ -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);
}
};

View File

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

View File

@ -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) {

View File

@ -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" ]
}