Merge branch 'release/1.0.3' into 'main'

Release/1.0.3

See merge request opensource/answer!421
This commit is contained in:
linkinstar 2023-01-16 02:50:35 +00:00
commit 006d89a405
80 changed files with 994 additions and 378 deletions

View File

@ -181,7 +181,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
reportAdminService := report_admin.NewReportAdminService(reportRepo, userCommon, commonRepo, answerRepo, questionRepo, commentCommonRepo, reportHandle, configRepo)
controller_adminReportController := controller_admin.NewReportController(reportAdminService)
userAdminRepo := user.NewUserAdminRepo(dataData, authRepo)
userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon)
userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon, userActiveActivityRepo)
userAdminController := controller_admin.NewUserAdminController(userAdminService)
reasonRepo := reason.NewReasonRepo(configRepo)
reasonService := reason2.NewReasonService(reasonRepo)

View File

@ -6948,10 +6948,6 @@ const docTemplate = `{
},
"schema.SiteBrandingReq": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",
@ -6973,10 +6969,6 @@ const docTemplate = `{
},
"schema.SiteBrandingResp": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",

View File

@ -6936,10 +6936,6 @@
},
"schema.SiteBrandingReq": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",
@ -6961,10 +6957,6 @@
},
"schema.SiteBrandingResp": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",

View File

@ -1241,9 +1241,6 @@ definitions:
square_icon:
maxLength: 512
type: string
required:
- logo
- square_icon
type: object
schema.SiteBrandingResp:
properties:
@ -1259,9 +1256,6 @@ definitions:
square_icon:
maxLength: 512
type: string
required:
- logo
- square_icon
type: object
schema.SiteCustomCssHTMLReq:
properties:

4
go.mod
View File

