mirror of https://gitee.com/answerdev/answer.git
Merge branch 'main' into fix/search
This commit is contained in:
commit
164eef862c
28
README.md
28
README.md
|
@ -1,24 +1,20 @@
|
||||||
# Answer - Simple Q&A Community
|
<a href="https://answer.dev">
|
||||||
|
<img alt="logo" src="docs/img/answer-logo-flat.svg" height="63px">
|
||||||
|
</a>
|
||||||
|
|
||||||
![logo](docs/img/answer-logo-flat.svg)
|
# Answer - Build Q&A community
|
||||||
|
|
||||||
[![LICENSE](https://img.shields.io/badge/License-MIT-green)](https://github.com/segmentfault/answer/blob/master/LICENSE)
|
A minimalist open-source knowledge based community software. You can use it to quickly build your Q&A community for product technical support, user Q&A, fans communication, and more.
|
||||||
|
|
||||||
|
To learn more about the project, visit [answer.dev](https://answer.dev).
|
||||||
|
|
||||||
|
[![LICENSE](https://img.shields.io/badge/License-Apache-green)](https://github.com/answerdev/answer/blob/main/LICENSE)
|
||||||
[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/)
|
[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/)
|
||||||
[![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/)
|
[![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/)
|
||||||
|
|
||||||
## What is Answer?
|
## Screenshots
|
||||||
|
|
||||||
This is a minimalist open source Q&A community. Users can post questions and others can answer them.
|
![screenshot](docs/img/screenshot.png)
|
||||||
![abstract](docs/img/abstract.png)
|
|
||||||
|
|
||||||
## Why?
|
|
||||||
|
|
||||||
- Help organizations build knowledge and Q&A communities better and faster.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Produce knowledge by asking and answering questions.
|
|
||||||
- Maintain knowledge by voting and working together.
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
@ -40,4 +36,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for ways to get started.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](https://github.com/segmentfault/answer/blob/master/LICENSE)
|
[Apache](https://github.com/answerdev/answer/blob/main/LICENSE)
|
||||||
|
|
25
README_CN.md
25
README_CN.md
|
@ -1,24 +1,25 @@
|
||||||
![logo](docs/img/answer-logo-flat.svg)
|
<a href="https://answer.dev">
|
||||||
|
<img alt="logo" src="docs/img/answer-logo-flat.svg" height="63px">
|
||||||
|
</a>
|
||||||
|
|
||||||
# Answer - 极简问答社区
|
# Answer - 构建问答社区
|
||||||
|
|
||||||
|
一款极简的、问答形式的知识社区开源软件,用来快速构建产品你的产品问答支持社区、用户问答社区、粉丝社区等。
|
||||||
|
|
||||||
|
了解更多关于该项目的内容,请访问 [answer.dev](https://answer.dev).
|
||||||
|
|
||||||
[![LICENSE](https://img.shields.io/badge/License-MIT-green)](https://github.com/segmentfault/answer/blob/master/LICENSE)
|
[![LICENSE](https://img.shields.io/badge/License-MIT-green)](https://github.com/segmentfault/answer/blob/master/LICENSE)
|
||||||
[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/)
|
[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/)
|
||||||
[![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/)
|
[![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/)
|
||||||
|
|
||||||
## 什么是 Answer?
|
## 截图
|
||||||
这是一个极简的开源问答社区。用户可以发布问题,其他人可以回答。
|
|
||||||
![abstract](docs/img/abstract.png)
|
|
||||||
|
|
||||||
## 目标
|
![screenshot](docs/img/screenshot.png)
|
||||||
- 帮助企业更好更快构建知识问答社区
|
|
||||||
|
|
||||||
## 产品功能
|
|
||||||
- 通过提问、回答方式生产知识
|
|
||||||
- 通过投票、共同协作方式维护知识
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 使用 docker-compose 快速搭建
|
### 使用 docker-compose 快速搭建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir answer && cd answer
|
mkdir answer && cd answer
|
||||||
wget https://github.com/segmentfault/answer/releases/latest/download/docker-compose.yaml
|
wget https://github.com/segmentfault/answer/releases/latest/download/docker-compose.yaml
|
||||||
|
@ -31,7 +32,7 @@ docker-compose up
|
||||||
|
|
||||||
我们随时欢迎你的贡献!
|
我们随时欢迎你的贡献!
|
||||||
|
|
||||||
参考 [CONTRIBUTING.md](CONTRIBUTING.md) 其中的贡献指南
|
参考 [CONTRIBUTING.md](CONTRIBUTING.md) 开始贡献。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
19
docs/docs.go
19
docs/docs.go
|
@ -3597,12 +3597,12 @@ const docTemplate = `{
|
||||||
"summary": "UserRegisterByEmail",
|
"summary": "UserRegisterByEmail",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "UserRegister",
|
"description": "UserRegisterReq",
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/schema.UserRegister"
|
"$ref": "#/definitions/schema.UserRegisterReq"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -5089,14 +5089,14 @@ const docTemplate = `{
|
||||||
"schema.ReportHandleReq": {
|
"schema.ReportHandleReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"flaged_type",
|
"flagged_type",
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"flaged_content": {
|
"flagged_content": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"flaged_type": {
|
"flagged_type": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
|
@ -5392,6 +5392,11 @@ const docTemplate = `{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 100
|
"maxLength": 100
|
||||||
},
|
},
|
||||||
|
"username": {
|
||||||
|
"description": "username",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 30
|
||||||
|
},
|
||||||
"website": {
|
"website": {
|
||||||
"description": "website",
|
"description": "website",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -5642,7 +5647,7 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schema.UserRegister": {
|
"schema.UserRegisterReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"e_mail",
|
"e_mail",
|
||||||
|
@ -5658,7 +5663,7 @@ const docTemplate = `{
|
||||||
"name": {
|
"name": {
|
||||||
"description": "name",
|
"description": "name",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 50
|
"maxLength": 30
|
||||||
},
|
},
|
||||||
"pass": {
|
"pass": {
|
||||||
"description": "password",
|
"description": "password",
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 112 KiB |
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
|
@ -3585,12 +3585,12 @@
|
||||||
"summary": "UserRegisterByEmail",
|
"summary": "UserRegisterByEmail",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "UserRegister",
|
"description": "UserRegisterReq",
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/schema.UserRegister"
|
"$ref": "#/definitions/schema.UserRegisterReq"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -5077,14 +5077,14 @@
|
||||||
"schema.ReportHandleReq": {
|
"schema.ReportHandleReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"flaged_type",
|
"flagged_type",
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"flaged_content": {
|
"flagged_content": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"flaged_type": {
|
"flagged_type": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
|
@ -5380,6 +5380,11 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 100
|
"maxLength": 100
|
||||||
},
|
},
|
||||||
|
"username": {
|
||||||
|
"description": "username",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 30
|
||||||
|
},
|
||||||
"website": {
|
"website": {
|
||||||
"description": "website",
|
"description": "website",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -5630,7 +5635,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schema.UserRegister": {
|
"schema.UserRegisterReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"e_mail",
|
"e_mail",
|
||||||
|
@ -5646,7 +5651,7 @@
|
||||||
"name": {
|
"name": {
|
||||||
"description": "name",
|
"description": "name",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 50
|
"maxLength": 30
|
||||||
},
|
},
|
||||||
"pass": {
|
"pass": {
|
||||||
"description": "password",
|
"description": "password",
|
||||||
|
|
|
@ -905,14 +905,14 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
schema.ReportHandleReq:
|
schema.ReportHandleReq:
|
||||||
properties:
|
properties:
|
||||||
flaged_content:
|
flagged_content:
|
||||||
type: string
|
type: string
|
||||||
flaged_type:
|
flagged_type:
|
||||||
type: integer
|
type: integer
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- flaged_type
|
- flagged_type
|
||||||
- id
|
- id
|
||||||
type: object
|
type: object
|
||||||
schema.SearchListResp:
|
schema.SearchListResp:
|
||||||
|
@ -1118,6 +1118,10 @@ definitions:
|
||||||
description: location
|
description: location
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
type: string
|
type: string
|
||||||
|
username:
|
||||||
|
description: username
|
||||||
|
maxLength: 30
|
||||||
|
type: string
|
||||||
website:
|
website:
|
||||||
description: website
|
description: website
|
||||||
maxLength: 500
|
maxLength: 500
|
||||||
|
@ -1297,7 +1301,7 @@ definitions:
|
||||||
- code
|
- code
|
||||||
- pass
|
- pass
|
||||||
type: object
|
type: object
|
||||||
schema.UserRegister:
|
schema.UserRegisterReq:
|
||||||
properties:
|
properties:
|
||||||
e_mail:
|
e_mail:
|
||||||
description: email
|
description: email
|
||||||
|
@ -1305,7 +1309,7 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
description: name
|
description: name
|
||||||
maxLength: 50
|
maxLength: 30
|
||||||
type: string
|
type: string
|
||||||
pass:
|
pass:
|
||||||
description: password
|
description: password
|
||||||
|
@ -3535,12 +3539,12 @@ paths:
|
||||||
- application/json
|
- application/json
|
||||||
description: UserRegisterByEmail
|
description: UserRegisterByEmail
|
||||||
parameters:
|
parameters:
|
||||||
- description: UserRegister
|
- description: UserRegisterReq
|
||||||
in: body
|
in: body
|
||||||
name: data
|
name: data
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/schema.UserRegister'
|
$ref: '#/definitions/schema.UserRegisterReq'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
|
|
@ -74,8 +74,10 @@ error:
|
||||||
other: "user not found"
|
other: "user not found"
|
||||||
suspended:
|
suspended:
|
||||||
other: "user is suspended"
|
other: "user is suspended"
|
||||||
|
username_invalid:
|
||||||
|
other: "username is invalid"
|
||||||
|
username_duplicate:
|
||||||
|
other: "username is already in use"
|
||||||
|
|
||||||
report:
|
report:
|
||||||
spam:
|
spam:
|
||||||
|
|
|
@ -55,7 +55,7 @@ func BindAndCheck(ctx *gin.Context, data interface{}) bool {
|
||||||
|
|
||||||
errField, err := validator.GetValidatorByLang(lang.Abbr()).Check(data)
|
errField, err := validator.GetValidatorByLang(lang.Abbr()).Check(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
HandleResponse(ctx, myErrors.New(http.StatusBadRequest, reason.RequestFormatError).WithMsg(err.Error()), errField)
|
HandleResponse(ctx, err, errField)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -24,6 +24,8 @@ const (
|
||||||
DisallowVoteYourSelf = "error.object.disallow_vote_your_self"
|
DisallowVoteYourSelf = "error.object.disallow_vote_your_self"
|
||||||
CaptchaVerificationFailed = "error.object.captcha_verification_failed"
|
CaptchaVerificationFailed = "error.object.captcha_verification_failed"
|
||||||
UserNotFound = "error.user.not_found"
|
UserNotFound = "error.user.not_found"
|
||||||
|
UsernameInvalid = "error.user.username_invalid"
|
||||||
|
UsernameDuplicate = "error.user.username_duplicate"
|
||||||
EmailDuplicate = "error.email.duplicate"
|
EmailDuplicate = "error.email.duplicate"
|
||||||
EmailVerifyUrlExpired = "error.email.verify_url_expired"
|
EmailVerifyUrlExpired = "error.email.verify_url_expired"
|
||||||
EmailNeedToBeVerified = "error.email.need_to_be_verified"
|
EmailNeedToBeVerified = "error.email.need_to_be_verified"
|
||||||
|
|
|
@ -11,7 +11,9 @@ import (
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/go-playground/validator/v10/translations/en"
|
"github.com/go-playground/validator/v10/translations/en"
|
||||||
"github.com/go-playground/validator/v10/translations/zh"
|
"github.com/go-playground/validator/v10/translations/zh"
|
||||||
|
"github.com/segmentfault/answer/internal/base/reason"
|
||||||
"github.com/segmentfault/answer/internal/base/translator"
|
"github.com/segmentfault/answer/internal/base/translator"
|
||||||
|
myErrors "github.com/segmentfault/pacman/errors"
|
||||||
"github.com/segmentfault/pacman/i18n"
|
"github.com/segmentfault/pacman/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -98,7 +100,7 @@ func (m *MyValidator) Check(value interface{}) (errField *ErrorField, err error)
|
||||||
Key: translator.GlobalTrans.Tr(m.Lang, fieldError.Field()),
|
Key: translator.GlobalTrans.Tr(m.Lang, fieldError.Field()),
|
||||||
Value: fieldError.Translate(m.Tran),
|
Value: fieldError.Translate(m.Tran),
|
||||||
}
|
}
|
||||||
return errField, errors.New(fieldError.Translate(m.Tran))
|
return errField, myErrors.BadRequest(reason.RequestFormatError).WithMsg(fieldError.Translate(m.Tran))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -206,11 +206,11 @@ func (uc *UserController) UserLogout(ctx *gin.Context) {
|
||||||
// @Tags User
|
// @Tags User
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param data body schema.UserRegister true "UserRegister"
|
// @Param data body schema.UserRegisterReq true "UserRegisterReq"
|
||||||
// @Success 200 {object} handler.RespBody{data=schema.GetUserResp}
|
// @Success 200 {object} handler.RespBody{data=schema.GetUserResp}
|
||||||
// @Router /answer/api/v1/user/register/email [post]
|
// @Router /answer/api/v1/user/register/email [post]
|
||||||
func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
|
func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
|
||||||
req := &schema.UserRegister{}
|
req := &schema.UserRegisterReq{}
|
||||||
if handler.BindAndCheck(ctx, req) {
|
if handler.BindAndCheck(ctx, req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,8 +27,8 @@ type Report struct {
|
||||||
ObjectType int `xorm:"not null default 0 comment('revision type') INT(11) object_type"`
|
ObjectType int `xorm:"not null default 0 comment('revision type') INT(11) object_type"`
|
||||||
ReportType int `xorm:"not null default 0 comment('report type') INT(11) report_type"`
|
ReportType int `xorm:"not null default 0 comment('report type') INT(11) report_type"`
|
||||||
Content string `xorm:"not null comment('report content') TEXT content"`
|
Content string `xorm:"not null comment('report content') TEXT content"`
|
||||||
FlagedType int `xorm:"not null default 0 comment('flaged type') INT(11) flaged_type"`
|
FlaggedType int `xorm:"not null default 0 comment('flaged type') INT(11) flaged_type"`
|
||||||
FlagedContent string `xorm:"not null comment('flaged content') TEXT flaged_content"`
|
FlaggedContent string `xorm:"not null comment('flaged content') TEXT flaged_content"`
|
||||||
Status int `xorm:"not null default 1 comment('status(normal: 1; delete 2)') INT(11) status"`
|
Status int `xorm:"not null default 1 comment('status(normal: 1; delete 2)') INT(11) status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -108,7 +108,7 @@ func (ur *userRepo) UpdateEmail(ctx context.Context, userID, email string) (err
|
||||||
// UpdateInfo update user info
|
// UpdateInfo update user info
|
||||||
func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) {
|
func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) {
|
||||||
_, err = ur.data.DB.Where("id = ?", userInfo.ID).
|
_, err = ur.data.DB.Where("id = ?", userInfo.ID).
|
||||||
Cols("display_name", "avatar", "bio", "bio_html", "website", "location").Update(userInfo)
|
Cols("username", "display_name", "avatar", "bio", "bio_html", "website", "location").Update(userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
package schema
|
package schema
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/segmentfault/answer/internal/base/constant"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/segmentfault/answer/internal/base/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddReportReq add report request
|
// AddReportReq add report request
|
||||||
|
@ -41,9 +42,9 @@ type GetReportTypeResp struct {
|
||||||
|
|
||||||
// ReportHandleReq request handle request
|
// ReportHandleReq request handle request
|
||||||
type ReportHandleReq struct {
|
type ReportHandleReq struct {
|
||||||
ID string `validate:"required" comment:"report id" form:"id" json:"id"`
|
ID string `validate:"required" comment:"report id" form:"id" json:"id"`
|
||||||
FlagedType int `validate:"required" comment:"flaged type" form:"flaged_type" json:"flaged_type"`
|
FlaggedType int `validate:"required" comment:"flagged type" form:"flagged_type" json:"flagged_type"`
|
||||||
FlagedContent string `validate:"omitempty" comment:"flaged content" form:"flaged_content" json:"flaged_content"`
|
FlaggedContent string `validate:"omitempty" comment:"flagged content" form:"flagged_content" json:"flagged_content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetReportListPageDTO report list data transfer object
|
// GetReportListPageDTO report list data transfer object
|
||||||
|
@ -60,9 +61,9 @@ type GetReportListPageResp struct {
|
||||||
ReportedUser *UserBasicInfo `json:"reported_user"`
|
ReportedUser *UserBasicInfo `json:"reported_user"`
|
||||||
ReportUser *UserBasicInfo `json:"report_user"`
|
ReportUser *UserBasicInfo `json:"report_user"`
|
||||||
|
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
FlagedContent string `json:"flaged_content"`
|
FlaggedContent string `json:"flagged_content"`
|
||||||
OType string `json:"object_type"`
|
OType string `json:"object_type"`
|
||||||
|
|
||||||
ObjectID string `json:"-"`
|
ObjectID string `json:"-"`
|
||||||
QuestionID string `json:"question_id"`
|
QuestionID string `json:"question_id"`
|
||||||
|
@ -79,15 +80,15 @@ type GetReportListPageResp struct {
|
||||||
UpdatedAt time.Time `json:"_"`
|
UpdatedAt time.Time `json:"_"`
|
||||||
UpdatedAtParsed int64 `json:"updated_at"`
|
UpdatedAtParsed int64 `json:"updated_at"`
|
||||||
|
|
||||||
Reason *ReasonItem `json:"reason"`
|
Reason *ReasonItem `json:"reason"`
|
||||||
FlagedReason *ReasonItem `json:"flaged_reason"`
|
FlaggedReason *ReasonItem `json:"flagged_reason"`
|
||||||
|
|
||||||
UserID string `json:"-"`
|
UserID string `json:"-"`
|
||||||
ReportedUserID string `json:"-"`
|
ReportedUserID string `json:"-"`
|
||||||
Status int `json:"-"`
|
Status int `json:"-"`
|
||||||
ObjectType int `json:"-"`
|
ObjectType int `json:"-"`
|
||||||
ReportType int `json:"-"`
|
ReportType int `json:"-"`
|
||||||
FlagedType int `json:"-"`
|
FlaggedType int `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format format result
|
// Format format result
|
||||||
|
|
|
@ -2,11 +2,14 @@ package schema
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/jinzhu/copier"
|
"github.com/jinzhu/copier"
|
||||||
|
"github.com/segmentfault/answer/internal/base/reason"
|
||||||
"github.com/segmentfault/answer/internal/base/validator"
|
"github.com/segmentfault/answer/internal/base/validator"
|
||||||
"github.com/segmentfault/answer/internal/entity"
|
"github.com/segmentfault/answer/internal/entity"
|
||||||
"github.com/segmentfault/answer/pkg/checker"
|
"github.com/segmentfault/answer/pkg/checker"
|
||||||
|
"github.com/segmentfault/pacman/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserVerifyEmailReq user verify email request
|
// UserVerifyEmailReq user verify email request
|
||||||
|
@ -179,10 +182,10 @@ type UserEmailLogin struct {
|
||||||
CaptchaCode string `json:"captcha_code" ` // captcha_code
|
CaptchaCode string `json:"captcha_code" ` // captcha_code
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register
|
// UserRegisterReq user register request
|
||||||
type UserRegister struct {
|
type UserRegisterReq struct {
|
||||||
// name
|
// name
|
||||||
Name string `validate:"required,gt=5,lte=50" json:"name"`
|
Name string `validate:"required,gt=4,lte=30" json:"name"`
|
||||||
// email
|
// email
|
||||||
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
|
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
|
||||||
// password
|
// password
|
||||||
|
@ -190,7 +193,7 @@ type UserRegister struct {
|
||||||
IP string `json:"-" `
|
IP string `json:"-" `
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UserRegister) Check() (errField *validator.ErrorField, err error) {
|
func (u *UserRegisterReq) Check() (errField *validator.ErrorField, err error) {
|
||||||
// TODO i18n
|
// TODO i18n
|
||||||
err = checker.PassWordCheck(8, 32, 0, u.Pass)
|
err = checker.PassWordCheck(8, 32, 0, u.Pass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -224,6 +227,8 @@ func (u *UserModifyPassWordRequest) Check() (errField *validator.ErrorField, err
|
||||||
type UpdateInfoRequest struct {
|
type UpdateInfoRequest struct {
|
||||||
// display_name
|
// display_name
|
||||||
DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"`
|
DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"`
|
||||||
|
// username
|
||||||
|
Username string `validate:"omitempty,gt=0,lte=30" json:"username"`
|
||||||
// avatar
|
// avatar
|
||||||
Avatar string `validate:"omitempty,gt=0,lte=500" json:"avatar"`
|
Avatar string `validate:"omitempty,gt=0,lte=500" json:"avatar"`
|
||||||
// bio
|
// bio
|
||||||
|
@ -238,6 +243,21 @@ type UpdateInfoRequest struct {
|
||||||
UserId string `json:"-" `
|
UserId string `json:"-" `
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UpdateInfoRequest) Check() (errField *validator.ErrorField, err error) {
|
||||||
|
if len(u.Username) > 0 {
|
||||||
|
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
|
||||||
|
match := re.MatchString(u.Username)
|
||||||
|
if !match {
|
||||||
|
err = errors.BadRequest(reason.UsernameInvalid)
|
||||||
|
return &validator.ErrorField{
|
||||||
|
Key: "username",
|
||||||
|
Value: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
type UserRetrievePassWordRequest struct {
|
type UserRetrievePassWordRequest struct {
|
||||||
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail
|
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail
|
||||||
CaptchaID string `json:"captcha_id" ` // captcha_id
|
CaptchaID string `json:"captcha_id" ` // captcha_id
|
||||||
|
|
|
@ -62,10 +62,10 @@ func (rs *ReportBackyardService) ListReportPage(ctx context.Context, dto schema.
|
||||||
flags []entity.Report
|
flags []entity.Report
|
||||||
total int64
|
total int64
|
||||||
|
|
||||||
flagedUserIds,
|
flaggedUserIds,
|
||||||
userIds []string
|
userIds []string
|
||||||
|
|
||||||
flagedUsers,
|
flaggedUsers,
|
||||||
users map[string]*schema.UserBasicInfo
|
users map[string]*schema.UserBasicInfo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -78,18 +78,18 @@ func (rs *ReportBackyardService) ListReportPage(ctx context.Context, dto schema.
|
||||||
|
|
||||||
_ = copier.Copy(&resp, flags)
|
_ = copier.Copy(&resp, flags)
|
||||||
for _, r := range resp {
|
for _, r := range resp {
|
||||||
flagedUserIds = append(flagedUserIds, r.ReportedUserID)
|
flaggedUserIds = append(flaggedUserIds, r.ReportedUserID)
|
||||||
userIds = append(userIds, r.UserID)
|
userIds = append(userIds, r.UserID)
|
||||||
r.Format()
|
r.Format()
|
||||||
}
|
}
|
||||||
|
|
||||||
// flaged users
|
// flagged users
|
||||||
flagedUsers, err = rs.commonUser.BatchUserBasicInfoByID(ctx, flagedUserIds)
|
flaggedUsers, err = rs.commonUser.BatchUserBasicInfoByID(ctx, flaggedUserIds)
|
||||||
|
|
||||||
// flag users
|
// flag users
|
||||||
users, err = rs.commonUser.BatchUserBasicInfoByID(ctx, userIds)
|
users, err = rs.commonUser.BatchUserBasicInfoByID(ctx, userIds)
|
||||||
for _, r := range resp {
|
for _, r := range resp {
|
||||||
r.ReportedUser = flagedUsers[r.ReportedUserID]
|
r.ReportedUser = flaggedUsers[r.ReportedUserID]
|
||||||
r.ReportUser = users[r.UserID]
|
r.ReportUser = users[r.UserID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,9 +102,9 @@ func (rs *ReportBackyardService) HandleReported(ctx context.Context, req schema.
|
||||||
var (
|
var (
|
||||||
reported = entity.Report{}
|
reported = entity.Report{}
|
||||||
handleData = entity.Report{
|
handleData = entity.Report{
|
||||||
FlagedContent: req.FlagedContent,
|
FlaggedContent: req.FlaggedContent,
|
||||||
FlagedType: req.FlagedType,
|
FlaggedType: req.FlaggedType,
|
||||||
Status: entity.ReportStatusCompleted,
|
Status: entity.ReportStatusCompleted,
|
||||||
}
|
}
|
||||||
exist = false
|
exist = false
|
||||||
)
|
)
|
||||||
|
@ -203,11 +203,11 @@ func (rs *ReportBackyardService) parseObject(ctx context.Context, resp *[]*schem
|
||||||
}
|
}
|
||||||
err = rs.configRepo.GetConfigById(r.ReportType, r.Reason)
|
err = rs.configRepo.GetConfigById(r.ReportType, r.Reason)
|
||||||
}
|
}
|
||||||
if r.FlagedType > 0 {
|
if r.FlaggedType > 0 {
|
||||||
r.FlagedReason = &schema.ReasonItem{
|
r.FlaggedReason = &schema.ReasonItem{
|
||||||
ReasonType: r.FlagedType,
|
ReasonType: r.FlaggedType,
|
||||||
}
|
}
|
||||||
_ = rs.configRepo.GetConfigById(r.FlagedType, r.FlagedReason)
|
_ = rs.configRepo.GetConfigById(r.FlaggedType, r.FlaggedReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
res[i] = r
|
res[i] = r
|
||||||
|
|
|
@ -2,6 +2,7 @@ package report_handle_backyard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/segmentfault/answer/internal/service/config"
|
"github.com/segmentfault/answer/internal/service/config"
|
||||||
|
|
||||||
"github.com/segmentfault/answer/internal/base/constant"
|
"github.com/segmentfault/answer/internal/base/constant"
|
||||||
|
@ -46,23 +47,23 @@ func (rh *ReportHandle) HandleObject(ctx context.Context, reported entity.Report
|
||||||
}
|
}
|
||||||
switch objectKey {
|
switch objectKey {
|
||||||
case "question":
|
case "question":
|
||||||
switch req.FlagedType {
|
switch req.FlaggedType {
|
||||||
case reasonDelete:
|
case reasonDelete:
|
||||||
err = rh.questionCommon.RemoveQuestion(ctx, &schema.RemoveQuestionReq{ID: objectID})
|
err = rh.questionCommon.RemoveQuestion(ctx, &schema.RemoveQuestionReq{ID: objectID})
|
||||||
case reasonClose:
|
case reasonClose:
|
||||||
err = rh.questionCommon.CloseQuestion(ctx, &schema.CloseQuestionReq{
|
err = rh.questionCommon.CloseQuestion(ctx, &schema.CloseQuestionReq{
|
||||||
ID: objectID,
|
ID: objectID,
|
||||||
CloseType: req.FlagedType,
|
CloseType: req.FlaggedType,
|
||||||
CloseMsg: req.FlagedContent,
|
CloseMsg: req.FlaggedContent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "answer":
|
case "answer":
|
||||||
switch req.FlagedType {
|
switch req.FlaggedType {
|
||||||
case reasonDelete:
|
case reasonDelete:
|
||||||
err = rh.questionCommon.RemoveAnswer(ctx, objectID)
|
err = rh.questionCommon.RemoveAnswer(ctx, objectID)
|
||||||
}
|
}
|
||||||
case "comment":
|
case "comment":
|
||||||
switch req.FlagedType {
|
switch req.FlaggedType {
|
||||||
case reasonDelete:
|
case reasonDelete:
|
||||||
err = rh.commentRepo.RemoveComment(ctx, objectID)
|
err = rh.commentRepo.RemoveComment(ctx, objectID)
|
||||||
rh.sendNotification(ctx, reportedUserID, objectID, constant.YourCommentWasDeleted)
|
rh.sendNotification(ctx, reportedUserID, objectID, constant.YourCommentWasDeleted)
|
||||||
|
|
|
@ -2,7 +2,9 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -17,7 +19,6 @@ import (
|
||||||
"github.com/segmentfault/answer/internal/service/service_config"
|
"github.com/segmentfault/answer/internal/service/service_config"
|
||||||
usercommon "github.com/segmentfault/answer/internal/service/user_common"
|
usercommon "github.com/segmentfault/answer/internal/service/user_common"
|
||||||
"github.com/segmentfault/answer/pkg/checker"
|
"github.com/segmentfault/answer/pkg/checker"
|
||||||
"github.com/segmentfault/answer/pkg/uid"
|
|
||||||
"github.com/segmentfault/pacman/errors"
|
"github.com/segmentfault/pacman/errors"
|
||||||
"github.com/segmentfault/pacman/log"
|
"github.com/segmentfault/pacman/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
@ -233,18 +234,28 @@ func (us *UserService) UserModifyPassWord(ctx context.Context, request *schema.U
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateInfo
|
// UpdateInfo update user info
|
||||||
func (us *UserService) UpdateInfo(ctx context.Context, request *schema.UpdateInfoRequest) error {
|
func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) (err error) {
|
||||||
userinfo := entity.User{}
|
if len(req.Username) > 0 {
|
||||||
userinfo.ID = request.UserId
|
userInfo, exist, err := us.userRepo.GetByUsername(ctx, req.Username)
|
||||||
userinfo.Avatar = request.Avatar
|
if err != nil {
|
||||||
userinfo.DisplayName = request.DisplayName
|
return err
|
||||||
userinfo.Bio = request.Bio
|
}
|
||||||
userinfo.BioHtml = request.BioHtml
|
if exist && userInfo.ID != req.UserId {
|
||||||
userinfo.Location = request.Location
|
return errors.BadRequest(reason.UsernameDuplicate)
|
||||||
userinfo.Website = request.Website
|
}
|
||||||
err := us.userRepo.UpdateInfo(ctx, &userinfo)
|
}
|
||||||
if err != nil {
|
|
||||||
|
userInfo := entity.User{}
|
||||||
|
userInfo.ID = req.UserId
|
||||||
|
userInfo.Avatar = req.Avatar
|
||||||
|
userInfo.DisplayName = req.DisplayName
|
||||||
|
userInfo.Bio = req.Bio
|
||||||
|
userInfo.BioHtml = req.BioHtml
|
||||||
|
userInfo.Location = req.Location
|
||||||
|
userInfo.Website = req.Website
|
||||||
|
userInfo.Username = req.Username
|
||||||
|
if err := us.userRepo.UpdateInfo(ctx, &userInfo); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -259,7 +270,7 @@ func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, er
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRegisterByEmail user register
|
// UserRegisterByEmail user register
|
||||||
func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegister) (
|
func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) (
|
||||||
resp *schema.GetUserResp, err error) {
|
resp *schema.GetUserResp, err error) {
|
||||||
_, has, err := us.userRepo.GetByEmail(ctx, registerUserInfo.Email)
|
_, has, err := us.userRepo.GetByEmail(ctx, registerUserInfo.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -276,7 +287,7 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userInfo.Username, err = us.makeUserName(ctx, registerUserInfo.Name)
|
userInfo.Username, err = us.makeUsername(ctx, registerUserInfo.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -408,58 +419,42 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeUserName
|
// makeUsername
|
||||||
// Generate a unique Username based on the NickName
|
// Generate a unique Username based on the displayName
|
||||||
// todo Waiting to be realized
|
func (us *UserService) makeUsername(ctx context.Context, displayName string) (username string, err error) {
|
||||||
func (us *UserService) makeUserName(ctx context.Context, userName string) (string, error) {
|
// Chinese processing
|
||||||
userName = us.formatUserName(ctx, userName)
|
if has := checker.IsChinese(displayName); has {
|
||||||
_, has, err := us.userRepo.GetByUsername(ctx, userName)
|
str, err := pinyin.New(displayName).Split("").Mode(pinyin.WithoutTone).Convert()
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
//If the user name is duplicated, it is generated recursively from the new one.
|
|
||||||
if has {
|
|
||||||
userName = uid.IDStr()
|
|
||||||
return us.makeUserName(ctx, userName)
|
|
||||||
}
|
|
||||||
return userName, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatUserName
|
|
||||||
// Generate a Username through a nickname
|
|
||||||
func (us *UserService) formatUserName(ctx context.Context, Name string) string {
|
|
||||||
formatName, pass := us.CheckUserName(ctx, Name)
|
|
||||||
if !pass {
|
|
||||||
//todo 重新给用户 生成随机 username
|
|
||||||
return uid.IDStr()
|
|
||||||
}
|
|
||||||
return formatName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (us *UserService) CheckUserName(ctx context.Context, name string) (string, bool) {
|
|
||||||
name = strings.Replace(name, " ", "_", -1)
|
|
||||||
name = strings.ToLower(name)
|
|
||||||
//Chinese processing
|
|
||||||
has := checker.IsChinese(name)
|
|
||||||
if has {
|
|
||||||
str, err := pinyin.New(name).Split("").Mode(pinyin.WithoutTone).Convert()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("pinyin Error", err)
|
return "", err
|
||||||
return "", false
|
|
||||||
} else {
|
} else {
|
||||||
name = str
|
displayName = str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//Format filtering
|
|
||||||
re, err := regexp.Compile(`^[a-z0-9._-]{4,20}$`)
|
username = strings.ReplaceAll(displayName, " ", "_")
|
||||||
if err != nil {
|
username = strings.ToLower(username)
|
||||||
log.Error("regexp.Compile Error", err, "name", name)
|
suffix := ""
|
||||||
}
|
|
||||||
match := re.MatchString(name)
|
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
|
||||||
|
match := re.MatchString(username)
|
||||||
if !match {
|
if !match {
|
||||||
return "", false
|
return "", errors.BadRequest(reason.UsernameInvalid)
|
||||||
}
|
}
|
||||||
return name, true
|
|
||||||
|
for {
|
||||||
|
_, has, err := us.userRepo.GetByUsername(ctx, username+suffix)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bytes := make([]byte, 2)
|
||||||
|
_, _ = rand.Read(bytes)
|
||||||
|
suffix = hex.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
return username + suffix, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyPassword
|
// verifyPassword
|
||||||
|
|
|
@ -195,7 +195,7 @@ export interface PostAnswerReq {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageUser {
|
export interface PageUser {
|
||||||
id;
|
id?;
|
||||||
displayName;
|
displayName;
|
||||||
userName?;
|
userName?;
|
||||||
avatar_url?;
|
avatar_url?;
|
||||||
|
|
|
@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Avatar } from '@answer/components';
|
import { Avatar } from '@answer/components';
|
||||||
|
|
||||||
|
import { formatCount } from '@/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: any;
|
data: any;
|
||||||
showAvatar?: boolean;
|
showAvatar?: boolean;
|
||||||
|
@ -34,7 +36,9 @@ const Index: FC<Props> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="fw-bold">{data?.rank}</span>
|
<span className="fw-bold" title="Reputation">
|
||||||
|
{formatCount(data?.rank)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button, Dropdown } from 'react-bootstrap';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -64,6 +64,29 @@ const ActionBar = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<Dropdown className="d-block d-md-none">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
as="div"
|
||||||
|
variant="success"
|
||||||
|
className="no-toggle"
|
||||||
|
id="dropdown-comment">
|
||||||
|
<Icon name="three-dots" className="text-secondary" />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu align="end">
|
||||||
|
{memberActions.map((action) => {
|
||||||
|
return (
|
||||||
|
<Dropdown.Item
|
||||||
|
key={action.name}
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAction(action)}>
|
||||||
|
{action.name}
|
||||||
|
</Dropdown.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -29,27 +29,33 @@ const Form = ({
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
};
|
};
|
||||||
|
const handleSelected = (val) => {
|
||||||
|
setValue(val);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex align-items-start', className)}>
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'd-flex align-items-start flex-column flex-md-row',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
<div>
|
<div>
|
||||||
<Mentions pageUsers={pageUsers.getUsers()}>
|
<Mentions pageUsers={pageUsers.getUsers()} onSelected={handleSelected}>
|
||||||
<TextArea size="sm" value={value} onChange={handleChange} />
|
<TextArea size="sm" value={value} onChange={handleChange} />
|
||||||
</Mentions>
|
</Mentions>
|
||||||
<div className="form-text">{t(`tip_${mode}`)}</div>
|
<div className="form-text">{t(`tip_${mode}`)}</div>
|
||||||
</div>
|
</div>
|
||||||
{type === 'edit' ? (
|
{type === 'edit' ? (
|
||||||
<div className="d-flex flex-column">
|
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-nowrap ms-2"
|
className="text-nowrap "
|
||||||
onClick={() => onSendReply(value)}>
|
onClick={() => onSendReply(value)}>
|
||||||
{t('btn_save_edits')}
|
{t('btn_save_edits')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-nowrap ms-2 btn-no-border"
|
className="text-nowrap btn-no-border ms-2 ms-md-0"
|
||||||
onClick={onCancel}>
|
onClick={onCancel}>
|
||||||
{t('btn_cancel')}
|
{t('btn_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -57,7 +63,7 @@ const Form = ({
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-nowrap ms-2"
|
className="text-nowrap ms-0 ms-md-2 mt-2 mt-md-0"
|
||||||
onClick={() => onSendReply(value)}>
|
onClick={() => onSendReply(value)}>
|
||||||
{t('btn_add_comment')}
|
{t('btn_add_comment')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -13,28 +13,33 @@ const Form = ({ userName, onSendReply, onCancel, mode }) => {
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
};
|
};
|
||||||
|
const handleSelected = (val) => {
|
||||||
|
setValue(val);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="fs-14 mb-2">Reply to {userName}</div>
|
<div className="fs-14 mb-2">Reply to {userName}</div>
|
||||||
<div className="d-flex mb-1 align-items-start">
|
<div className="d-flex mb-1 align-items-start flex-column flex-md-row">
|
||||||
<div>
|
<div>
|
||||||
<Mentions pageUsers={pageUsers.getUsers()}>
|
<Mentions
|
||||||
|
pageUsers={pageUsers.getUsers()}
|
||||||
|
onSelected={handleSelected}>
|
||||||
<TextArea size="sm" value={value} onChange={handleChange} />
|
<TextArea size="sm" value={value} onChange={handleChange} />
|
||||||
</Mentions>
|
</Mentions>
|
||||||
<div className="form-text">{t(`tip_${mode}`)}</div>
|
<div className="form-text">{t(`tip_${mode}`)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex flex-column">
|
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-nowrap ms-2"
|
className="text-nowrap"
|
||||||
onClick={() => onSendReply(value)}>
|
onClick={() => onSendReply(value)}>
|
||||||
{t('btn_add_comment')}
|
{t('btn_add_comment')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-nowrap ms-2 btn-no-border"
|
className="text-nowrap btn-no-border ms-2 ms-md-0"
|
||||||
onClick={onCancel}>
|
onClick={onCancel}>
|
||||||
{t('btn_cancel')}
|
{t('btn_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
|
@import 'bootstrap/scss/functions';
|
||||||
|
@import 'bootstrap/scss/variables';
|
||||||
|
@import 'bootstrap/scss/mixins/_breakpoints';
|
||||||
|
|
||||||
.comments-wrap {
|
.comments-wrap {
|
||||||
.comment-item {
|
.comment-item {
|
||||||
&:hover {
|
&:hover {
|
||||||
.control-area {
|
@include media-breakpoint-up(md) {
|
||||||
display: flex !important;
|
.control-area {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { FC, memo } from 'react';
|
||||||
|
import { Nav, Dropdown } from 'react-bootstrap';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link, NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Avatar, Icon } from '@answer/components';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
redDot;
|
||||||
|
userInfo;
|
||||||
|
logOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav.Link
|
||||||
|
as={NavLink}
|
||||||
|
to="/users/notifications/inbox"
|
||||||
|
className="icon-link d-flex align-items-center justify-content-center p-0 me-3 position-relative">
|
||||||
|
<div className="text-white text-opacity-75">
|
||||||
|
<Icon name="bell-fill" className="fs-4" />
|
||||||
|
</div>
|
||||||
|
{(redDot?.inbox || 0) > 0 && <div className="unread-dot bg-danger" />}
|
||||||
|
</Nav.Link>
|
||||||
|
|
||||||
|
<Nav.Link
|
||||||
|
as={Link}
|
||||||
|
to="/users/notifications/achievement"
|
||||||
|
className="icon-link d-flex align-items-center justify-content-center p-0 me-3 position-relative">
|
||||||
|
<div className="text-white text-opacity-75">
|
||||||
|
<Icon name="trophy-fill" className="fs-4" />
|
||||||
|
</div>
|
||||||
|
{(redDot?.achievement || 0) > 0 && (
|
||||||
|
<div className="unread-dot bg-danger" />
|
||||||
|
)}
|
||||||
|
</Nav.Link>
|
||||||
|
|
||||||
|
<Dropdown align="end">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
variant="success"
|
||||||
|
id="dropdown-basic"
|
||||||
|
as="a"
|
||||||
|
className="no-toggle pointer">
|
||||||
|
<Avatar size="36px" avatar={userInfo?.avatar} />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item href={`/users/${userInfo.username}`}>
|
||||||
|
{t('header.nav.profile')}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item href="/users/settings/profile">
|
||||||
|
{t('header.nav.setting')}
|
||||||
|
</Dropdown.Item>
|
||||||
|
{userInfo?.is_admin ? (
|
||||||
|
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
|
||||||
|
) : null}
|
||||||
|
<Dropdown.Divider />
|
||||||
|
<Dropdown.Item onClick={logOut}>
|
||||||
|
{t('header.nav.logout')}
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Index);
|
|
@ -14,8 +14,8 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
&.icon-link {
|
&.icon-link {
|
||||||
width: 46px;
|
width: 36px;
|
||||||
height: 38px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.placeholder-search {
|
.placeholder-search {
|
||||||
|
@ -28,4 +28,40 @@
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.answer-navBar {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.answer-navBar:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-none {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 992.9px) {
|
||||||
|
#header {
|
||||||
|
.nav-grow {
|
||||||
|
flex-grow: 1!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg-none {
|
||||||
|
display: flex!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-75 {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,16 +7,22 @@ import {
|
||||||
FormControl,
|
FormControl,
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
Dropdown,
|
|
||||||
} from 'react-bootstrap';
|
} from 'react-bootstrap';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams, NavLink, Link, useNavigate } from 'react-router-dom';
|
import {
|
||||||
|
useSearchParams,
|
||||||
|
NavLink,
|
||||||
|
Link,
|
||||||
|
useNavigate,
|
||||||
|
useLocation,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
|
||||||
import { Avatar, Icon } from '@answer/components';
|
|
||||||
import { userInfoStore, siteInfoStore, interfaceStore } from '@answer/stores';
|
import { userInfoStore, siteInfoStore, interfaceStore } from '@answer/stores';
|
||||||
import { logout, useQueryNotificationStatus } from '@answer/api';
|
import { logout, useQueryNotificationStatus } from '@answer/api';
|
||||||
import Storage from '@answer/utils/storage';
|
import Storage from '@answer/utils/storage';
|
||||||
|
|
||||||
|
import NavItems from './components/NavItems';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const Header: FC = () => {
|
const Header: FC = () => {
|
||||||
|
@ -29,6 +35,7 @@ const Header: FC = () => {
|
||||||
const siteInfo = siteInfoStore((state) => state.siteInfo);
|
const siteInfo = siteInfoStore((state) => state.siteInfo);
|
||||||
const { interface: interfaceInfo } = interfaceStore();
|
const { interface: interfaceInfo } = interfaceStore();
|
||||||
const { data: redDot } = useQueryNotificationStatus();
|
const { data: redDot } = useQueryNotificationStatus();
|
||||||
|
const location = useLocation();
|
||||||
const handleInput = (val) => {
|
const handleInput = (val) => {
|
||||||
setSearch(val);
|
setSearch(val);
|
||||||
};
|
};
|
||||||
|
@ -45,18 +52,61 @@ const Header: FC = () => {
|
||||||
handleInput(q);
|
handleInput(q);
|
||||||
}
|
}
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const collapse = document.querySelector('#navBarContent');
|
||||||
|
if (collapse && collapse.classList.contains('show')) {
|
||||||
|
const toogle = document.querySelector('#navBarToggle') as HTMLElement;
|
||||||
|
if (toogle) {
|
||||||
|
toogle?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar variant="dark" expand="lg" className="sticky-top" id="header">
|
<Navbar variant="dark" expand="lg" className="sticky-top" id="header">
|
||||||
<Container className="d-flex align-items-center">
|
<Container className="d-flex align-items-center">
|
||||||
<Navbar.Brand className="lh-1" href="/">
|
<Navbar.Toggle
|
||||||
{interfaceInfo.logo ? (
|
aria-controls="navBarContent"
|
||||||
<img className="logo" src={interfaceInfo.logo} alt="" />
|
className="answer-navBar me-2"
|
||||||
) : (
|
id="navBarToggle"
|
||||||
<span>{siteInfo.name || 'Answer'}</span>
|
/>
|
||||||
)}
|
|
||||||
</Navbar.Brand>
|
<div className="left-wrap d-flex justify-content-between align-items-center nav-grow">
|
||||||
<Navbar.Toggle aria-controls="navBarContent" />
|
<Navbar.Brand to="/" as={Link} className="lh-1">
|
||||||
|
{interfaceInfo.logo ? (
|
||||||
|
<img
|
||||||
|
className="logo rounded-1 me-0"
|
||||||
|
src={interfaceInfo.logo}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{siteInfo.name || 'Answer'}</span>
|
||||||
|
)}
|
||||||
|
</Navbar.Brand>
|
||||||
|
|
||||||
|
{/* mobile nav */}
|
||||||
|
<div className="d-flex lg-none align-items-center flex-lg-nowrap">
|
||||||
|
{user?.username ? (
|
||||||
|
<NavItems redDot={redDot} userInfo={user} logOut={handleLogout} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="me-2 text-white"
|
||||||
|
href="/users/login">
|
||||||
|
{t('btns.login')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" href="/users/register">
|
||||||
|
{t('btns.signup')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Navbar.Collapse id="navBarContent" className="me-auto">
|
<Navbar.Collapse id="navBarContent" className="me-auto">
|
||||||
|
<hr className="hr lg-none mb-2" style={{ marginTop: '12px' }} />
|
||||||
<Col md={4}>
|
<Col md={4}>
|
||||||
<Nav>
|
<Nav>
|
||||||
<NavLink className="nav-link" to="/questions">
|
<NavLink className="nav-link" to="/questions">
|
||||||
|
@ -70,9 +120,10 @@ const Header: FC = () => {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Col>
|
</Col>
|
||||||
|
<hr className="hr lg-none mt-2" />
|
||||||
|
|
||||||
<Col md={4} className="d-none d-sm-flex justify-content-center">
|
<Col lg={4} className="d-flex justify-content-center">
|
||||||
<Form action="/search" className="w-75 px-2">
|
<Form action="/search" className="w-75 px-0 px-lg-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
placeholder={t('header.search.placeholder')}
|
placeholder={t('header.search.placeholder')}
|
||||||
className="text-white placeholder-search"
|
className="text-white placeholder-search"
|
||||||
|
@ -83,69 +134,32 @@ const Header: FC = () => {
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<Nav.Item className="lg-none mt-3 pb-1">
|
||||||
|
<Link
|
||||||
|
to="/questions/ask"
|
||||||
|
className="text-capitalize text-nowrap btn btn-light">
|
||||||
|
{t('btns.add_question')}
|
||||||
|
</Link>
|
||||||
|
</Nav.Item>
|
||||||
|
{/* pc nav */}
|
||||||
<Col
|
<Col
|
||||||
md={4}
|
lg={4}
|
||||||
className="d-flex justify-content-start justify-content-sm-end">
|
className="d-none d-lg-flex justify-content-start justify-content-sm-end">
|
||||||
{user?.username ? (
|
{user?.username ? (
|
||||||
<Nav className="d-flex align-items-center flex-lg-nowrap">
|
<Nav className="d-flex align-items-center flex-lg-nowrap">
|
||||||
<Nav.Item className="me-2">
|
<Nav.Item className="me-3">
|
||||||
<Link
|
<Link
|
||||||
to="/questions/ask"
|
to="/questions/ask"
|
||||||
className="text-capitalize text-nowrap btn btn-light">
|
className="text-capitalize text-nowrap btn btn-light">
|
||||||
{t('btns.add_question')}
|
{t('btns.add_question')}
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Link
|
|
||||||
as={NavLink}
|
|
||||||
to="/users/notifications/inbox"
|
|
||||||
className="icon-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
|
|
||||||
<div className="text-white text-opacity-75">
|
|
||||||
<Icon name="bell-fill" className="fs-5" />
|
|
||||||
</div>
|
|
||||||
{(redDot?.inbox || 0) > 0 && (
|
|
||||||
<div className="unread-dot bg-danger" />
|
|
||||||
)}
|
|
||||||
</Nav.Link>
|
|
||||||
|
|
||||||
<Nav.Link
|
<NavItems
|
||||||
as={Link}
|
redDot={redDot}
|
||||||
to="/users/notifications/achievement"
|
userInfo={user}
|
||||||
className="icon-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
|
logOut={handleLogout}
|
||||||
<div className="text-white text-opacity-75">
|
/>
|
||||||
<Icon name="trophy-fill" className="fs-5" />
|
|
||||||
</div>
|
|
||||||
{(redDot?.achievement || 0) > 0 && (
|
|
||||||
<div className="unread-dot bg-danger" />
|
|
||||||
)}
|
|
||||||
</Nav.Link>
|
|
||||||
|
|
||||||
<Dropdown align="end">
|
|
||||||
<Dropdown.Toggle
|
|
||||||
variant="success"
|
|
||||||
id="dropdown-basic"
|
|
||||||
as="a"
|
|
||||||
className="no-toggle pointer">
|
|
||||||
<Avatar size="36px" avatar={user?.avatar} />
|
|
||||||
</Dropdown.Toggle>
|
|
||||||
|
|
||||||
<Dropdown.Menu>
|
|
||||||
<Dropdown.Item href={`/users/${user.username}`}>
|
|
||||||
{t('header.nav.profile')}
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item href="/users/settings/profile">
|
|
||||||
{t('header.nav.setting')}
|
|
||||||
</Dropdown.Item>
|
|
||||||
{user?.is_admin ? (
|
|
||||||
<Dropdown.Item href="/admin">
|
|
||||||
{t('header.nav.admin')}
|
|
||||||
</Dropdown.Item>
|
|
||||||
) : null}
|
|
||||||
<Dropdown.Divider />
|
|
||||||
<Dropdown.Item onClick={handleLogout}>
|
|
||||||
{t('header.nav.logout')}
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</Nav>
|
</Nav>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -6,11 +6,12 @@ import * as Types from '@answer/common/interface';
|
||||||
interface IProps {
|
interface IProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
pageUsers;
|
pageUsers;
|
||||||
|
onSelected: (val: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RECODE = 5;
|
const MAX_RECODE = 5;
|
||||||
|
|
||||||
const Mentions: FC<IProps> = ({ children, pageUsers }) => {
|
const Mentions: FC<IProps> = ({ children, pageUsers, onSelected }) => {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [val, setValue] = useState('');
|
const [val, setValue] = useState('');
|
||||||
|
@ -71,23 +72,17 @@ const Mentions: FC<IProps> = ({ children, pageUsers }) => {
|
||||||
if (!selectionStart) {
|
if (!selectionStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const str = value.substring(
|
|
||||||
value.substring(0, selectionStart).lastIndexOf('@'),
|
const text = `@${item?.userName}`;
|
||||||
selectionStart,
|
onSelected(
|
||||||
|
`${value.substring(
|
||||||
|
0,
|
||||||
|
value.substring(0, selectionStart).lastIndexOf('@'),
|
||||||
|
)}${text}${value.substring(selectionStart)}`,
|
||||||
);
|
);
|
||||||
const text = `@${item?.displayName}[${item?.userName}] `;
|
|
||||||
element.value = `${value.substring(
|
|
||||||
0,
|
|
||||||
value.substring(0, selectionStart).lastIndexOf('@'),
|
|
||||||
)}${text}${value.substring(selectionStart)}`;
|
|
||||||
setUsers([]);
|
setUsers([]);
|
||||||
setValue('');
|
setValue('');
|
||||||
const newSelectionStart = selectionStart + text.length - str.length;
|
|
||||||
|
|
||||||
element.setSelectionRange(newSelectionStart, newSelectionStart);
|
|
||||||
element.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterData = val
|
const filterData = val
|
||||||
? users.filter(
|
? users.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import { FC, memo } from 'react';
|
import { FC, memo } from 'react';
|
||||||
import { ButtonGroup, Button } from 'react-bootstrap';
|
import { ButtonGroup, Button, DropdownButton, Dropdown } from 'react-bootstrap';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: string[] | Array<{ name: string; sort: string }>;
|
data;
|
||||||
i18nkeyPrefix: string;
|
i18nkeyPrefix: string;
|
||||||
currentSort: string;
|
currentSort: string;
|
||||||
sortKey?: string;
|
sortKey?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
const MAX_BUTTON_COUNT = 3;
|
||||||
const Index: FC<Props> = ({
|
const Index: FC<Props> = ({
|
||||||
data,
|
data = [],
|
||||||
currentSort = '',
|
currentSort = '',
|
||||||
sortKey = 'order',
|
sortKey = 'order',
|
||||||
i18nkeyPrefix = '',
|
i18nkeyPrefix = '',
|
||||||
|
@ -37,9 +39,13 @@ const Index: FC<Props> = ({
|
||||||
setUrlSearchParams(str);
|
setUrlSearchParams(str);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredData = data.filter((_, index) => index > MAX_BUTTON_COUNT - 2);
|
||||||
|
const currentBtn = filteredData.find((btn) => {
|
||||||
|
return (typeof btn === 'string' ? btn : btn.name) === currentSort;
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<ButtonGroup size="sm">
|
<ButtonGroup size="sm">
|
||||||
{data.map((btn) => {
|
{data.map((btn, index) => {
|
||||||
const key = typeof btn === 'string' ? btn : btn.sort;
|
const key = typeof btn === 'string' ? btn : btn.sort;
|
||||||
const name = typeof btn === 'string' ? btn : btn.name;
|
const name = typeof btn === 'string' ? btn : btn.name;
|
||||||
return (
|
return (
|
||||||
|
@ -48,13 +54,55 @@ const Index: FC<Props> = ({
|
||||||
key={key}
|
key={key}
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
active={currentSort === name}
|
active={currentSort === name}
|
||||||
className={`text-capitalize ${className}`}
|
className={classNames(
|
||||||
|
'text-capitalize',
|
||||||
|
data.length > MAX_BUTTON_COUNT &&
|
||||||
|
index > MAX_BUTTON_COUNT - 2 &&
|
||||||
|
'd-none d-md-block',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
data.length > MAX_BUTTON_COUNT && index === data.length - 1
|
||||||
|
? {
|
||||||
|
borderTopRightRadius: '0.25rem',
|
||||||
|
borderBottomRightRadius: '0.25rem',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
href={handleParams(key)}
|
href={handleParams(key)}
|
||||||
onClick={(evt) => handleClick(evt, key)}>
|
onClick={(evt) => handleClick(evt, key)}>
|
||||||
{t(name)}
|
{t(name)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{data.length > MAX_BUTTON_COUNT && (
|
||||||
|
<DropdownButton
|
||||||
|
size="sm"
|
||||||
|
variant={currentBtn ? 'secondary' : 'outline-secondary'}
|
||||||
|
className="d-block d-md-none"
|
||||||
|
as={ButtonGroup}
|
||||||
|
title={currentBtn ? t(currentSort) : t('more')}>
|
||||||
|
{filteredData.map((btn) => {
|
||||||
|
const key = typeof btn === 'string' ? btn : btn.sort;
|
||||||
|
const name = typeof btn === 'string' ? btn : btn.name;
|
||||||
|
return (
|
||||||
|
<Dropdown.Item
|
||||||
|
as="a"
|
||||||
|
key={key}
|
||||||
|
active={currentSort === name}
|
||||||
|
className={classNames(
|
||||||
|
'text-capitalize',
|
||||||
|
'd-block d-md-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
href={handleParams(key)}
|
||||||
|
onClick={(evt) => handleClick(evt, key)}>
|
||||||
|
{t(name)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownButton>
|
||||||
|
)}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,26 +1,41 @@
|
||||||
import { memo, FC } from 'react';
|
import { memo, FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
import { Avatar, FormatTime } from '@answer/components';
|
import { Avatar, FormatTime } from '@answer/components';
|
||||||
|
|
||||||
|
import { formatCount } from '@/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: any;
|
data: any;
|
||||||
time: number;
|
time: number;
|
||||||
preFix: string;
|
preFix: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Index: FC<Props> = ({ data, time, preFix }) => {
|
const Index: FC<Props> = ({ data, time, preFix, className = '' }) => {
|
||||||
return (
|
return (
|
||||||
<div className="d-flex">
|
<div className={classnames('d-flex', className)}>
|
||||||
{data?.status !== 'deleted' ? (
|
{data?.status !== 'deleted' ? (
|
||||||
<Link to={`/users/${data?.username}`}>
|
<Link to={`/users/${data?.username}`}>
|
||||||
<Avatar avatar={data?.avatar} size="40px" className="me-2" />
|
<Avatar
|
||||||
|
avatar={data?.avatar}
|
||||||
|
size="40px"
|
||||||
|
className="me-2 d-none d-md-block"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
avatar={data?.avatar}
|
||||||
|
size="24px"
|
||||||
|
className="me-2 d-block d-md-none"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Avatar avatar={data?.avatar} size="40px" className="me-2" />
|
<Avatar avatar={data?.avatar} size="40px" className="me-2" />
|
||||||
)}
|
)}
|
||||||
<div className="fs-14 text-secondary">
|
<div className="fs-14 text-secondary d-flex flex-row flex-md-column align-items-center align-items-md-start">
|
||||||
<div>
|
<div className="me-1 me-md-0">
|
||||||
{data?.status !== 'deleted' ? (
|
{data?.status !== 'deleted' ? (
|
||||||
<Link to={`/users/${data?.username}`} className="me-1 text-break">
|
<Link to={`/users/${data?.username}`} className="me-1 text-break">
|
||||||
{data?.display_name}
|
{data?.display_name}
|
||||||
|
@ -28,7 +43,9 @@ const Index: FC<Props> = ({ data, time, preFix }) => {
|
||||||
) : (
|
) : (
|
||||||
<span className="me-1 text-break">{data?.display_name}</span>
|
<span className="me-1 text-break">{data?.display_name}</span>
|
||||||
)}
|
)}
|
||||||
<span className="fw-bold">{data?.rank}</span>
|
<span className="fw-bold" title="Reputation">
|
||||||
|
{formatCount(data?.rank)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{time && <FormatTime time={time} preFix={preFix} />}
|
{time && <FormatTime time={time} preFix={preFix} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,11 +14,11 @@ const usePageUsers = () => {
|
||||||
getUsers,
|
getUsers,
|
||||||
setUsers: (data: Types.PageUser | Types.PageUser[]) => {
|
setUsers: (data: Types.PageUser | Types.PageUser[]) => {
|
||||||
if (data instanceof Array) {
|
if (data instanceof Array) {
|
||||||
setUsers(uniqBy([...users, ...data], 'name'));
|
setUsers(uniqBy([...users, ...data], 'userName'));
|
||||||
globalUsers = uniqBy([...globalUsers, ...data], 'name');
|
globalUsers = uniqBy([...globalUsers, ...data], 'userName');
|
||||||
} else {
|
} else {
|
||||||
setUsers(uniqBy([...users, data], 'name'));
|
setUsers(uniqBy([...users, data], 'userName'));
|
||||||
globalUsers = uniqBy([...globalUsers, data], 'name');
|
globalUsers = uniqBy([...globalUsers, data], 'userName');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -117,9 +117,8 @@ const useReportModal = (callback?: () => void) => {
|
||||||
if (params.isBackend && params.action === 'review') {
|
if (params.isBackend && params.action === 'review') {
|
||||||
putReport({
|
putReport({
|
||||||
action: params.type,
|
action: params.type,
|
||||||
// FIXME: typo
|
flagged_content: content.value,
|
||||||
flaged_content: content.value,
|
flagged_type: reportType.type,
|
||||||
flaged_type: reportType.type,
|
|
||||||
id: params.id,
|
id: params.id,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
callback?.();
|
callback?.();
|
||||||
|
|
|
@ -495,6 +495,13 @@
|
||||||
"msg": "Display name cannot be empty.",
|
"msg": "Display name cannot be empty.",
|
||||||
"msg_range": "Display name up to 30 characters"
|
"msg_range": "Display name up to 30 characters"
|
||||||
},
|
},
|
||||||
|
"username": {
|
||||||
|
"label": "Username",
|
||||||
|
"caption": "People can mention you as @username",
|
||||||
|
"msg": "Username cannot be empty.",
|
||||||
|
"msg_range": "Username up to 30 characters",
|
||||||
|
"character": "Must use the character set \"a-z\", \"0-9\", \" - . _\""
|
||||||
|
},
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"label": "Profile image",
|
"label": "Profile image",
|
||||||
"text": "You can upload your image or <1>reset</1> it to"
|
"text": "You can upload your image or <1>reset</1> it to"
|
||||||
|
@ -668,7 +675,8 @@
|
||||||
"answered": "answered",
|
"answered": "answered",
|
||||||
"asked": "asked",
|
"asked": "asked",
|
||||||
"closed": "closed",
|
"closed": "closed",
|
||||||
"follow_a_tag": "Follow a tag"
|
"follow_a_tag": "Follow a tag",
|
||||||
|
"more": "More"
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
|
|
|
@ -53,8 +53,8 @@ a {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 22px;
|
left: 20px;
|
||||||
top: 3px;
|
top: 0px;
|
||||||
border: 1px solid #fff;
|
border: 1px solid #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -118,8 +118,8 @@ const Flags: FC = () => {
|
||||||
className="fs-14 text-secondary"
|
className="fs-14 text-secondary"
|
||||||
/>
|
/>
|
||||||
<BaseUserCard data={li.report_user} className="mt-2 mb-2" />
|
<BaseUserCard data={li.report_user} className="mt-2 mb-2" />
|
||||||
{li.flaged_reason ? (
|
{li.flagged_reason ? (
|
||||||
<small>{li.flaged_content}</small>
|
<small>{li.flagged_content}</small>
|
||||||
) : (
|
) : (
|
||||||
<small>
|
<small>
|
||||||
{li.reason?.name}
|
{li.reason?.name}
|
||||||
|
|
|
@ -412,7 +412,7 @@ const Ask = () => {
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
{t('title', { keyPrefix: 'how_to_format' })}
|
{t('title', { keyPrefix: 'how_to_format' })}
|
||||||
|
|
|
@ -102,7 +102,7 @@ const Index: FC<Props> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row className="mt-4 mb-3">
|
<Row className="mt-4 mb-3">
|
||||||
<Col>
|
<Col className="mb-3 mb-md-0">
|
||||||
<Operate
|
<Operate
|
||||||
qid={data.question_id}
|
qid={data.question_id}
|
||||||
aid={data.id}
|
aid={data.id}
|
||||||
|
@ -113,7 +113,7 @@ const Index: FC<Props> = ({
|
||||||
callback={callback}
|
callback={callback}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col lg={3}>
|
<Col lg={3} className="mb-3 mb-md-0">
|
||||||
{data.update_user_info?.username !== data.user_info?.username ? (
|
{data.update_user_info?.username !== data.user_info?.username ? (
|
||||||
<UserCard
|
<UserCard
|
||||||
data={data?.update_user_info}
|
data={data?.update_user_info}
|
||||||
|
|
|
@ -57,7 +57,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="fs-3 mb-3 text-wrap text-break">
|
<h1 className="h3 mb-3 text-wrap text-break">
|
||||||
<Link className="link-dark" reloadDocument to={`/questions/${data.id}`}>
|
<Link className="link-dark" reloadDocument to={`/questions/${data.id}`}>
|
||||||
{data.title}
|
{data.title}
|
||||||
{data.status === 2
|
{data.status === 2
|
||||||
|
@ -65,7 +65,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
||||||
: ''}
|
: ''}
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="d-flex align-items-center fs-14 mb-2 text-secondary">
|
|
||||||
|
<div className="d-flex flex-wrap align-items-center fs-14 mb-3 text-secondary">
|
||||||
<FormatTime
|
<FormatTime
|
||||||
time={data.create_time}
|
time={data.create_time}
|
||||||
preFix={t('Asked')}
|
preFix={t('Asked')}
|
||||||
|
@ -90,7 +91,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
||||||
{followed ? 'Following' : 'Follow'}
|
{followed ? 'Following' : 'Follow'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 mx-n1">
|
<div className="m-n1">
|
||||||
{data?.tags?.map((item: any) => {
|
{data?.tags?.map((item: any) => {
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
|
@ -105,7 +106,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
||||||
<article
|
<article
|
||||||
ref={ref}
|
ref={ref}
|
||||||
dangerouslySetInnerHTML={{ __html: data?.html }}
|
dangerouslySetInnerHTML={{ __html: data?.html }}
|
||||||
className="fmt text-break text-wrap"
|
className="fmt text-break text-wrap mt-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Actions
|
<Actions
|
||||||
|
@ -122,7 +123,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row className="mt-4 mb-3">
|
<Row className="mt-4 mb-3">
|
||||||
<Col lg={5}>
|
<Col lg={5} className="mb-3 mb-md-0">
|
||||||
<Operate
|
<Operate
|
||||||
qid={data?.id}
|
qid={data?.id}
|
||||||
type="question"
|
type="question"
|
||||||
|
@ -133,7 +134,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
|
||||||
callback={initPage}
|
callback={initPage}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col lg={3}>
|
<Col lg={3} className="mb-3 mb-md-0">
|
||||||
{data.update_user_info?.username !== data.user_info?.username ? (
|
{data.update_user_info?.username !== data.user_info?.username ? (
|
||||||
<UserCard
|
<UserCard
|
||||||
data={data?.user_info}
|
data={data?.user_info}
|
||||||
|
|
|
@ -1,3 +1,11 @@
|
||||||
.answer-item {
|
.answer-item {
|
||||||
border-top: 1px solid rgba(33, 37, 41, 0.25);
|
border-top: 1px solid rgba(33, 37, 41, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.questionDetailPage {
|
||||||
|
h1.h3 {
|
||||||
|
font-size: calc(1.275rem + .3vw)!important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -55,7 +55,16 @@ const Index = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
res.list.forEach((item) => {
|
res.list.forEach((item) => {
|
||||||
setUsers([item.user_info, item?.update_user_info]);
|
setUsers([
|
||||||
|
{
|
||||||
|
displayName: item.user_info.display_name,
|
||||||
|
userName: item.user_info.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: item?.update_user_info?.display_name,
|
||||||
|
userName: item?.update_user_info?.username,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -107,9 +116,9 @@ const Index = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={question?.title} />
|
<PageTitle title={question?.title} />
|
||||||
<Container className="pt-4 mt-2 mb-5">
|
<Container className="pt-4 mt-2 mb-5 questionDetailPage">
|
||||||
<Row className="justify-content-center">
|
<Row className="justify-content-center">
|
||||||
<Col xxl={7} lg={8} sm={12}>
|
<Col xxl={7} lg={8} sm={12} className="mb-5 mb-md-0">
|
||||||
{question?.operation?.operation_type && (
|
{question?.operation?.operation_type && (
|
||||||
<Alert data={question.operation} />
|
<Alert data={question.operation} />
|
||||||
)}
|
)}
|
||||||
|
@ -145,6 +154,7 @@ const Index = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!question?.operation?.operation_type && (
|
{!question?.operation?.operation_type && (
|
||||||
<WriteAnswer
|
<WriteAnswer
|
||||||
visible={answers.count === 0}
|
visible={answers.count === 0}
|
||||||
|
@ -156,7 +166,7 @@ const Index = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<RelatedQuestions id={question?.id || ''} />
|
<RelatedQuestions id={question?.id || ''} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -217,7 +217,7 @@ const Ask = () => {
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
{t('title', { keyPrefix: 'how_to_format' })}
|
{t('title', { keyPrefix: 'how_to_format' })}
|
||||||
|
|
|
@ -29,7 +29,7 @@ const Questions: FC = () => {
|
||||||
<Col xxl={7} lg={8} sm={12}>
|
<Col xxl={7} lg={8} sm={12}>
|
||||||
<QuestionList source="questions" />
|
<QuestionList source="questions" />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<FollowingTags />
|
<FollowingTags />
|
||||||
<HotQuestions />
|
<HotQuestions />
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { QueryGroup } from '@answer/components';
|
import { QueryGroup } from '@answer/components';
|
||||||
|
|
||||||
const sortBtns = ['newest', 'active', 'score'];
|
const sortBtns = ['relevance', 'newest', 'active', 'score'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
count: number;
|
count: number;
|
||||||
|
|
|
@ -13,7 +13,7 @@ const Index = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const page = searchParams.get('page') || 1;
|
const page = searchParams.get('page') || 1;
|
||||||
const q = searchParams.get('q') || '';
|
const q = searchParams.get('q') || '';
|
||||||
const order = searchParams.get('order') || 'newest';
|
const order = searchParams.get('order') || 'relevance';
|
||||||
|
|
||||||
const { data, isLoading } = useSearch({
|
const { data, isLoading } = useSearch({
|
||||||
q,
|
q,
|
||||||
|
@ -53,7 +53,7 @@ const Index = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<Tips />
|
<Tips />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -90,7 +90,7 @@ const Questions: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<QuestionList source="tag" />
|
<QuestionList source="tag" />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<FollowingTags />
|
<FollowingTags />
|
||||||
<HotQuestions />
|
<HotQuestions />
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -249,7 +249,7 @@ const Ask = () => {
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
{t('title', { keyPrefix: 'how_to_format' })}
|
{t('title', { keyPrefix: 'how_to_format' })}
|
||||||
|
|
|
@ -93,7 +93,7 @@ const TagIntroduction = () => {
|
||||||
<PageTitle title={pageTitle} />
|
<PageTitle title={pageTitle} />
|
||||||
<Container className="pt-4 mt-2 mb-5">
|
<Container className="pt-4 mt-2 mb-5">
|
||||||
<Row className="justify-content-center">
|
<Row className="justify-content-center">
|
||||||
<Col xs={7}>
|
<Col xxl={7} lg={8} sm={12}>
|
||||||
<h3 className="mb-3">
|
<h3 className="mb-3">
|
||||||
<Link
|
<Link
|
||||||
to={`/tags/${tagInfo?.slug_name}`}
|
to={`/tags/${tagInfo?.slug_name}`}
|
||||||
|
@ -133,7 +133,7 @@ const TagIntroduction = () => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={3}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header className="d-flex justify-content-between">
|
<Card.Header className="d-flex justify-content-between">
|
||||||
<span>{t('synonyms.title')}</span>
|
<span>{t('synonyms.title')}</span>
|
||||||
|
|
|
@ -120,7 +120,7 @@ const Notifications = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12} />
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0" />
|
||||||
</Row>
|
</Row>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -60,7 +60,11 @@ const Personal: FC = () => {
|
||||||
<Col xxl={7} lg={8} sm={12}>
|
<Col xxl={7} lg={8} sm={12}>
|
||||||
<UserInfo data={userInfo?.info} />
|
<UserInfo data={userInfo?.info} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12} className="d-flex justify-content-end">
|
<Col
|
||||||
|
xxl={3}
|
||||||
|
lg={4}
|
||||||
|
sm={12}
|
||||||
|
className="d-flex justify-content-end mt-5 mt-lg-0">
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
@ -111,7 +115,7 @@ const Personal: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
<Col xxl={3} lg={4} sm={12}>
|
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
|
||||||
<h5 className="mb-3">Stats</h5>
|
<h5 className="mb-3">Stats</h5>
|
||||||
{userInfo?.info && (
|
{userInfo?.info && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -22,6 +22,11 @@ const Index: React.FC = () => {
|
||||||
isInvalid: false,
|
isInvalid: false,
|
||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
},
|
},
|
||||||
|
username: {
|
||||||
|
value: '',
|
||||||
|
isInvalid: false,
|
||||||
|
errorMsg: '',
|
||||||
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
value: '',
|
value: '',
|
||||||
isInvalid: false,
|
isInvalid: false,
|
||||||
|
@ -66,7 +71,7 @@ const Index: React.FC = () => {
|
||||||
|
|
||||||
const checkValidated = (): boolean => {
|
const checkValidated = (): boolean => {
|
||||||
let bol = true;
|
let bol = true;
|
||||||
const { display_name, website } = formData;
|
const { display_name, website, username } = formData;
|
||||||
if (!display_name.value) {
|
if (!display_name.value) {
|
||||||
bol = false;
|
bol = false;
|
||||||
formData.display_name = {
|
formData.display_name = {
|
||||||
|
@ -83,6 +88,29 @@ const Index: React.FC = () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!username.value) {
|
||||||
|
bol = false;
|
||||||
|
formData.username = {
|
||||||
|
value: '',
|
||||||
|
isInvalid: true,
|
||||||
|
errorMsg: t('username.msg'),
|
||||||
|
};
|
||||||
|
} else if ([...username.value].length > 30) {
|
||||||
|
bol = false;
|
||||||
|
formData.username = {
|
||||||
|
value: username.value,
|
||||||
|
isInvalid: true,
|
||||||
|
errorMsg: t('username.msg_range'),
|
||||||
|
};
|
||||||
|
} else if (/[^a-z0-9\-._]/.test(username.value)) {
|
||||||
|
bol = false;
|
||||||
|
formData.username = {
|
||||||
|
value: username.value,
|
||||||
|
isInvalid: true,
|
||||||
|
errorMsg: t('username.character'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const reg = /^(http|https):\/\//g;
|
const reg = /^(http|https):\/\//g;
|
||||||
if (website.value && !website.value.match(reg)) {
|
if (website.value && !website.value.match(reg)) {
|
||||||
bol = false;
|
bol = false;
|
||||||
|
@ -101,12 +129,13 @@ const Index: React.FC = () => {
|
||||||
const handleSubmit = (event: FormEvent) => {
|
const handleSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (checkValidated() === false) {
|
if (!checkValidated()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
display_name: formData.display_name.value,
|
display_name: formData.display_name.value,
|
||||||
|
username: formData.username.value,
|
||||||
avatar: formData.avatar.value,
|
avatar: formData.avatar.value,
|
||||||
bio: formData.bio.value,
|
bio: formData.bio.value,
|
||||||
website: formData.website.value,
|
website: formData.website.value,
|
||||||
|
@ -137,6 +166,7 @@ const Index: React.FC = () => {
|
||||||
const getProfile = () => {
|
const getProfile = () => {
|
||||||
getUserInfo().then((res) => {
|
getUserInfo().then((res) => {
|
||||||
formData.display_name.value = res.display_name;
|
formData.display_name.value = res.display_name;
|
||||||
|
formData.username.value = res.username;
|
||||||
formData.bio.value = res.bio;
|
formData.bio.value = res.bio;
|
||||||
formData.avatar.value = res.avatar;
|
formData.avatar.value = res.avatar;
|
||||||
formData.location.value = res.location;
|
formData.location.value = res.location;
|
||||||
|
@ -172,6 +202,29 @@ const Index: React.FC = () => {
|
||||||
</Form.Control.Feedback>
|
</Form.Control.Feedback>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group controlId="userName" className="mb-3">
|
||||||
|
<Form.Label>{t('username.label')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={formData.username.value}
|
||||||
|
isInvalid={formData.username.isInvalid}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({
|
||||||
|
username: {
|
||||||
|
value: e.target.value,
|
||||||
|
isInvalid: false,
|
||||||
|
errorMsg: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text as="div">{t('username.caption')}</Form.Text>
|
||||||
|
<Form.Control.Feedback type="invalid">
|
||||||
|
{formData.username.errorMsg}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('avatar.label')}</Form.Label>
|
<Form.Label>{t('avatar.label')}</Form.Label>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
|
|
|
@ -9,9 +9,18 @@ function getQueryString(name: string): string {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function thousandthDivision(num) {
|
||||||
|
const reg = /\d{1,3}(?=(\d{3})+$)/g;
|
||||||
|
return `${num}`.replace(reg, '$&,');
|
||||||
|
}
|
||||||
|
|
||||||
function formatCount($num: number): string {
|
function formatCount($num: number): string {
|
||||||
let res = String($num);
|
let res = String($num);
|
||||||
if ($num >= 1000 && $num < 1000000) {
|
if (!Number.isFinite($num)) {
|
||||||
|
res = '0';
|
||||||
|
} else if ($num < 10000) {
|
||||||
|
res = thousandthDivision($num);
|
||||||
|
} else if ($num < 1000000) {
|
||||||
res = `${Math.round($num / 100) / 10}k`;
|
res = `${Math.round($num / 100) / 10}k`;
|
||||||
} else if ($num >= 1000000) {
|
} else if ($num >= 1000000) {
|
||||||
res = `${Math.round($num / 100000) / 10}m`;
|
res = `${Math.round($num / 100000) / 10}m`;
|
||||||
|
@ -69,8 +78,8 @@ function scrollTop(element) {
|
||||||
* @returns Array<{displayName: string, userName: string}>
|
* @returns Array<{displayName: string, userName: string}>
|
||||||
*/
|
*/
|
||||||
function matchedUsers(markdown) {
|
function matchedUsers(markdown) {
|
||||||
const globalReg = /@(.*?)\[(.*?)\]/gm;
|
const globalReg = /\B@([\w|]+)/g;
|
||||||
const reg = /@(.*?)\[(.*?)\]/;
|
const reg = /\B@([\w\\_\\.]+)/;
|
||||||
|
|
||||||
const users = markdown.match(globalReg);
|
const users = markdown.match(globalReg);
|
||||||
if (!users) {
|
if (!users) {
|
||||||
|
@ -79,8 +88,7 @@ function matchedUsers(markdown) {
|
||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
const matched = user.match(reg);
|
const matched = user.match(reg);
|
||||||
return {
|
return {
|
||||||
displayName: matched[2],
|
userName: matched[1],
|
||||||
userName: matched[2],
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -91,12 +99,13 @@ function matchedUsers(markdown) {
|
||||||
* @returns string
|
* @returns string
|
||||||
*/
|
*/
|
||||||
function parseUserInfo(markdown) {
|
function parseUserInfo(markdown) {
|
||||||
const globalReg = /@(.*?)\[(.*?)\]/gm;
|
const globalReg = /\B@([\w\\_\\.\\-]+)/g;
|
||||||
return markdown.replace(globalReg, '[@$1](/u/$2)');
|
return markdown.replace(globalReg, '[@$1](/u/$1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getQueryString,
|
getQueryString,
|
||||||
|
thousandthDivision,
|
||||||
formatCount,
|
formatCount,
|
||||||
isLogin,
|
isLogin,
|
||||||
scrollTop,
|
scrollTop,
|
||||||
|
|
Loading…
Reference in New Issue