@ -15,7 +15,6 @@ require (
github.com/go-sql-driver/mysql v1.6.0
github.com/goccy/go-json v0.9.11
github.com/golang/mock v1.4.4
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0
github.com/gosimple/slug v1.13.1
@ -24,6 +23,7 @@ require (
github.com/jinzhu/now v1.1.5
github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.16
github.com/microcosm-cc/bluemonday v1.0.21
github.com/mojocn/base64Captcha v1.3.5
github.com/ory/dockertest/v3 v3.9.1
github.com/robfig/cron/v3 v3.0.1
@ -55,6 +55,7 @@ require (
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/containerd/continuity v0.3.0 // indirect
github.com/docker/cli v20.10.14+incompatible // indirect
@ -71,6 +72,7 @@ require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect

8
go.sum
View File

@ -82,6 +82,8 @@ github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6l
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@ -256,8 +258,6 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c h1:iyaGYbCmcYK0Ja9a3OUa2Fo+EaN0cbLu0eKpBwPFzc8=
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -301,6 +301,8 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
@ -476,6 +478,8 @@ github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=

View File

@ -607,7 +607,8 @@ ui:
msg:
empty: Cannot be empty.
login:
page_title: Welcome to Answer
page_title: Welcome to {{site_name}}
login_to_continue: Log in to continue
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>.
@ -1266,14 +1267,14 @@ ui:
branding:
page_title: Branding
logo:
label: Logo
label: Logo (optional)
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
label: Square Icon (optional)
msg: Square icon cannot be empty.
text: Image used as the base for metadata icons. Should ideally be larger than 512x512.
favicon:

View File

@ -21,7 +21,7 @@ type RespBody struct {
// TrMsg translate the reason cause as a message
func (r *RespBody) TrMsg(lang i18n.Language) *RespBody {
if len(r.Message) == 0 {
r.Message = translator.GlobalTrans.Tr(lang, r.Reason)
r.Message = translator.Tr(lang, r.Reason)
}
return r
}

View File

@ -106,3 +106,12 @@ func CheckLanguageIsValid(lang string) bool {
}
return false
}
// Tr use language to translate data. If this language translation is not available, return default english translation.
func Tr(lang i18n.Language, data string) string {
translation := GlobalTrans.Tr(lang, data)
if translation == data {
return GlobalTrans.Tr(i18n.DefaultLanguage, data)
}
return translation
}

View File

@ -102,7 +102,7 @@ func createDefaultValidator(la i18n.Language) *validator.Validate {
validate.RegisterTagNameFunc(func(fld reflect.StructField) (res string) {
defer func() {
if len(res) > 0 {
res = translator.GlobalTrans.Tr(la, res)
res = translator.Tr(la, res)
}
}()
if jsonTag := fld.Tag.Get("json"); len(jsonTag) > 0 {
@ -168,7 +168,7 @@ func (m *MyValidator) Check(value interface{}) (errFields []*FormErrorField, err
return nil, nil
}
for _, errField := range errFields {
errField.ErrorMsg = translator.GlobalTrans.Tr(m.Lang, errField.ErrorMsg)
errField.ErrorMsg = translator.Tr(m.Lang, errField.ErrorMsg)
}
return errFields, err
}

View File

@ -3,7 +3,9 @@ package controller
import (
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/uploader"
"github.com/answerdev/answer/pkg/converter"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
@ -63,3 +65,21 @@ func (uc *UploadController) UploadFile(ctx *gin.Context) {
}
handler.HandleResponse(ctx, err, url)
}
// PostRender render post content
// @Summary render post content
// @Description render post content
// @Tags Upload
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.PostRenderReq true "PostRenderReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/post/render [post]
func (uc *UploadController) PostRender(ctx *gin.Context) {
req := &schema.PostRenderReq{}
if handler.BindAndCheck(ctx, req) {
return
}
handler.HandleResponse(ctx, nil, converter.Markdown2HTML(req.Content))
}

View File

@ -113,7 +113,7 @@ func (uc *UserController) UserEmailLogin(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -124,7 +124,7 @@ func (uc *UserController) UserEmailLogin(ctx *gin.Context) {
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeLogin, ctx.ClientIP())
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "e_mail",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.EmailOrPasswordWrong),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.EmailOrPasswordWrong),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailOrPasswordWrong), errFields)
return
@ -151,7 +151,7 @@ func (uc *UserController) RetrievePassWord(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -236,7 +236,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -245,7 +245,8 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
resp, errFields, err := uc.userService.UserRegisterByEmail(ctx, req)
if len(errFields) > 0 {
for _, field := range errFields {
field.ErrorMsg = translator.GlobalTrans.Tr(handler.GetLang(ctx), field.ErrorMsg)
field.ErrorMsg = translator.
Tr(handler.GetLang(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
} else {
@ -312,7 +313,7 @@ func (uc *UserController) UserVerifyEmailSend(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -350,7 +351,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) {
if !oldPassVerification {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "old_pass",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.OldPasswordVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.OldPasswordVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.OldPasswordVerificationFailed), errFields)
return
@ -358,7 +359,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) {
if req.OldPass == req.Pass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "pass",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.NewPasswordSameAsPreviousSetting),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.NewPasswordSameAsPreviousSetting),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.NewPasswordSameAsPreviousSetting), errFields)
return
@ -386,7 +387,7 @@ func (uc *UserController) UserUpdateInfo(ctx *gin.Context) {
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
errFields, err := uc.userService.UpdateInfo(ctx, req)
for _, field := range errFields {
field.ErrorMsg = translator.GlobalTrans.Tr(handler.GetLang(ctx), field.ErrorMsg)
field.ErrorMsg = translator.Tr(handler.GetLang(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
}
@ -491,7 +492,7 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return

View File

@ -77,7 +77,7 @@ type InitBaseInfoReq struct {
SiteName string `validate:"required,gt=0,lte=30" json:"site_name"`
SiteURL string `validate:"required,gt=0,lte=512,url" json:"site_url"`
ContactEmail string `validate:"required,email,gt=0,lte=500" json:"contact_email"`
AdminName string `validate:"required,gt=4,lte=30" json:"name"`
AdminName string `validate:"required,gt=3,lte=30" json:"name"`
AdminPassword string `validate:"required,gte=8,lte=32" json:"password"`
AdminEmail string `validate:"required,email,gt=0,lte=500" json:"email"`
}

View File

@ -60,7 +60,7 @@ func (tr *tagCommonRepo) GetTagListByName(ctx context.Context, name string, hasR
cond := &entity.Tag{}
session := tr.data.DB.Where("")
if name != "" {
session.Where("slug_name LIKE ?", name+"%")
session.Where("slug_name LIKE ? or display_name LIKE ?", name+"%", name+"%")
} else {
session.UseBool("recommend")
cond.Recommend = true

View File

@ -217,6 +217,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
// upload file
r.POST("/file", a.uploadController.UploadFile)
r.POST("/post/render", a.uploadController.PostRender)
// activity
r.GET("/activity/timeline", a.activityController.GetObjectTimeline)

View File

@ -246,9 +246,9 @@ type QuestionPageReq struct {
}
const (
QuestionPageRespOperationTypeAsked = "question.operation_type.asked"
QuestionPageRespOperationTypeAnswered = "question.operation_type.answered"
QuestionPageRespOperationTypeModified = "question.operation_type.modified"
QuestionPageRespOperationTypeAsked = "asked"
QuestionPageRespOperationTypeAnswered = "answered"
QuestionPageRespOperationTypeModified = "modified"
)
type QuestionPageResp struct {

View File

@ -0,0 +1,6 @@
package schema
// PostRenderReq post render request
type PostRenderReq struct {
Content string `json:"content"`
}

View File

@ -46,9 +46,9 @@ type SiteInterfaceReq struct {
// SiteBrandingReq site branding request
type SiteBrandingReq struct {
Logo string `validate:"required,gt=0,lte=512" form:"logo" json:"logo"`
Logo string `validate:"omitempty,gt=0,lte=512" form:"logo" json:"logo"`
MobileLogo string `validate:"omitempty,gt=0,lte=512" form:"mobile_logo" json:"mobile_logo"`
SquareIcon string `validate:"required,gt=0,lte=512" form:"square_icon" json:"square_icon"`
SquareIcon string `validate:"omitempty,gt=0,lte=512" form:"square_icon" json:"square_icon"`
Favicon string `validate:"omitempty,gt=0,lte=512" form:"favicon" json:"favicon"`
}
@ -134,7 +134,7 @@ type SiteThemeResp struct {
func (s *SiteThemeResp) TrTheme(ctx context.Context) {
la := handler.GetLangByCtx(ctx)
for _, option := range s.ThemeOptions {
tr := translator.GlobalTrans.Tr(la, option.Value)
tr := translator.Tr(la, option.Value)
// if tr is equal the option value means not found translation, so use the original label
if tr != option.Value {
option.Label = tr

View File

@ -228,7 +228,7 @@ type UserEmailLogin struct {
// UserRegisterReq user register request
type UserRegisterReq struct {
// name
Name string `validate:"required,gt=4,lte=30" json:"name"`
Name string `validate:"required,gt=3,lte=30" json:"name"`
// email
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
// password
@ -277,7 +277,7 @@ type UpdateInfoRequest struct {
// display_name
DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"`
// username
Username string `validate:"omitempty,gt=0,lte=30" json:"username"`
Username string `validate:"omitempty,gt=3,lte=30" json:"username"`
// avatar
Avatar AvatarInfo `json:"avatar"`
// bio
@ -300,12 +300,13 @@ type AvatarInfo struct {
func (u *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
if len(u.Username) > 0 {
errFields := make([]*validator.FormErrorField, 0)
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(u.Username)
if !match {
errField := &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: err.Error(),
ErrorMsg: reason.UsernameInvalid,
}
errFields = append(errFields, errField)
return errFields, errors.BadRequest(reason.UsernameInvalid)

View File

@ -134,7 +134,7 @@ func (ns *NotificationService) GetNotificationPage(ctx context.Context, searchCo
continue
}
lang, _ := ctx.Value(constant.AcceptLanguageFlag).(i18n.Language)
item.NotificationAction = translator.GlobalTrans.Tr(lang, item.NotificationAction)
item.NotificationAction = translator.Tr(lang, item.NotificationAction)
item.ID = notificationInfo.ID
item.UpdateTime = notificationInfo.UpdatedAt.Unix()
if notificationInfo.IsRead == schema.NotificationRead {

View File

@ -6,9 +6,7 @@ import (
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/service/activity_common"
"github.com/answerdev/answer/internal/service/activity_queue"
"github.com/answerdev/answer/internal/service/config"
@ -250,13 +248,7 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
func (qs *QuestionCommon) FormatQuestionsPage(
ctx context.Context, questionList []*entity.Question, loginUserID string, orderCond string) (
formattedQuestions []*schema.QuestionPageResp, err error) {
language := handler.GetLangByCtx(ctx)
askedOp := translator.GlobalTrans.Tr(language, schema.QuestionPageRespOperationTypeAsked)
answeredOp := translator.GlobalTrans.Tr(language, schema.QuestionPageRespOperationTypeAnswered)
modifiedOp := translator.GlobalTrans.Tr(language, schema.QuestionPageRespOperationTypeModified)
formattedQuestions = make([]*schema.QuestionPageResp, 0)
questionIDs := make([]string, 0)
userIDs := make([]string, 0)
for _, questionInfo := range questionList {
@ -300,20 +292,20 @@ func (qs *QuestionCommon) FormatQuestionsPage(
// if order condition is newest or nobody edited or nobody answered, only show question author
if orderCond == schema.QuestionOrderCondNewest || (!haveEdited && !haveAnswered) {
t.OperationType = askedOp
t.OperationType = schema.QuestionPageRespOperationTypeAsked
t.OperatedAt = questionInfo.CreatedAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: questionInfo.UserID}
} else {
// if no one
if haveEdited {
t.OperationType = modifiedOp
t.OperationType = schema.QuestionPageRespOperationTypeModified
t.OperatedAt = questionInfo.UpdatedAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: questionInfo.LastEditUserID}
}
if haveAnswered {
if t.LastAnsweredAt.Unix() > t.OperatedAt {
t.OperationType = answeredOp
t.OperationType = schema.QuestionPageRespOperationTypeAnswered
t.OperatedAt = t.LastAnsweredAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: t.LastAnsweredUserID}
}

View File

@ -140,8 +140,8 @@ func (qs *QuestionService) CloseMsgList(ctx context.Context, lang i18n.Language)
return nil, errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
for _, t := range resp {
t.Name = translator.GlobalTrans.Tr(lang, t.Name)
t.Description = translator.GlobalTrans.Tr(lang, t.Description)
t.Name = translator.Tr(lang, t.Name)
t.Description = translator.Tr(lang, t.Description)
}
return resp, err
}
@ -163,7 +163,7 @@ func (qs *QuestionService) CheckAddQuestion(ctx context.Context, req *schema.Que
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -176,7 +176,7 @@ func (qs *QuestionService) CheckAddQuestion(ctx context.Context, req *schema.Que
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -213,7 +213,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -226,7 +226,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -539,7 +539,7 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err

View File

@ -74,8 +74,8 @@ func (rs *ReportService) GetReportTypeList(ctx context.Context, lang i18n.Langua
err = errors.BadRequest(reason.UnknownError)
}
for _, t := range resp {
t.Name = translator.GlobalTrans.Tr(lang, t.Name)
t.Description = translator.GlobalTrans.Tr(lang, t.Description)
t.Name = translator.Tr(lang, t.Name)
t.Description = translator.Tr(lang, t.Description)
}
return resp, err
}

View File

@ -72,13 +72,13 @@ func (rs *RoleService) GetRoleMapping(ctx context.Context) (roleMapping map[int]
func (rs *RoleService) translateRole(ctx context.Context, role *entity.Role) {
switch role.Name {
case roleUserName:
role.Name = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleNameUser)
role.Description = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionUser)
role.Name = translator.Tr(handler.GetLangByCtx(ctx), trRoleNameUser)
role.Description = translator.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionUser)
case roleAdminName:
role.Name = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleNameAdmin)
role.Description = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionAdmin)
role.Name = translator.Tr(handler.GetLangByCtx(ctx), trRoleNameAdmin)
role.Description = translator.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionAdmin)
case roleModeratorName:
role.Name = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleNameModerator)
role.Description = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionModerator)
role.Name = translator.Tr(handler.GetLangByCtx(ctx), trRoleNameModerator)
role.Description = translator.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionModerator)
}
}

View File

@ -496,7 +496,7 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
thisObjTagNameList := make([]string, 0)
thisObjTagIDList := make([]string, 0)
for _, t := range objectTagData.Tags {
t.SlugName = strings.ToLower(t.SlugName)
// t.SlugName = strings.ToLower(t.SlugName)
thisObjTagNameList = append(thisObjTagNameList, t.SlugName)
}
@ -508,13 +508,13 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
tagInDbMapping := make(map[string]*entity.Tag)
for _, tag := range tagListInDb {
tagInDbMapping[tag.SlugName] = tag
tagInDbMapping[strings.ToLower(tag.SlugName)] = tag
thisObjTagIDList = append(thisObjTagIDList, tag.ID)
}
addTagList := make([]*entity.Tag, 0)
for _, tag := range objectTagData.Tags {
_, ok := tagInDbMapping[tag.SlugName]
_, ok := tagInDbMapping[strings.ToLower(tag.SlugName)]
if ok {
continue
}

View File

@ -12,6 +12,7 @@ import (
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/activity"
"github.com/answerdev/answer/internal/service/auth"
"github.com/answerdev/answer/internal/service/role"
usercommon "github.com/answerdev/answer/internal/service/user_common"
@ -38,6 +39,7 @@ type UserAdminService struct {
userRoleRelService *role.UserRoleRelService
authService *auth.AuthService
userCommonService *usercommon.UserCommon
userActivity activity.UserActiveActivityRepo
}
// NewUserAdminService new user admin service
@ -46,12 +48,14 @@ func NewUserAdminService(
userRoleRelService *role.UserRoleRelService,
authService *auth.AuthService,
userCommonService *usercommon.UserCommon,
userActivity activity.UserActiveActivityRepo,
) *UserAdminService {
return &UserAdminService{
userRepo: userRepo,
userRoleRelService: userRoleRelService,
authService: authService,
userCommonService: userCommonService,
userActivity: userActivity,
}
}
@ -83,7 +87,17 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up
userInfo.Status = entity.UserStatusAvailable
userInfo.MailStatus = entity.EmailStatusAvailable
}
return us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail)
err = us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail)
if err != nil {
return err
}
// if user reputation is zero means this user is inactive, so try to activate this user.
if req.IsNormal() && userInfo.Rank == 0 {
return us.userActivity.UserActive(ctx, userInfo.ID)
}
return nil
}
// UpdateUserRole update user role

View File

@ -510,7 +510,7 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
if exist {
resp = append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "e_mail",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.EmailDuplicate),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.EmailDuplicate),
})
return resp, errors.BadRequest(reason.EmailDuplicate)
}

View File

@ -3,22 +3,26 @@ package converter
import (
"bytes"
"github.com/microcosm-cc/bluemonday"
"github.com/segmentfault/pacman/log"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/renderer"
goldmarkHTML "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// Markdown2HTML convert markdown to html
func Markdown2HTML(source string) string {
mdConverter := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithExtensions(&DangerousHTMLFilterExtension{}, extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
goldmarkHTML.WithHardWraps(),
),
)
var buf bytes.Buffer
@ -28,3 +32,56 @@ func Markdown2HTML(source string) string {
}
return buf.String()
}
type DangerousHTMLFilterExtension struct {
}
func (e *DangerousHTMLFilterExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&DangerousHTMLRenderer{
Config: goldmarkHTML.NewConfig(),
Filter: bluemonday.UGCPolicy(),
}, 1),
))
}
type DangerousHTMLRenderer struct {
goldmarkHTML.Config
Filter *bluemonday.Policy
}
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
func (r *DangerousHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
}
func (r *DangerousHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkSkipChildren, nil
}
n := node.(*ast.RawHTML)
l := n.Segments.Len()
for i := 0; i < l; i++ {
segment := n.Segments.At(i)
_, _ = w.Write(r.Filter.SanitizeBytes(segment.Value(source)))
}
return ast.WalkSkipChildren, nil
}
func (r *DangerousHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.HTMLBlock)
if entering {
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
r.Writer.SecureWrite(w, r.Filter.SanitizeBytes(line.Value(source)))
}
} else {
if n.HasClosure() {
closure := n.ClosureLine
r.Writer.SecureWrite(w, closure.Value(source))
}
}
return ast.WalkContinue, nil
}

View File

@ -71,7 +71,7 @@ If you would like to help us with the i18n translation, please visit [Answer@Cro
├── common (project information/data defined here)
├── components (all components of the project)
├── hooks (all hooks of the project)
├── i18n (Initialize the front-end i18n)
├── i18n (Used only to initialize the front-end i18n tool)
├── pages (all pages of the project)
├── router (Project routing definition)
├── services (all data api of the project)

View File

@ -1 +1 @@
<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><script defer="defer" src="/static/js/main.fde484b3.js"></script><link href="/static/css/main.401dc3ca.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"><div id="spin-mask"><noscript><style>#spin-mask{display:none!important}</style></noscript><style>@keyframes _doc-spin{to{transform:rotate(360deg)}}#spin-mask{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:9999}#spin-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#spin-container .spinner{box-sizing:border-box;display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25rem solid currentColor;border-right-color:transparent;color:rgba(108,117,125,.75);border-radius:50%;animation:.75s linear infinite _doc-spin}</style><div id="spin-container"><div class="spinner"></div></div></div></div></body></html>
<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><script defer="defer" src="/static/js/main.cb9bf782.js"></script><link href="/static/css/main.b8d8739f.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"><div id="spin-mask"><noscript><style>#spin-mask{display:none!important}</style></noscript><style>@keyframes _doc-spin{to{transform:rotate(360deg)}}#spin-mask{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:9999}#spin-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#spin-container .spinner{box-sizing:border-box;display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25rem solid currentColor;border-right-color:transparent;color:rgba(108,117,125,.75);border-radius:50%;animation:.75s linear infinite _doc-spin}</style><div id="spin-container"><div class="spinner"></div></div></div></div></body></html>

View File

@ -1,3 +1,5 @@
$link-hover-decoration: none;
$enable-negative-margins: true;
$blue: #0033FF !default;
$placeholder-opacity-max: .2;
$placeholder-opacity-min: .1;

View File

@ -102,6 +102,7 @@ export interface ModifyUserReq {
}
export interface UserInfoBase {
id?: string;
avatar: any;
username: string;
display_name: string;

View File

@ -10,7 +10,7 @@ import { marked } from 'marked';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
import { usePageUsers, useReportModal } from '@/hooks';
import { matchedUsers, parseUserInfo, scrollTop } from '@/utils';
import { matchedUsers, parseUserInfo, scrollTop, bgFadeOut } from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import {
useQueryComments,
@ -27,7 +27,6 @@ import './index.scss';
const Comment = ({ objectId, mode, commentId }) => {
const pageUsers = usePageUsers();
const [pageIndex, setPageIndex] = useState(0);
const [comments, setComments] = useState<any>([]);
const [visibleComment, setVisibleComment] = useState(false);
const pageSize = pageIndex === 0 ? 3 : 15;
const { data, mutate } = useQueryComments({
@ -36,6 +35,7 @@ const Comment = ({ objectId, mode, commentId }) => {
page: pageIndex,
page_size: pageSize,
});
const [comments, setComments] = useState<any>([]);
const reportModal = useReportModal();
@ -44,6 +44,7 @@ const Comment = ({ objectId, mode, commentId }) => {
if (pageIndex === 0 && co.comment_id === commentId) {
setTimeout(() => {
scrollTop(el);
bgFadeOut(el);
}, 100);
}
}, []);
@ -74,6 +75,9 @@ const Comment = ({ objectId, mode, commentId }) => {
}, [data]);
const handleReply = (id) => {
if (!tryNormalLogged(true)) {
return;
}
setComments(
comments.map((item) => {
if (item.comment_id === id) {
@ -99,6 +103,9 @@ const Comment = ({ objectId, mode, commentId }) => {
const users = matchedUsers(item.value);
const userNames = unionBy(users.map((user) => user.userName));
const html = marked.parse(parseUserInfo(item.value));
if (!item.value || !html) {
return;
}
const params = {
object_id: objectId,
original_text: item.value,
@ -163,9 +170,8 @@ const Comment = ({ objectId, mode, commentId }) => {
deleteComment(id).then(() => {
if (pageIndex === 0) {
mutate();
} else {
setComments(comments.filter((item) => item.comment_id !== id));
}
setComments(comments.filter((item) => item.comment_id !== id));
});
},
});
@ -302,7 +308,9 @@ const Comment = ({ objectId, mode, commentId }) => {
variant="link"
className="p-0 fs-14 btn-no-border"
onClick={() => {
setVisibleComment(!visibleComment);
if (tryNormalLogged(true)) {
setVisibleComment(!visibleComment);
}
}}>
{t('btn_add_comment')}
</Button>

View File

@ -251,7 +251,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
<Form.Group controlId="editor.imgDescription" className="mb-3">
<Form.Label>
{t('image.form_image.fields.description.label')}
{t('image.form_image.fields.desc.label')}
</Form.Label>
<Form.Control
type="text"

View File

@ -7,28 +7,31 @@ import {
useImperativeHandle,
} from 'react';
import { marked } from 'marked';
import { markdownToHtml } from '@/services';
import { htmlRender } from './utils';
let scrollTop = 0;
marked.setOptions({
breaks: true,
});
let renderTimer;
const Index = ({ value }, ref) => {
const [html, setHtml] = useState('');
const previewRef = useRef<HTMLDivElement>(null);
const renderMarkdown = (markdown) => {
clearTimeout(renderTimer);
const timeout = renderTimer ? 1000 : 0;
renderTimer = setTimeout(() => {
markdownToHtml(markdown).then((resp) => {
scrollTop = previewRef.current?.scrollTop || 0;
setHtml(resp);
});
}, timeout);
};
useEffect(() => {
const previewHtml = marked(value).replace(
/<img/gi,
'<img referrerpolicy="no-referrer"',
);
scrollTop = previewRef.current?.scrollTop || 0;
setHtml(previewHtml);
renderMarkdown(value);
}, [value]);
useEffect(() => {
if (!html) {
return;

View File

@ -56,6 +56,7 @@ const ToolItem: FC<IProps> = (props) => {
disable ? 'disabled' : ''
} `}
disabled={disable}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
if (typeof onClick === 'function') {

View File

@ -113,4 +113,20 @@ export function htmlRender(el: HTMLElement | null) {
});
},
);
el.querySelectorAll('table').forEach((table) => {
if (
(table.parentNode as HTMLDivElement)?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
}

View File

@ -5,12 +5,10 @@ import { Trans } from 'react-i18next';
import dayjs from 'dayjs';
import { siteInfoStore } from '@/stores';
import { DEFAULT_SITE_NAME } from '@/common/constants';
const Index = () => {
const fullYear = dayjs().format('YYYY');
const siteName =
siteInfoStore((state) => state.siteInfo.name) || DEFAULT_SITE_NAME;
const siteName = siteInfoStore((state) => state.siteInfo.name);
const cc = `${fullYear} ${siteName}`;
return (
<footer className="bg-light py-3">

View File

@ -19,6 +19,7 @@ import {
import classnames from 'classnames';
import { floppyNavigation } from '@/utils';
import {
loggedUserInfoStore,
siteInfoStore,
@ -27,8 +28,6 @@ import {
themeSettingStore,
} from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias';
import { DEFAULT_SITE_NAME } from '@/common/constants';
import NavItems from './components/NavItems';
@ -36,7 +35,7 @@ import './index.scss';
const Header: FC = () => {
const navigate = useNavigate();
const { user, clear } = loggedUserInfoStore();
const { user, clear: clearUserStore } = loggedUserInfoStore();
const { t } = useTranslation();
const [urlSearch] = useSearchParams();
const q = urlSearch.get('q');
@ -60,8 +59,14 @@ const Header: FC = () => {
const handleLogout = async () => {
await logout();
clear();
navigate(RouteAlias.home);
clearUserStore();
window.location.replace(window.location.href);
};
const onLoginClick = (evt) => {
evt.preventDefault();
floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true });
});
};
useEffect(() => {
@ -116,7 +121,7 @@ const Header: FC = () => {
/>
</>
) : (
<span>{siteInfo.name || DEFAULT_SITE_NAME}</span>
<span>{siteInfo.name}</span>
)}
</Navbar.Brand>
@ -132,6 +137,7 @@ const Header: FC = () => {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href="/users/login">
{t('btns.login')}
</Button>
@ -219,6 +225,7 @@ const Header: FC = () => {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href="/users/login">
{t('btns.login')}
</Button>

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { loginToContinueStore, siteInfoStore } from '@/stores';
interface IProps {
visible: boolean;
}
const Index: React.FC<IProps> = ({ visible = false }) => {
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { update: updateStore } = loginToContinueStore();
const { siteInfo } = siteInfoStore((_) => _);
const closeModal = () => {
updateStore({ show: false });
};
const linkClick = (evt) => {
evt.stopPropagation();
closeModal();
};
return (
<Modal
title="LoginToContinue"
show={visible}
onHide={closeModal}
centered
fullscreen="sm-down">
<Modal.Header closeButton>
<Modal.Title as="h5">{t('login_to_continue')}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-5">
<div className="d-flex flex-column align-items-center text-center text-body">
<h3>{t('page_title', { site_name: siteInfo.name })}</h3>
<p>{siteInfo.description}</p>
</div>
<div className="d-grid gap-2">
<Link
to="/users/login"
className="btn btn-primary"
onClick={linkClick}>
{t('login', { keyPrefix: 'btns' })}
</Link>
<Link
to="/users/register"
className="btn btn-link"
onClick={linkClick}>
{t('signup', { keyPrefix: 'btns' })}
</Link>
</div>
</Modal.Body>
</Modal>
);
};
export default Index;

View File

@ -1,6 +1,7 @@
import DefaultModal from './Modal';
import confirm, { Config } from './Confirm';
import PicAuthCodeModal from './PicAuthCodeModal';
import LoginToContinueModal from './LoginToContinueModal';
type ModalType = typeof DefaultModal & {
confirm: (config: Config) => void;
@ -14,3 +15,4 @@ Modal.confirm = function (props: Config) {
export default Modal;
export { PicAuthCodeModal };
export { LoginToContinueModal };

View File

@ -41,10 +41,10 @@ const Index: FC<IProps> = ({
const navigate = useNavigate();
const reportModal = useReportModal();
const refershQuestion = () => {
const refreshQuestion = () => {
callback?.('default');
};
const closeModal = useReportModal(refershQuestion);
const closeModal = useReportModal(refreshQuestion);
const editUrl =
type === 'answer' ? `/posts/${qid}/${aid}/edit` : `/posts/${qid}/edit`;
@ -97,7 +97,7 @@ const Index: FC<IProps> = ({
deleteAnswer({
id: aid,
}).then(() => {
// refersh page
// refresh page
toast.onShow({
msg: t('tip_answer_deleted'),
variant: 'success',
@ -132,7 +132,7 @@ const Index: FC<IProps> = ({
msg: t('success', { keyPrefix: 'question_detail.reopen' }),
variant: 'success',
});
refershQuestion();
refreshQuestion();
});
},
});

View File

@ -13,6 +13,7 @@ import {
Empty,
BaseUserCard,
QueryGroup,
QuestionListLoader,
} from '@/components';
import { useQuestionList } from '@/services';
@ -64,69 +65,73 @@ const QuestionList: FC<Props> = ({ source }) => {
/>
</div>
<ListGroup className="rounded-0">
{listData?.list?.map((li) => {
return (
<ListGroup.Item
key={li.id}
className="bg-transparent py-3 px-0 border-start-0 border-end-0">
<h5 className="text-wrap text-break">
<NavLink
to={pathFactory.questionLanding(li.id, li.url_title)}
className="link-dark">
{li.title}
{li.status === 2 ? ` [${t('closed')}]` : ''}
</NavLink>
</h5>
<div className="d-flex flex-column flex-md-row align-items-md-center fs-14 mb-2 text-secondary">
<div className="d-flex">
<BaseUserCard
data={li.operator}
showAvatar={false}
className="me-1"
/>
<FormatTime
time={li.operated_at}
className="text-secondary ms-1"
preFix={t(li.operation_type)}
/>
</div>
<div className="ms-0 ms-md-3 mt-2 mt-md-0">
<span>
<Icon name="hand-thumbs-up-fill" />
<em className="fst-normal ms-1">{li.vote_count}</em>
</span>
<span
className={`ms-3 ${
li.accepted_answer_id >= 1 ? 'text-success' : ''
}`}>
<Icon
name={
li.accepted_answer_id >= 1
? 'check-circle-fill'
: 'chat-square-text-fill'
}
{isLoading ? (
<QuestionListLoader />
) : (
listData?.list?.map((li) => {
return (
<ListGroup.Item
key={li.id}
className="bg-transparent py-3 px-0 border-start-0 border-end-0">
<h5 className="text-wrap text-break">
<NavLink
to={pathFactory.questionLanding(li.id, li.url_title)}
className="link-dark">
{li.title}
{li.status === 2 ? ` [${t('closed')}]` : ''}
</NavLink>
</h5>
<div className="d-flex flex-column flex-md-row align-items-md-center fs-14 mb-2 text-secondary">
<div className="d-flex">
<BaseUserCard
data={li.operator}
showAvatar={false}
className="me-1"
/>
<em className="fst-normal ms-1">{li.answer_count}</em>
</span>
<span className="summary-stat ms-3">
<Icon name="eye-fill" />
<em className="fst-normal ms-1">{li.view_count}</em>
</span>
<FormatTime
time={li.operated_at}
className="text-secondary ms-1"
preFix={t(li.operation_type)}
/>
</div>
<div className="ms-0 ms-md-3 mt-2 mt-md-0">
<span>
<Icon name="hand-thumbs-up-fill" />
<em className="fst-normal ms-1">{li.vote_count}</em>
</span>
<span
className={`ms-3 ${
li.accepted_answer_id >= 1 ? 'text-success' : ''
}`}>
<Icon
name={
li.accepted_answer_id >= 1
? 'check-circle-fill'
: 'chat-square-text-fill'
}
/>
<em className="fst-normal ms-1">{li.answer_count}</em>
</span>
<span className="summary-stat ms-3">
<Icon name="eye-fill" />
<em className="fst-normal ms-1">{li.view_count}</em>
</span>
</div>
</div>
</div>
<div className="question-tags m-n1">
{Array.isArray(li.tags)
? li.tags.map((tag) => {
return (
<Tag key={tag.slug_name} className="m-1" data={tag} />
);
})
: null}
</div>
</ListGroup.Item>
);
})}
<div className="question-tags m-n1">
{Array.isArray(li.tags)
? li.tags.map((tag) => {
return (
<Tag key={tag.slug_name} className="m-1" data={tag} />
);
})
: null}
</div>
</ListGroup.Item>
);
})
)}
</ListGroup>
{count <= 0 && !isLoading && <Empty />}
<div className="mt-4 mb-2 d-flex justify-content-center">

View File

@ -0,0 +1,36 @@
import { FC, memo } from 'react';
import { ListGroupItem } from 'react-bootstrap';
interface Props {
count?: number;
}
const Index: FC<Props> = ({ count = 10 }) => {
const list = new Array(count).fill(0).map((v, i) => v + i);
return (
<>
{list.map((v) => (
<ListGroupItem
className="bg-transparent py-3 px-0 border-start-0 border-end-0 placeholder-glow"
key={v}>
<div
className="placeholder w-100 h5 align-top"
style={{ height: '24px' }}
/>
<div
className="placeholder w-75 d-block align-top mb-2"
style={{ height: '21px' }}
/>
<div
className="placeholder w-50 align-top"
style={{ height: '24px' }}
/>
</ListGroupItem>
))}
</>
);
};
export default memo(Index);

View File

@ -1,10 +1,4 @@
.tag-selector-wrap {
.btn {
&.warning {
-webkit-animation: tag-input-warning 2s;
animation: tag-input-warning 2s;
}
}
.dropdown-menu {
min-width: 15rem;
}
@ -18,23 +12,5 @@
color: #212529;
background-color: #e9ecef;
}
@-webkit-keyframes tag-input-warning {
0% {
background-color: #ffc107;
}
100% {
background-color: #f8f9fa;
}
}
@keyframes tag-input-warning {
0% {
background-color: #ffc107;
}
100% {
background-color: #f8f9fa;
}
}
}

View File

@ -188,7 +188,7 @@ const TagSelector: FC<IProps> = ({
key={item.slug_name}
className={classNames(
'm-1 text-nowrap d-flex align-items-center',
index === repeatIndex && 'warning',
index === repeatIndex && 'bg-fade-out',
)}
variant={`outline-${
item.reserved ? 'danger' : item.recommend ? 'dark' : 'secondary'

View File

@ -0,0 +1,49 @@
import { FC, memo } from 'react';
import { Col, Card } from 'react-bootstrap';
interface Props {
count?: number;
}
const Index: FC<Props> = ({ count = 20 }) => {
const list = new Array(count).fill(0).map((v, i) => v + i);
return (
<>
{list.map((v) => (
<Col
key={v}
xs={12}
lg={3}
md={4}
sm={6}
className="mb-4 placeholder-glow">
<Card className="h-100">
<Card.Body className="d-flex flex-column align-items-start">
<div
className="placeholder align-top w-25 mb-3"
style={{ height: '24px' }}
/>
<p
className="placeholder fs-14 text-truncate-3 w-100"
style={{ height: '42px' }}
/>
<div className="d-flex align-items-center">
<div
className="placeholder me-2"
style={{ width: '80px', height: '31px' }}
/>
<span
className="placeholder text-secondary fs-14 text-nowrap"
style={{ width: '100px', height: '21px' }}
/>
</div>
</Card.Body>
</Card>
</Col>
))}
</>
);
};
export default memo(Index);

View File

@ -30,6 +30,8 @@ import DiffContent from './DiffContent';
import Customize from './Customize';
import CustomizeTheme from './CustomizeTheme';
import PageTags from './PageTags';
import QuestionListLoader from './QuestionListLoader';
import TagsLoader from './TagsLoader';
export {
Avatar,
@ -66,5 +68,7 @@ export {
Customize,
CustomizeTheme,
PageTags,
QuestionListLoader,
TagsLoader,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -14,6 +14,9 @@ const usePageUsers = () => {
getUsers,
setUsers: (data: Types.PageUser | Types.PageUser[]) => {
if (data instanceof Array) {
if (data.length === 0) {
return;
}
setUsers(uniqBy([...users, ...data], 'userName'));
globalUsers = uniqBy([...globalUsers, ...data], 'userName');
} else {

View File

@ -95,13 +95,13 @@ const useTagModal = (props: IProps = {}) => {
isInvalid: true,
errorMsg: t('form.fields.slug_name.msg.range'),
};
} else if (/[^a-z0-9+#\-.]/.test(slugName.value)) {
bol = false;
formData.slugName = {
value: slugName.value,
isInvalid: true,
errorMsg: t('form.fields.slug_name.msg.character'),
};
// } else if (/[^a-z0-9+#\-.]/.test(slugName.value)) {
// bol = false;
// formData.slugName = {
// value: slugName.value,
// isInvalid: true,
// errorMsg: t('form.fields.slug_name.msg.character'),
// };
} else {
formData.slugName = {
value: slugName.value,
@ -217,7 +217,9 @@ const useTagModal = (props: IProps = {}) => {
isInvalid={formData.slugName.isInvalid}
/>
<Form.Text as="div">{t('form.fields.slug_name.desc')}</Form.Text>
<Form.Text as="div">
{t('form.fields.slug_name.msg.range')}
</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.slugName.errorMsg}
</Form.Control.Feedback>

View File

@ -18,10 +18,6 @@
}
}
:root {
overflow-y: scroll;
}
html,
body {
padding: 0;
@ -239,6 +235,12 @@ a {
margin-bottom: 0;
}
}
table {
td,
th {
word-break: initial;
}
}
}
.upload-img-wrap {
@ -283,3 +285,16 @@ a {
.pre-line {
white-space: pre-wrap;
}
@keyframes bg-fade-out {
0%,
25% {
background-color: var(--bs-highlight-bg);
}
100% {
background-color: transparent;
}
}
.bg-fade-out {
animation: bg-fade-out 2s ease 0.3s;
}

View File

@ -38,6 +38,7 @@ const Index: FC = () => {
'ui:widget': 'textarea',
'ui:options': {
rows: 10,
className: 'font-monospace',
},
},
};

View File

@ -18,7 +18,7 @@ const Index: FC<Props> = ({ visible, siteUrl = '' }) => {
<p>
<Trans i18nKey="install.ready_description">
If you ever feel like changing more settings, visit
<a href={`${siteUrl}/users/login`}>admin section</a>; find it in the
<a href={`${siteUrl}/users/login`}> admin section</a>; find it in the
site menu.
</Trans>
</p>

View File

@ -23,7 +23,7 @@ const Index: FC<Props> = ({ visible, errorMsg, nextCallback }) => {
<div className="fmt">
<p>
<Trans
i18nKey="install.config_yaml.description"
i18nKey="install.config_yaml.desc"
components={{ 1: <code /> }}
/>
</p>

View File

@ -4,7 +4,7 @@ import { HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import { toastStore } from '@/stores';
import { toastStore, loginToContinueStore } from '@/stores';
import {
Header,
Footer,
@ -13,13 +13,14 @@ import {
CustomizeTheme,
PageTags,
} from '@/components';
import { LoginToContinueModal } from '@/components/Modal';
const Layout: FC = () => {
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const closeToast = () => {
toastClear();
};
const { show: showLoginToContinueModal } = loginToContinueStore();
return (
<HelmetProvider>
<PageTags />
@ -35,6 +36,7 @@ const Layout: FC = () => {
<Toast msg={toastMsg} variant={variant} onClose={closeToast} />
<Footer />
<Customize />
<LoginToContinueModal visible={showLoginToContinueModal} />
</SWRConfig>
</HelmetProvider>
);

View File

@ -1,5 +1,5 @@
import { memo, FC, useEffect, useRef } from 'react';
import { Row, Col, Button } from 'react-bootstrap';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
@ -12,7 +12,7 @@ import {
FormatTime,
htmlRender,
} from '@/components';
import { scrollTop } from '@/utils';
import { scrollTop, bgFadeOut } from '@/utils';
import { AnswerItem } from '@/common/interface';
import { acceptanceAnswer } from '@/services';
@ -54,13 +54,18 @@ const Index: FC<Props> = ({
if (!answerRef?.current) {
return;
}
htmlRender(answerRef.current.querySelector('.fmt'));
if (aid === data.id) {
setTimeout(() => {
const element = answerRef.current;
scrollTop(element);
if (!searchParams.get('commentId')) {
bgFadeOut(answerRef.current);
}
}, 100);
}
htmlRender(answerRef.current.querySelector('.fmt'));
}, [data.id, answerRef.current]);
if (!data?.id) {
return null;
@ -107,8 +112,8 @@ const Index: FC<Props> = ({
)}
</div>
<Row className="mt-4 mb-3">
<Col className="mb-3 mb-md-0">
<div className="d-block d-md-flex flex-wrap mt-4 mb-3">
<div className="mb-3 mb-md-0 me-4 flex-grow-1">
<Operate
qid={data.question_id}
aid={data.id}
@ -119,8 +124,8 @@ const Index: FC<Props> = ({
slugTitle={slugTitle}
callback={callback}
/>
</Col>
<Col lg={3} className="mb-3 mb-md-0">
</div>
<div className="mb-3 mb-md-0 me-4" style={{ minWidth: '196px' }}>
{data.update_user_info &&
data.update_user_info?.username !== data.user_info?.username ? (
<UserCard
@ -145,8 +150,8 @@ const Index: FC<Props> = ({
className="text-secondary fs-14"
/>
)}
</Col>
<Col lg={4}>
</div>
<div style={{ minWidth: '196px' }}>
<UserCard
data={data?.user_info}
time={Number(data.create_time)}
@ -154,8 +159,8 @@ const Index: FC<Props> = ({
isLogged={isLogged}
timelinePath={`/posts/${data.question_id}/${data.id}/timeline`}
/>
</Col>
</Row>
</div>
</div>
<Comment
objectId={data.id}

View File

@ -0,0 +1,124 @@
import { FC, memo } from 'react';
const Index: FC = () => {
return (
<div className="placeholder-glow">
<div className="placeholder w-100 h1 mb-3" style={{ height: '34px' }} />
<div className="placeholder w-75 mb-3" style={{ height: '21px' }} />
<div
className="placeholder w-50 d-block align-top mb-4"
style={{ height: '24px' }}
/>
<div style={{ marginBottom: '2rem' }}>
<p>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-75 d-block align-top"
style={{ height: '24px' }}
/>
</p>
<p>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-100 d-block align-top mb-1"
style={{ height: '24px' }}
/>
<span
className="placeholder w-50 d-block align-top"
style={{ height: '24px' }}
/>
</p>
</div>
<div className="d-flex">
<div
className="placeholder align-top me-3 rounded"
style={{ height: '38px', width: '120px' }}
/>
<div
className="placeholder align-top rounded"
style={{ height: '38px', width: '68px' }}
/>
</div>
<div className="d-block d-md-flex flex-wrap mt-4 mb-3">
<div
className="placeholder mb-3 mb-md-0 me-4"
style={{ height: '21px', width: '40%' }}
/>
<div
style={{ minWidth: '196px', height: '24px' }}
className="placeholder mb-3 me-4 mb-md-0 d-block d-md-none"
/>
<div
style={{ minWidth: '196px', height: '24px' }}
className="placeholder d-block d-md-none"
/>
<div
style={{ minWidth: '196px', height: '40px' }}
className="placeholder mb-3 me-4 mb-md-0 d-none d-md-block"
/>
<div
style={{ minWidth: '196px', height: '40px' }}
className="placeholder d-none d-md-block"
/>
</div>
{[0, 1, 2].map((item, i) => (
<div
className={`border-bottom py-2 ${i === 0 ? 'border-top' : ''}`}
key={item}>
<div className="placeholder w-100 mb-1" style={{ height: '17px' }} />
<div className="placeholder w-50" style={{ height: '17px' }} />
</div>
))}
<div className="d-flex mt-2 mb-4">
<div
className="placeholder align-top me-4"
style={{ height: '21px', width: '140px' }}
/>
<div
className="placeholder align-top"
style={{ height: '21px', width: '140px' }}
/>
</div>
</div>
);
};
export default memo(Index);

View File

@ -1,7 +1,7 @@
import { memo, FC, useState, useEffect, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Row, Col, Button } from 'react-bootstrap';
import { Button } from 'react-bootstrap';
import {
Tag,
@ -12,7 +12,7 @@ import {
FormatTime,
htmlRender,
} from '@/components';
import { formatCount } from '@/utils';
import { formatCount, guard } from '@/utils';
import { following } from '@/services';
import { pathFactory } from '@/router/pathFactory';
@ -33,6 +33,9 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
const handleFollow = (e) => {
e.preventDefault();
if (!guard.tryNormalLogged(true)) {
return;
}
following({
object_id: data?.id,
is_cancel: followed,
@ -122,8 +125,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
}}
/>
<Row className="mt-4 mb-3">
<Col lg={5} className="mb-3 mb-md-0">
<div className="d-block d-md-flex flex-wrap mt-4 mb-3">
<div className="mb-3 mb-md-0 me-4 flex-grow-1">
<Operate
qid={data?.id}
type="question"
@ -134,8 +137,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
isAccepted={Boolean(data?.accepted_answer_id)}
callback={initPage}
/>
</Col>
<Col lg={3} className="mb-3 mb-md-0">
</div>
<div style={{ minWidth: '196px' }} className="mb-3 me-4 mb-md-0">
{data.update_user_info &&
data.update_user_info?.username !== data.user_info?.username ? (
<UserCard
@ -160,8 +163,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
className="text-secondary fs-14"
/>
)}
</Col>
<Col lg={3}>
</div>
<div style={{ minWidth: '196px' }}>
<UserCard
data={data?.user_info}
time={data.create_time}
@ -169,8 +172,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
isLogged={isLogged}
timelinePath={`/posts/${data.id}/timeline`}
/>
</Col>
</Row>
</div>
</div>
<Comment
objectId={data?.id}

View File

@ -17,11 +17,15 @@ const Index: FC<Props> = ({ id }) => {
keyPrefix: 'related_question',
});
const { data } = useSimilarQuestion({
const { data, isLoading } = useSimilarQuestion({
question_id: id,
page_size: 5,
});
if (isLoading) {
return null;
}
return (
<Card>
<Card.Header>{t('title')}</Card.Header>

View File

@ -8,6 +8,7 @@ import classNames from 'classnames';
import { Editor, Modal, TextArea } from '@/components';
import { FormDataType } from '@/common/interface';
import { postAnswer } from '@/services';
import { guard } from '@/utils';
interface Props {
visible?: boolean;
@ -31,9 +32,13 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
},
});
const [showEditor, setShowEditor] = useState<boolean>(visible);
const [focusType, setForceType] = useState('');
const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false);
const handleSubmit = () => {
if (!guard.tryNormalLogged(true)) {
return;
}
if (!formData.content.value) {
setFormData({
content: {
@ -62,6 +67,9 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
};
const clickBtn = () => {
if (!guard.tryNormalLogged(true)) {
return;
}
if (data?.answered && !showEditor) {
Modal.confirm({
title: t('confirm_title'),
@ -81,9 +89,14 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
handleSubmit();
};
const handleFocusForTextArea = () => {
setForceType('answer');
const handleFocusForTextArea = (evt) => {
if (!guard.tryNormalLogged(true)) {
evt.currentTarget.blur();
return;
}
setFocusType('answer');
setShowEditor(true);
setEditorFocusState(true);
};
return (
@ -114,7 +127,7 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
focusType === 'answer' && 'focus',
)}
value={formData.content.value}
autoFocus
autoFocus={editorFocusState}
onChange={(val) => {
setFormData({
content: {
@ -125,10 +138,10 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
});
}}
onFocus={() => {
setForceType('answer');
setFocusType('answer');
}}
onBlur={() => {
setForceType('');
setFocusType('');
}}
/>
)}

View File

@ -4,5 +4,14 @@ import AnswerHead from './AnswerHead';
import RelatedQuestions from './RelatedQuestions';
import WriteAnswer from './WriteAnswer';
import Alert from './Alert';
import ContentLoader from './ContentLoader';
export { Question, Answer, AnswerHead, RelatedQuestions, WriteAnswer, Alert };
export {
Question,
Answer,
AnswerHead,
RelatedQuestions,
WriteAnswer,
Alert,
ContentLoader,
};

View File

@ -27,6 +27,7 @@ import {
RelatedQuestions,
WriteAnswer,
Alert,
ContentLoader,
} from './components';
import './index.scss';
@ -35,7 +36,9 @@ const Index = () => {
const navigate = useNavigate();
const { t } = useTranslation('translation');
const { qid = '', slugPermalink = '' } = useParams();
// Compatible with Permalink
/**
* Note: Compatible with Permalink
*/
let { aid = '' } = useParams();
if (!aid && Pattern.isAnswerId.test(slugPermalink)) {
aid = slugPermalink;
@ -45,6 +48,7 @@ const Index = () => {
const page = Number(urlSearch.get('page') || 0);
const order = urlSearch.get('order') || '';
const [question, setQuestion] = useState<QuestionDetailRes | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [answers, setAnswers] = useState<ListResult<AnswerItem>>({
count: -1,
list: [],
@ -95,15 +99,35 @@ const Index = () => {
};
const getDetail = async () => {
const res = await questionDetail(qid);
if (res) {
// undo
setUsers([
res.user_info,
res?.update_user_info,
res?.last_answered_user_info,
]);
setQuestion(res);
setIsLoading(true);
try {
const res = await questionDetail(qid);
if (res) {
setUsers([
{
id: res.user_info.id,
displayName: res.user_info.display_name,
userName: res.user_info.username,
avatar_url: res.user_info.avatar,
},
{
id: res?.update_user_info?.id,
displayName: res?.update_user_info?.display_name,
userName: res?.update_user_info?.username,
avatar_url: res?.update_user_info?.avatar,
},
{
id: res?.last_answered_user_info?.id,
displayName: res?.last_answered_user_info?.display_name,
userName: res?.last_answered_user_info?.username,
avatar_url: res?.last_answered_user_info?.avatar,
},
]);
setQuestion(res);
}
setIsLoading(false);
} catch (e) {
setIsLoading(false);
}
};
@ -114,7 +138,6 @@ const Index = () => {
}, 1000);
return;
}
if (type === 'default') {
window.scrollTo(0, 0);
getDetail();
@ -141,6 +164,7 @@ const Index = () => {
if (!qid) {
return;
}
window.scrollTo(0, 0);
getDetail();
requestAnswers();
}, [qid]);
@ -162,13 +186,17 @@ const Index = () => {
{question?.operation?.operation_type && (
<Alert data={question.operation} />
)}
<Question
data={question}
initPage={initPage}
hasAnswer={answers.count > 0}
isLogged={isLogged}
/>
{answers.count > 0 && (
{isLoading ? (
<ContentLoader />
) : (
<Question
data={question}
initPage={initPage}
hasAnswer={answers.count > 0}
isLogged={isLogged}
/>
)}
{!isLoading && answers.count > 0 && (
<>
<AnswerHead count={answers.count} order={order} />
{answers?.list?.map((item) => {
@ -188,7 +216,7 @@ const Index = () => {
</>
)}
{Math.ceil(answers.count / 15) > 1 && (
{!isLoading && Math.ceil(answers.count / 15) > 1 && (
<div className="d-flex justify-content-center answer-item pt-4">
<Pagination
currentPage={Number(page || 1)}
@ -198,9 +226,8 @@ const Index = () => {
</div>
)}
{!question?.operation?.operation_type && (
{!isLoading && !question?.operation?.operation_type && (
<WriteAnswer
visible={answers.count === 0}
data={{
qid,
answered: question?.answered,

View File

@ -1,17 +1,17 @@
import { FC } from 'react';
import { Container, Row, Col } from 'react-bootstrap';
import { useMatch } from 'react-router-dom';
import { useMatch, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { FollowingTags } from '@/components';
import QuestionList from '@/components/QuestionList';
import HotQuestions from '@/components/HotQuestions';
import { siteInfoStore } from '@/stores';
import { siteInfoStore, loggedUserInfoStore } from '@/stores';
const Questions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { user: loggedUser } = loggedUserInfoStore((_) => _);
const isIndexPage = useMatch('/');
let pageTitle = t('questions', { keyPrefix: 'page_title' });
let slogan = '';
@ -29,7 +29,26 @@ const Questions: FC = () => {
<QuestionList source="questions" />
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<FollowingTags />
{!loggedUser.access_token && (
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">
{t('page_title', {
keyPrefix: 'login',
site_name: siteInfo.name,
})}
</h5>
<p className="card-text">{siteInfo.description}</p>
<Link to="/users/login" className="btn btn-primary">
{t('login', { keyPrefix: 'btns' })}
</Link>
<Link to="/users/register" className="btn btn-link ms-2">
{t('signup', { keyPrefix: 'btns' })}
</Link>
</div>
</div>
)}
{loggedUser.access_token && <FollowingTags />}
<HotQuestions />
</Col>
</Row>

View File

@ -0,0 +1,46 @@
import { FC, memo } from 'react';
import { ListGroupItem } from 'react-bootstrap';
interface Props {
count?: number;
}
const Index: FC<Props> = ({ count = 10 }) => {
const list = new Array(count).fill(0).map((v, i) => v + i);
return (
<>
{list.map((v) => (
<ListGroupItem
className="py-3 px-0 border-start-0 border-end-0 bg-transparent placeholder-glow"
key={v}>
<div className="mb-2">
<div
className="placeholder me-2"
style={{ height: '25px', width: '30px' }}
/>
<div
className="h5 mb-0 w-75 placeholder"
style={{ height: '25px' }}
/>
</div>
<div
className="placeholder w-50 h5 align-top mb-2"
style={{ height: '21px' }}
/>
<div
className="placeholder w-100 d-block align-top mb-2"
style={{ height: '42px' }}
/>
<div
className="placeholder w-25 align-top"
style={{ height: '24px' }}
/>
</ListGroupItem>
))}
</>
);
};
export default memo(Index);

View File

@ -3,5 +3,6 @@ import SearchItem from './SearchItem';
import Tips from './Tips';
import Empty from './Empty';
import SearchHead from './SearchHead';
import ListLoader from './ListLoader';
export { Head, SearchItem, Tips, Empty, SearchHead };
export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader };

View File

@ -7,7 +7,14 @@ import { usePageTags } from '@/hooks';
import { Pagination } from '@/components';
import { useSearch } from '@/services';
import { Head, SearchHead, SearchItem, Tips, Empty } from './components';
import {
Head,
SearchHead,
SearchItem,
Tips,
Empty,
ListLoader,
} from './components';
const Index = () => {
const { t } = useTranslation('translation');
@ -38,9 +45,13 @@ const Index = () => {
<Head data={extra} />
<SearchHead sort={order} count={count} />
<ListGroup className="rounded-0 mb-5">
{list?.map((item) => {
return <SearchItem key={item.object.id} data={item} />;
})}
{isLoading ? (
<ListLoader />
) : (
list?.map((item) => {
return <SearchItem key={item.object.id} data={item} />;
})
)}
</ListGroup>
{!isLoading && !list?.length && <Empty />}

View File

@ -9,7 +9,7 @@ import { FollowingTags } from '@/components';
import { useTagInfo, useFollow, useQuerySynonymsTags } from '@/services';
import QuestionList from '@/components/QuestionList';
import HotQuestions from '@/components/HotQuestions';
import { escapeRemove } from '@/utils';
import { escapeRemove, guard } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
const Questions: FC = () => {
@ -19,10 +19,13 @@ const Questions: FC = () => {
const curTagName = routeParams.tagName || '';
const [tagInfo, setTagInfo] = useState<any>({});
const [tagFollow, setTagFollow] = useState<Type.FollowParams>();
const { data: tagResp } = useTagInfo({ name: curTagName });
const { data: tagResp, isLoading } = useTagInfo({ name: curTagName });
const { data: followResp } = useFollow(tagFollow);
const { data: synonymsRes } = useQuerySynonymsTags(tagInfo?.tag_id);
const toggleFollow = () => {
if (!guard.tryNormalLogged(true)) {
return;
}
setTagFollow({
is_cancel: tagInfo.is_follower,
object_id: tagInfo.tag_id,
@ -73,37 +76,52 @@ const Questions: FC = () => {
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
<Col xxl={7} lg={8} sm={12}>
<div className="tag-box mb-5">
<h3 className="mb-3">
<Link
to={pathFactory.tagLanding(tagInfo.slug_name)}
replace
className="link-dark">
{tagInfo.display_name}
</Link>
</h3>
{isLoading ? (
<div className="tag-box mb-5 placeholder-glow">
<div className="mb-3 h3 placeholder" style={{ width: '120px' }} />
<p
className="placeholder w-100 d-block align-top"
style={{ height: '24px' }}
/>
<p className="text-break">
{escapeRemove(tagInfo.excerpt) || t('no_desc')}
<Link to={pathFactory.tagInfo(curTagName)} className="ms-1">
[{t('more')}]
</Link>
</p>
<div className="box-ft">
{tagInfo.is_follower ? (
<Button variant="primary" onClick={() => toggleFollow()}>
{t('button_following')}
</Button>
) : (
<Button
variant="outline-primary"
onClick={() => toggleFollow()}>
{t('button_follow')}
</Button>
)}
<div
className="placeholder d-block align-top"
style={{ height: '38px', width: '100px' }}
/>
</div>
</div>
) : (
<div className="tag-box mb-5">
<h3 className="mb-3">
<Link
to={pathFactory.tagLanding(tagInfo.slug_name)}
replace
className="link-dark">
{tagInfo.display_name}
</Link>
</h3>
<p className="text-break">
{escapeRemove(tagInfo.excerpt) || t('no_desc')}
<Link to={pathFactory.tagInfo(curTagName)} className="ms-1">
[{t('more')}]
</Link>
</p>
<div className="box-ft">
{tagInfo.is_follower ? (
<Button variant="primary" onClick={() => toggleFollow()}>
{t('button_following')}
</Button>
) : (
<Button
variant="outline-primary"
onClick={() => toggleFollow()}>
{t('button_follow')}
</Button>
)}
</div>
</div>
)}
<QuestionList source="tag" />
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">

View File

@ -4,8 +4,9 @@ import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { Tag, Pagination, QueryGroup } from '@/components';
import { Tag, Pagination, QueryGroup, TagsLoader } from '@/components';
import { formatCount } from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import { useQueryTags, following } from '@/services';
const sortBtns = ['popular', 'name', 'newest'];
@ -19,7 +20,11 @@ const Tags = () => {
const sort = urlSearch.get('sort');
const pageSize = 20;
const { data: tags, mutate } = useQueryTags({
const {
data: tags,
mutate,
isLoading,
} = useQueryTags({
page,
page_size: pageSize,
...(searchTag ? { slug_name: searchTag } : {}),
@ -31,6 +36,9 @@ const Tags = () => {
};
const handleFollow = (tag) => {
if (!tryNormalLogged(true)) {
return;
}
following({
object_id: tag.tag_id,
is_cancel: tag.is_follower,
@ -69,39 +77,43 @@ const Tags = () => {
<Col className="mt-4" xxl={10} sm={12}>
<Row>
{tags?.list?.map((tag) => (
<Col
key={tag.slug_name}
xs={12}
lg={3}
md={4}
sm={6}
className="mb-4">
<Card className="h-100">
<Card.Body className="d-flex flex-column align-items-start">
<Tag className="mb-3" data={tag} />
{isLoading ? (
<TagsLoader />
) : (
tags?.list?.map((tag) => (
<Col
key={tag.slug_name}
xs={12}
lg={3}
md={4}
sm={6}
className="mb-4">
<Card className="h-100">
<Card.Body className="d-flex flex-column align-items-start">
<Tag className="mb-3" data={tag} />
<p className="fs-14 flex-fill text-break text-wrap text-truncate-3">
{tag.original_text}
</p>
<div className="d-flex align-items-center">
<Button
className={`me-2 ${tag.is_follower ? 'active' : ''}`}
variant="outline-primary"
size="sm"
onClick={() => handleFollow(tag)}>
{tag.is_follower
? t('button_following')
: t('button_follow')}
</Button>
<span className="text-secondary fs-14 text-nowrap">
{formatCount(tag.question_count)} {t('tag_label')}
</span>
</div>
</Card.Body>
</Card>
</Col>
))}
<p className="fs-14 flex-fill text-break text-wrap text-truncate-3">
{tag.original_text}
</p>
<div className="d-flex align-items-center">
<Button
className={`me-2 ${tag.is_follower ? 'active' : ''}`}
variant="outline-primary"
size="sm"
onClick={() => handleFollow(tag)}>
{tag.is_follower
? t('button_following')
: t('button_follow')}
</Button>
<span className="text-secondary fs-14 text-nowrap">
{formatCount(tag.question_count)} {t('tag_label')}
</span>
</div>
</Card.Body>
</Card>
</Col>
))
)}
</Row>
<div className="d-flex justify-content-center">
<Pagination

View File

@ -3,6 +3,8 @@ import { Container, Form, Button, Col } from 'react-bootstrap';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { RouteAlias } from '@/router/alias';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
import { usePageTags } from '@/hooks';
import type {
LoginReqParams,
@ -10,11 +12,13 @@ import type {
FormDataType,
} from '@/common/interface';
import { Unactivate } from '@/components';
import { loggedUserInfoStore, loginSettingStore } from '@/stores';
import {
loggedUserInfoStore,
loginSettingStore,
siteInfoStore,
} from '@/stores';
import { guard, floppyNavigation, handleFormError } from '@/utils';
import { login, checkImgCode } from '@/services';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
import { RouteAlias } from '@/router/alias';
import { PicAuthCodeModal } from '@/components/Modal';
import Storage from '@/utils/storage';
@ -23,8 +27,8 @@ const Index: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [refresh, setRefresh] = useState(0);
const updateUser = loggedUserInfoStore((state) => state.update);
const storeUser = loggedUserInfoStore((state) => state.user);
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const { user: storeUser, update: updateUser } = loggedUserInfoStore((_) => _);
const loginSetting = loginSettingStore((state) => state.login);
const [formData, setFormData] = useState<FormDataType>({
e_mail: {
@ -91,6 +95,14 @@ const Index: React.FC = () => {
return bol;
};
const handleLoginRedirect = () => {
const redirect = Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home;
Storage.remove(REDIRECT_PATH_STORAGE_KEY);
floppyNavigation.navigate(redirect, () => {
navigate(redirect, { replace: true });
});
};
const handleLogin = (event?: any) => {
if (event) {
event.preventDefault();
@ -113,24 +125,12 @@ const Index: React.FC = () => {
setStep(2);
setRefresh((pre) => pre + 1);
} else {
const path =
Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home;
Storage.remove(REDIRECT_PATH_STORAGE_KEY);
floppyNavigation.navigate(path, () => {
navigate(path, { replace: true });
});
handleLoginRedirect();
}
setModalState(false);
})
.catch((err) => {
// if (err.isError && err.key) {
// formData[err.key].isInvalid = true;
// formData[err.key].errorMsg = err.value;
// if (err.key.indexOf('captcha') < 0) {
// setModalState(false);
// }
// }
if (err.isError) {
const data = handleFormError(err, formData);
if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) {
@ -174,7 +174,9 @@ const Index: React.FC = () => {
});
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
<h3 className="text-center mb-5">
{t('page_title', { site_name: siteName })}
</h3>
{step === 1 && (
<Col className="mx-auto" md={3}>
<Form noValidate onSubmit={handleSubmit}>

View File

@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { Unactivate } from '@/components';
import { siteInfoStore } from '@/stores';
import SignUpForm from './components/SignUpForm';
const Index: React.FC = () => {
const [showForm, setShowForm] = useState(true);
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { name: siteName } = siteInfoStore((_) => _.siteInfo);
const onStep = () => {
setShowForm((bol) => !bol);
};
@ -19,7 +20,9 @@ const Index: React.FC = () => {
});
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
<h3 className="text-center mb-5">
{t('page_title', { site_name: siteName })}
</h3>
{showForm ? (
<SignUpForm callback={onStep} />
) : (

View File

@ -16,7 +16,7 @@ const Index: FC<{
}) => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const callGuards = () => {
if (onEnter) {
const gr = onEnter();
const redirectUrl = gr.redirect;
@ -26,8 +26,10 @@ const Index: FC<{
});
}
}
};
useEffect(() => {
callGuards();
}, [location]);
return (
<>
{/* Route Guard */}

View File

@ -6,14 +6,12 @@ const tagLanding = (slugName: string) => {
if (!slugName) {
return '/tags';
}
slugName = slugName.toLowerCase();
return urlcat('/tags/:slugName', { slugName });
};
const tagInfo = (slugName: string) => {
if (!slugName) {
return '/tags';
}
slugName = slugName.toLowerCase();
return urlcat('/tags/:slugName/info', { slugName });
};
const tagEdit = (tagId: string) => {

View File

@ -19,12 +19,18 @@ export const useQueryQuestionByTitle = (title) => {
};
export const useQueryTags = (params) => {
return useSWR<Type.ListResult>(
const { data, error, mutate } = useSWR<Type.ListResult>(
`/answer/api/v1/tags/page?${qs.stringify(params, {
skipNulls: true,
})}`,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const useQueryRevisions = (object_id: string | undefined) => {
@ -262,3 +268,8 @@ export const unsubscribe = (code: string) => {
const apiUrl = '/answer/api/v1/user/email/notification';
return request.put(apiUrl, { code });
};
export const markdownToHtml = (content: string) => {
const apiUrl = '/answer/api/v1/post/render';
return request.post(apiUrl, { content });
};

View File

@ -9,6 +9,7 @@ import brandingStore from './branding';
import pageTagStore from './pageTags';
import customizeStore from './customize';
import themeSettingStore from './themeSetting';
import loginToContinueStore from './loginToContinue';
export {
toastStore,
@ -21,4 +22,5 @@ export {
customizeStore,
themeSettingStore,
seoSettingStore,
loginToContinueStore,
};

View File

@ -0,0 +1,16 @@
import create from 'zustand';
interface IProps {
show: boolean;
update: (params: { show: boolean }) => void;
}
const loginToContinueStore = create<IProps>((set) => ({
show: false,
update: (params) =>
set({
...params,
}),
}));
export default loginToContinueStore;

View File

@ -1,6 +1,7 @@
import create from 'zustand';
import { AdminSettingsGeneral } from '@/common/interface';
import { DEFAULT_SITE_NAME } from '@/common/constants';
interface SiteInfoType {
siteInfo: AdminSettingsGeneral;
@ -9,7 +10,7 @@ interface SiteInfoType {
const siteInfo = create<SiteInfoType>((set) => ({
siteInfo: {
name: '',
name: DEFAULT_SITE_NAME,
description: '',
short_description: '',
site_url: '',
@ -19,6 +20,9 @@ const siteInfo = create<SiteInfoType>((set) => ({
update: (params) =>
set((_) => {
const o = { ..._.siteInfo, ...params };
if (!o.name) {
o.name = DEFAULT_SITE_NAME;
}
return {
siteInfo: o,
};

View File

@ -36,6 +36,15 @@ function scrollTop(element) {
});
}
const bgFadeOut = (el) => {
if (el && !el.classList.contains('bg-fade-out')) {
el.classList.add('bg-fade-out');
setTimeout(() => {
el.classList.remove('bg-fade-out');
}, 3200);
}
};
/**
* Extract user info from markdown
* @param markdown string
@ -209,6 +218,7 @@ export {
thousandthDivision,
formatCount,
scrollTop,
bgFadeOut,
matchedUsers,
parseUserInfo,
formatUptime,

View File

@ -33,10 +33,13 @@ const navigate = (pathname: string, callback: Function) => {
/**
* auto navigate to login page with redirect info
*/
const navigateToLogin = () => {
storageLoginRedirect();
const navigateToLogin = (callback?: Function) => {
navigate(RouteAlias.login, () => {
window.location.replace(RouteAlias.login);
if (callback) {
callback(RouteAlias.login);
} else {
window.location.replace(RouteAlias.login);
}
});
};

View File

@ -8,6 +8,7 @@ import {
customizeStore,
themeSettingStore,
seoSettingStore,
loginToContinueStore,
} from '@/stores';
import { RouteAlias } from '@/router/alias';
import Storage from '@/utils/storage';
@ -225,7 +226,7 @@ export const tryNormalLogged = (canNavigate: boolean = false) => {
// must assert logged state first and return
if (!us.isLogged) {
if (canNavigate) {
floppyNavigation.navigateToLogin();
loginToContinueStore.getState().update({ show: true });
}
return false;
}

View File

@ -11,7 +11,6 @@ import Storage from './storage';
import { floppyNavigation } from './floppyNavigation';
const baseConfig = {
baseURL: process.env.REACT_APP_API_URL || '',
timeout: 10000,
withCredentials: true,
};