Merge remote-tracking branch 'github/test' into release/1.0.6

# Conflicts:
#	i18n/i18n.yaml
This commit is contained in:
LinkinStars 2023-03-07 10:06:44 +08:00
commit 02b536c2cf
73 changed files with 877 additions and 230 deletions

View File

@ -1,5 +1,4 @@
// Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
// Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
@ -5984,9 +5983,6 @@ const docTemplate = `{
"schema.GetOtherUserInfoResp": {
"type": "object",
"properties": {
"has": {
"type": "boolean"
},
"info": {
"$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp"
}
@ -6986,7 +6982,11 @@ const docTemplate = `{
},
"user_info": {
"description": "user info",
"$ref": "#/definitions/schema.UserBasicInfo"
"allOf": [
{
"$ref": "#/definitions/schema.UserBasicInfo"
}
]
},
"vote_count": {
"type": "integer"
@ -6998,7 +6998,11 @@ const docTemplate = `{
"properties": {
"object": {
"description": "this object",
"$ref": "#/definitions/schema.SearchObject"
"allOf": [
{
"$ref": "#/definitions/schema.SearchObject"
}
]
},
"object_type": {
"description": "object_type",
@ -7511,7 +7515,11 @@ const docTemplate = `{
"properties": {
"avatar": {
"description": "avatar",
"$ref": "#/definitions/schema.AvatarInfo"
"allOf": [
{
"$ref": "#/definitions/schema.AvatarInfo"
}
]
},
"bio": {
"description": "bio",
@ -7688,7 +7696,6 @@ const docTemplate = `{
],
"properties": {
"status": {
"description": "user status",
"type": "string",
"enum": [
"normal",
@ -7698,7 +7705,6 @@ const docTemplate = `{
]
},
"user_id": {
"description": "user id",
"type": "string"
}
}

View File

@ -5972,9 +5972,6 @@
"schema.GetOtherUserInfoResp": {
"type": "object",
"properties": {
"has": {
"type": "boolean"
},
"info": {
"$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp"
}
@ -6974,7 +6971,11 @@
},
"user_info": {
"description": "user info",
"$ref": "#/definitions/schema.UserBasicInfo"
"allOf": [
{
"$ref": "#/definitions/schema.UserBasicInfo"
}
]
},
"vote_count": {
"type": "integer"
@ -6986,7 +6987,11 @@
"properties": {
"object": {
"description": "this object",
"$ref": "#/definitions/schema.SearchObject"
"allOf": [
{
"$ref": "#/definitions/schema.SearchObject"
}
]
},
"object_type": {
"description": "object_type",
@ -7499,7 +7504,11 @@
"properties": {
"avatar": {
"description": "avatar",
"$ref": "#/definitions/schema.AvatarInfo"
"allOf": [
{
"$ref": "#/definitions/schema.AvatarInfo"
}
]
},
"bio": {
"description": "bio",
@ -7676,7 +7685,6 @@
],
"properties": {
"status": {
"description": "user status",
"type": "string",
"enum": [
"normal",
@ -7686,7 +7694,6 @@
]
},
"user_id": {
"description": "user id",
"type": "string"
}
}

View File

@ -488,8 +488,6 @@ definitions:
type: object
schema.GetOtherUserInfoResp:
properties:
has:
type: boolean
info:
$ref: '#/definitions/schema.GetOtherUserInfoByUsernameResp'
type: object
@ -1201,7 +1199,8 @@ definitions:
title:
type: string
user_info:
$ref: '#/definitions/schema.UserBasicInfo'
allOf:
- $ref: '#/definitions/schema.UserBasicInfo'
description: user info
vote_count:
type: integer
@ -1209,7 +1208,8 @@ definitions:
schema.SearchResp:
properties:
object:
$ref: '#/definitions/schema.SearchObject'
allOf:
- $ref: '#/definitions/schema.SearchObject'
description: this object
object_type:
description: object_type
@ -1563,7 +1563,8 @@ definitions:
schema.UpdateInfoRequest:
properties:
avatar:
$ref: '#/definitions/schema.AvatarInfo'
allOf:
- $ref: '#/definitions/schema.AvatarInfo'
description: avatar
bio:
description: bio
@ -1691,7 +1692,6 @@ definitions:
schema.UpdateUserStatusReq:
properties:
status:
description: user status
enum:
- normal
- suspended
@ -1699,7 +1699,6 @@ definitions:
- inactive
type: string
user_id:
description: user id
type: string
required:
- status

8
go.mod
View File

@ -35,13 +35,13 @@ require (
github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05
github.com/spf13/cobra v1.6.1
github.com/stretchr/testify v1.8.1
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a
github.com/swaggo/files v1.0.0
github.com/swaggo/gin-swagger v1.5.3
github.com/swaggo/swag v1.8.7
github.com/swaggo/swag v1.8.10
github.com/tidwall/gjson v1.14.4
github.com/yuin/goldmark v1.4.13
golang.org/x/crypto v0.1.0
golang.org/x/net v0.1.0
golang.org/x/net v0.2.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.14.2
@ -121,7 +121,7 @@ require (
go.uber.org/zap v1.23.0 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/tools v0.2.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect

16
go.sum
View File

@ -668,13 +668,14 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/gin-swagger v1.5.3 h1:8mWmHLolIbrhJJTflsaFoZzRBYVmEE7JZGIq08EiC0Q=
github.com/swaggo/gin-swagger v1.5.3/go.mod h1:3XJKSfHjDMB5dBo/0rrTXidPmgLeqsX89Yp4uA50HpI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.8.7 h1:2K9ivTD3teEO+2fXV6zrZKDqk5IuU2aJtBDo8U7omWU=
github.com/swaggo/swag v1.8.7/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
@ -850,8 +851,8 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -944,11 +945,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -35,6 +35,10 @@ backend:
other: Email and password do not match.
error:
admin:
cannot_update_their_password:
other: You cannot modify your password.
cannot_modify_self_status:
other: You cannot modify your status.
email_or_password_wrong:
other: Email and password do not match.
answer:
@ -81,6 +85,8 @@ backend:
new_password_same_as_previous_setting:
other: The new password is the same as the previous one.
question:
already_deleted:
other: This post has been deleted.
not_found:
other: Question not found.
cannot_deleted:
@ -819,6 +825,7 @@ ui:
approve: Approve
reject: Reject
skip: Skip
discard_draft: Discard draft
search:
title: Search Results
keywords: Keywords
@ -1015,9 +1022,11 @@ ui:
answers: answers
accepted: Accepted
page_404:
http_error: HTTP Error 404
desc: "Unfortunately, this page doesn't exist."
back_home: Back to homepage
page_50X:
http_error: HTTP Error 500
desc: The server encountered an error and could not complete your request.
back_home: Back to homepage
page_maintenance:
@ -1407,6 +1416,10 @@ ui:
reputation: reputation
votes: votes
prompt:
leave_page: "Are you sure you want to leave the page?"
changes_not_save: "Your changes may not be saved."
leave_page: Are you sure you want to leave the page?
changes_not_save: Your changes may not be saved.
draft:
discard_confirm: Are you sure you want to discard your draft?
messages:
post_deleted: This post has been deleted.

View File

@ -1,28 +1,41 @@
# all support language
language_options:
- label: "English(US)"
- label: "English"
value: "en_US"
- label: "Español(ES)"
progress: 100
- label: "Español"
value: "es_ES"
progress: 0
- label: "Português(BR)"
value: "pt_BR"
- label: "Português(PT)"
progress: 0
- label: "Português"
value: "pt_PT"
- label: "Deutsch(DE)"
progress: 0
- label: "Deutsch"
value: "de_DE"
- label: "Français(FR)"
progress: 4
- label: "Français"
value: "fr_FR"
- label: "日本語(JA)"
progress: 100
- label: "日本語"
value: "ja_JP"
- label: "Italiano(IT)"
progress: 0
- label: "Italiano"
value: "it_IT"
- label: "Русский(RU)"
progress: 16
- label: "Русский"
value: "ru_RU"
- label: "简体中文(CN)"
progress: 13
- label: "简体中文"
value: "zh_CN"
- label: "繁體中文(CN)"
progress: 100
- label: "繁體中文"
value: "zh_TW"
- label: "한국어(KO)"
progress: 100
- label: "한국어"
value: "ko_KR"
- label: "Tiếng Việt(VI)"
progress: 0
- label: "Tiếng Việt"
value: "vi_VN"
progress: 0

View File

@ -1,4 +1,5 @@
#The following fields are used for back-end
backend:
base:
success:
@ -80,6 +81,8 @@ backend:
new_password_same_as_previous_setting:
other: 新密码与之前的设置相同
question:
already_deleted:
other: 该内容已被删除
not_found:
other: 问题未找到
cannot_deleted:

View File

@ -21,6 +21,7 @@ const (
QuestionCannotDeleted = "error.question.cannot_deleted"
QuestionCannotClose = "error.question.cannot_close"
QuestionCannotUpdate = "error.question.cannot_update"
QuestionAlreadyDeleted = "error.question.already_deleted"
AnswerNotFound = "error.answer.not_found"
AnswerCannotDeleted = "error.answer.cannot_deleted"
AnswerCannotUpdate = "error.answer.cannot_update"
@ -64,4 +65,6 @@ const (
TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself"
NotAllowedRegistration = "error.user.not_allowed_registration"
SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email"
AdminCannotUpdateTheirPassword = "error.admin.cannot_update_their_password"
AdminCannotModifySelfStatus = "error.admin.cannot_modify_self_status"
)

View File

@ -20,6 +20,8 @@ var GlobalTrans i18n.Translator
type LangOption struct {
Label string `json:"label"`
Value string `json:"value"`
// Translation completion percentage
Progress int `json:"progress"`
}
// DefaultLangOption default language option. If user config the language is default, the language option is admin choose.
@ -94,6 +96,11 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
return nil, fmt.Errorf("i18n file parsing failed: %s", err)
}
LanguageOptions = s.LangOption
for _, option := range LanguageOptions {
if option.Progress != 100 {
option.Label = fmt.Sprintf("%s (%d%%)", option.Label, option.Progress)
}
}
return GlobalTrans, err
}

View File

@ -112,6 +112,18 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) {
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetIsAdminFromContext(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentAdd,
permission.CommentEdit,
permission.CommentDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanAdd = canList[0]
req.CanEdit = canList[1]
req.CanDelete = canList[2]
can, err := cc.rankService.CheckOperationPermission(ctx, req.UserID, permission.CommentEdit, req.CommentID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
@ -122,8 +134,8 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) {
return
}
err = cc.commentService.UpdateComment(ctx, req)
handler.HandleResponse(ctx, err, nil)
resp, err := cc.commentService.UpdateComment(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetCommentWithPage get comment page

View File

@ -404,22 +404,17 @@ func (tc *TemplateController) UserInfo(ctx *gin.Context) {
req := &schema.GetOtherUserInfoByUsernameReq{}
req.Username = username
userinfo, err := tc.templateRenderController.UserInfo(ctx, req)
if err != nil {
tc.Page404(ctx)
return
}
if !userinfo.Has {
tc.Page404(ctx)
return
}
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, username)
siteInfo.Title = fmt.Sprintf("%s - %s", username, siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "homepage.html", siteInfo, gin.H{
"userinfo": userinfo,
"bio": template.HTML(userinfo.Info.BioHTML),
"bio": template.HTML(userinfo.BioHTML),
})
}
@ -451,6 +446,7 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI
if !ok {
data["path"] = ""
}
ctx.Header("X-Frame-Options", "DENY")
ctx.HTML(code, tpl, data)
}

View File

@ -5,6 +5,6 @@ import (
"golang.org/x/net/context"
)
func (q *TemplateRenderController) UserInfo(ctx context.Context, req *schema.GetOtherUserInfoByUsernameReq) (resp *schema.GetOtherUserInfoResp, err error) {
func (q *TemplateRenderController) UserInfo(ctx context.Context, req *schema.GetOtherUserInfoByUsernameReq) (resp *schema.GetOtherUserInfoByUsernameResp, err error) {
return q.userService.GetOtherUserInfoByUsername(ctx, req.Username)
}

View File

@ -157,7 +157,7 @@ func (uc *UserController) RetrievePassWord(ctx *gin.Context) {
return
}
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP())
_, err := uc.userService.RetrievePassWord(ctx, req)
err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
@ -203,6 +203,7 @@ func (uc *UserController) UserLogout(ctx *gin.Context) {
return
}
_ = uc.authService.RemoveUserCacheInfo(ctx, accessToken)
_ = uc.authService.RemoveAdminUserCacheInfo(ctx, accessToken)
handler.HandleResponse(ctx, nil, nil)
}

View File

@ -34,6 +34,8 @@ func (uc *UserAdminController) UpdateUserStatus(ctx *gin.Context) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userService.UpdateUserStatus(ctx, req)
handler.HandleResponse(ctx, err, nil)
}

View File

@ -35,9 +35,11 @@ type Answer struct {
type AnswerSearch struct {
Answer
Order string `json:"order_by" ` // default or updated
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
IncludeDeleted bool `json:"include_deleted"`
LoginUserID string `json:"login_user_id"`
Order string `json:"order_by"` // default or updated
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
}
type AdminAnswerSearch struct {

View File

@ -78,7 +78,7 @@ type InitEnvironmentResp struct {
// InitBaseInfoReq init base info request
type InitBaseInfoReq struct {
Language string `validate:"required,gt=0,lte=30" json:"lang"`
SiteName string `validate:"required,gt=0,lte=30" json:"site_name"`
SiteName string `validate:"required,sanitizer,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=3,lte=30" json:"name"`

View File

@ -206,7 +206,9 @@ func (ar *answerRepo) SearchList(ctx context.Context, search *entity.AnswerSearc
default:
session = session.OrderBy("adopted desc,vote_count desc,created_at asc")
}
session = session.And("status = ?", entity.AnswerStatusAvailable)
if !search.IncludeDeleted {
session = session.And("status = ? OR user_id = ?", entity.AnswerStatusAvailable, search.LoginUserID)
}
session = session.Limit(search.PageSize, offset)
count, err = session.FindAndCount(&rows)

View File

@ -68,3 +68,11 @@ func (cr *captchaRepo) GetCaptcha(ctx context.Context, key string) (captcha stri
}
return captcha, nil
}
func (cr *captchaRepo) DelCaptcha(ctx context.Context, key string) (err error) {
err = cr.data.Cache.Del(ctx, key)
if err != nil {
log.Debug(err)
}
return nil
}

View File

@ -106,6 +106,7 @@ func (a *UIRouter) Register(r *gin.Engine) {
default:
filePath = UIIndexFilePath
c.Header("content-type", "text/html;charset=utf-8")
c.Header("X-Frame-Options", "DENY")
}
file, err := ui.Build.ReadFile(filePath)
if err != nil {

View File

@ -83,6 +83,7 @@ type AnswerInfo struct {
VoteStatus string `json:"vote_status"`
VoteCount int `json:"vote_count"`
QuestionInfo *QuestionInfo `json:"question_info,omitempty"`
Status int `json:"status"`
// MemberActions
MemberActions []*PermissionMemberAction `json:"member_actions"`

View File

@ -2,10 +2,9 @@ package schema
// UpdateUserStatusReq update user request
type UpdateUserStatusReq struct {
// user id
UserID string `validate:"required" json:"user_id"`
// user status
Status string `validate:"required,oneof=normal suspended deleted inactive" json:"status" enums:"normal,suspended,deleted,inactive"`
UserID string `validate:"required" json:"user_id"`
Status string `validate:"required,oneof=normal suspended deleted inactive" json:"status" enums:"normal,suspended,deleted,inactive"`
LoginUserID string `json:"-"`
}
const (

View File

@ -53,6 +53,12 @@ type UpdateCommentReq struct {
// user id
UserID string `json:"-"`
IsAdmin bool `json:"-"`
CanAdd bool `json:"-"`
// whether user can edit it
CanEdit bool `json:"-"`
// whether user can delete it
CanDelete bool `json:"-"`
}
func (req *UpdateCommentReq) Check() (errFields []*validator.FormErrorField, err error) {

View File

@ -176,11 +176,20 @@ type AdminQuestionInfo struct {
UserInfo *UserBasicInfo `json:"user_info"`
}
type OperationLevel string
const (
OperationLevelInfo OperationLevel = "info"
OperationLevelDanger OperationLevel = "danger"
OperationLevelWarning OperationLevel = "warning"
)
type Operation struct {
OperationType string `json:"operation_type"`
OperationDescription string `json:"operation_description"`
OperationMsg string `json:"operation_msg"`
OperationTime int64 `json:"operation_time"`
Type string `json:"type"`
Description string `json:"description"`
Msg string `json:"msg"`
Time int64 `json:"time"`
Level OperationLevel `json:"level"`
}
type GetCloseTypeResp struct {

View File

@ -90,7 +90,7 @@ type GetTagResp struct {
}
func (tr *GetTagResp) GetExcerpt() {
excerpt := strings.TrimSpace(tr.OriginalText)
excerpt := strings.TrimSpace(tr.ParsedText)
idx := strings.Index(excerpt, "\n")
if idx >= 0 {
excerpt = excerpt[0:idx]

View File

@ -309,7 +309,7 @@ func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, er
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
}
req.BioHTML = converter.Markdown2HTML(req.Bio)
req.BioHTML = converter.Markdown2BasicHTML(req.Bio)
return nil, nil
}
@ -386,7 +386,6 @@ type GetOtherUserInfoByUsernameReq struct {
type GetOtherUserInfoResp struct {
Info *GetOtherUserInfoByUsernameResp `json:"info"`
Has bool `json:"has"`
}
type UserChangeEmailSendCodeReq struct {

View File

@ -16,6 +16,7 @@ import (
type CaptchaRepo interface {
SetCaptcha(ctx context.Context, key, captcha string) (err error)
GetCaptcha(ctx context.Context, key string) (captcha string, err error)
DelCaptcha(ctx context.Context, key string) (err error)
SetActionType(ctx context.Context, ip, actionType string, amount int) (err error)
GetActionType(ctx context.Context, ip, actionType string) (amount int, err error)
DelActionType(ctx context.Context, ip, actionType string) (err error)
@ -143,6 +144,12 @@ func (cs *CaptchaService) GenerateCaptcha(ctx context.Context) (key, captchaBase
func (cs *CaptchaService) VerifyCaptcha(ctx context.Context, key, captcha string) (isCorrect bool, err error) {
realCaptcha, err := cs.captchaRepo.GetCaptcha(ctx, key)
if err != nil {
log.Error("VerifyCaptcha GetCaptcha Error", err.Error())
return false, nil
}
err = cs.captchaRepo.DelCaptcha(ctx, key)
if err != nil {
log.Error("VerifyCaptcha DelCaptcha Error", err.Error())
return false, nil
}
return strings.TrimSpace(captcha) == realCaptcha, nil

View File

@ -73,6 +73,7 @@ func (as *AnswerCommon) ShowFormat(ctx context.Context, data *entity.Answer) *sc
}
info.UserID = data.UserID
info.UpdateUserID = data.LastEditUserID
info.Status = data.Status
return &info
}

View File

@ -164,7 +164,7 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) (
if err != nil {
log.Error("UpdateLastAnswer error", err.Error())
}
err = as.questionCommon.UpdataPostTime(ctx, req.QuestionID)
err = as.questionCommon.UpdatePostTime(ctx, req.QuestionID)
if err != nil {
return insertData.ID, err
}
@ -232,6 +232,11 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq
return "", nil
}
if answerInfo.Status == entity.AnswerStatusDeleted {
err = errors.BadRequest(reason.AnswerCannotUpdate)
return "", err
}
//If the content is the same, ignore it
if answerInfo.OriginalText == req.Content {
return "", nil
@ -268,7 +273,7 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq
if err = as.answerRepo.UpdateAnswer(ctx, insertData, []string{"original_text", "parsed_text", "updated_at", "last_edit_user_id"}); err != nil {
return "", err
}
err = as.questionCommon.UpdataPostTime(ctx, req.QuestionID)
err = as.questionCommon.UpdatePostTime(ctx, req.QuestionID)
if err != nil {
return insertData.ID, err
}
@ -473,6 +478,8 @@ func (as *AnswerService) SearchList(ctx context.Context, req *schema.AnswerListR
dbSearch.Page = req.Page
dbSearch.PageSize = req.PageSize
dbSearch.Order = req.Order
dbSearch.IncludeDeleted = req.CanDelete
dbSearch.LoginUserID = req.UserID
answerOriginalList, count, err := as.answerRepo.SearchList(ctx, &dbSearch)
if err != nil {
return list, count, err

View File

@ -209,24 +209,40 @@ func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveC
}
// UpdateComment update comment
func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateCommentReq) (err error) {
func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateCommentReq) (
resp *schema.GetCommentResp, err error) {
resp = &schema.GetCommentResp{}
old, exist, err := cs.commentCommonRepo.GetComment(ctx, req.CommentID)
if err != nil {
return
}
if !exist {
return errors.BadRequest(reason.CommentNotFound)
return resp, errors.BadRequest(reason.CommentNotFound)
}
// user can edit the comment that was posted by himself before deadline.
if !req.IsAdmin && (time.Now().After(old.CreatedAt.Add(constant.CommentEditDeadline))) {
return errors.BadRequest(reason.CommentCannotEditAfterDeadline)
return resp, errors.BadRequest(reason.CommentCannotEditAfterDeadline)
}
comment := &entity.Comment{}
_ = copier.Copy(comment, req)
comment.ID = req.CommentID
return cs.commentRepo.UpdateComment(ctx, comment)
resp.SetFromComment(comment)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
time.Now(), req.CanEdit, req.CanDelete)
userInfo, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, resp.UserID)
if err != nil {
return nil, err
}
if exist {
resp.Username = userInfo.Username
resp.UserDisplayName = userInfo.DisplayName
resp.UserAvatar = userInfo.Avatar
resp.UserStatus = userInfo.Status
}
return resp, cs.commentRepo.UpdateComment(ctx, comment)
}
// GetComment get comment one

View File

@ -89,6 +89,7 @@ func (ds *DashboardService) StatisticalByCache(ctx context.Context) (*schema.Das
}
startTime := time.Now().Unix() - schema.AppStartTime.Unix()
dashboardInfo.AppStartTime = fmt.Sprintf("%d", startTime)
dashboardInfo.VersionInfo.Version = constant.Version
return dashboardInfo, nil
}

View File

@ -112,14 +112,14 @@ func (qs *QuestionCommon) UpdateLastAnswer(ctx context.Context, questionID, Answ
return qs.questionRepo.UpdateLastAnswer(ctx, question)
}
func (qs *QuestionCommon) UpdataPostTime(ctx context.Context, questionID string) error {
func (qs *QuestionCommon) UpdatePostTime(ctx context.Context, questionID string) error {
questioninfo := &entity.Question{}
now := time.Now()
questioninfo.ID = questionID
questioninfo.PostUpdateTime = now
return qs.questionRepo.UpdateQuestion(ctx, questioninfo, []string{"post_update_time"})
}
func (qs *QuestionCommon) UpdataPostSetTime(ctx context.Context, questionID string, setTime time.Time) error {
func (qs *QuestionCommon) UpdatePostSetTime(ctx context.Context, questionID string, setTime time.Time) error {
questioninfo := &entity.Question{}
questioninfo.ID = questionID
questioninfo.PostUpdateTime = setTime
@ -148,7 +148,7 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
return showinfo, err
}
if !has {
return showinfo, errors.BadRequest(reason.QuestionNotFound)
return showinfo, errors.NotFound(reason.QuestionNotFound)
}
showinfo = qs.ShowFormat(ctx, dbinfo)
@ -170,10 +170,11 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
log.Error("json.Unmarshal QuestionCloseJson error", err.Error())
} else {
operation := &schema.Operation{}
operation.OperationType = closeinfo.Name
operation.OperationDescription = closeinfo.Description
operation.OperationMsg = closemsg.CloseMsg
operation.OperationTime = metainfo.CreatedAt.Unix()
operation.Type = closeinfo.Name
operation.Description = closeinfo.Description
operation.Msg = closemsg.CloseMsg
operation.Time = metainfo.CreatedAt.Unix()
operation.Level = schema.OperationLevelInfo
showinfo.Operation = operation
}

View File

@ -470,6 +470,10 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
if !has {
return
}
if dbinfo.Status == entity.QuestionStatusDeleted {
err = errors.BadRequest(reason.QuestionCannotUpdate)
return nil, err
}
now := time.Now()
question := &entity.Question{}
@ -614,12 +618,23 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
if err != nil {
return
}
// If the question is deleted, only the administrator and the author can view it
if question.Status == entity.QuestionStatusDeleted && !per.CanReopen && question.UserID != userID {
return nil, errors.NotFound(reason.QuestionNotFound)
}
if question.Status != entity.QuestionStatusClosed {
per.CanReopen = false
}
if question.Status == entity.QuestionStatusClosed {
per.CanClose = false
}
if question.Status == entity.QuestionStatusDeleted {
operation := &schema.Operation{}
operation.Msg = translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionAlreadyDeleted)
operation.Level = schema.OperationLevelDanger
question.Operation = operation
}
question.Description = htmltext.FetchExcerpt(question.HTML, "...", 240)
question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID,
per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen)

View File

@ -191,7 +191,7 @@ func (rs *RevisionService) revisionAuditAnswer(ctx context.Context, revisionitem
if saveerr != nil {
return saveerr
}
saveerr = rs.questionCommon.UpdataPostSetTime(ctx, answerinfo.QuestionID, PostUpdateTime)
saveerr = rs.questionCommon.UpdatePostSetTime(ctx, answerinfo.QuestionID, PostUpdateTime)
if saveerr != nil {
return saveerr
}

View File

@ -102,7 +102,7 @@ func (ts *TagService) GetTagInfo(ctx context.Context, req *schema.GetTagInfoReq)
return nil, err
}
if !exist {
return nil, errors.BadRequest(reason.TagNotFound)
return nil, errors.NotFound(reason.TagNotFound)
}
resp = &schema.GetTagResp{}
@ -113,7 +113,7 @@ func (ts *TagService) GetTagInfo(ctx context.Context, req *schema.GetTagInfoReq)
return nil, err
}
if !exist {
return nil, errors.BadRequest(reason.TagNotFound)
return nil, errors.NotFound(reason.TagNotFound)
}
resp.MainTagSlugName = tagInfo.SlugName
}

View File

@ -61,6 +61,10 @@ func NewUserAdminService(
// UpdateUserStatus update user
func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.UpdateUserStatusReq) (err error) {
// Admin cannot modify their status
if req.UserID == req.LoginUserID {
return errors.BadRequest(reason.AdminCannotModifySelfStatus)
}
userInfo, exist, err := us.userRepo.GetUserInfo(ctx, req.UserID)
if err != nil {
return
@ -153,6 +157,10 @@ func (us *UserAdminService) AddUser(ctx context.Context, req *schema.AddUserReq)
// UpdateUserPassword update user password
func (us *UserAdminService) UpdateUserPassword(ctx context.Context, req *schema.UpdateUserPasswordReq) (err error) {
// Users cannot modify their password
if req.UserID == req.LoginUserID {
return errors.BadRequest(reason.AdminCannotUpdateTheirPassword)
}
userInfo, exist, err := us.userRepo.GetUserInfo(ctx, req.UserID)
if err != nil {
return err

View File

@ -86,19 +86,17 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st
}
func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, username string) (
resp *schema.GetOtherUserInfoResp, err error,
resp *schema.GetOtherUserInfoByUsernameResp, err error,
) {
userInfo, exist, err := us.userRepo.GetByUsername(ctx, username)
if err != nil {
return nil, err
}
resp = &schema.GetOtherUserInfoResp{}
if !exist {
return resp, nil
return nil, errors.NotFound(reason.UserNotFound)
}
resp.Has = true
resp.Info = &schema.GetOtherUserInfoByUsernameResp{}
resp.Info.GetFromUserEntity(userInfo)
resp = &schema.GetOtherUserInfoByUsernameResp{}
resp.GetFromUserEntity(userInfo)
return resp, nil
}
@ -149,13 +147,13 @@ func (us *UserService) EmailLogin(ctx context.Context, req *schema.UserEmailLogi
}
// RetrievePassWord .
func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRetrievePassWordRequest) (string, error) {
func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRetrievePassWordRequest) error {
userInfo, has, err := us.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
return "", err
return err
}
if !has {
return "", errors.BadRequest(reason.UserNotFound)
return nil
}
// send email
@ -167,10 +165,10 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code)
title, body, err := us.emailService.PassResetTemplate(ctx, verifyEmailURL)
if err != nil {
return "", err
return err
}
go us.emailService.SendAndSaveCode(ctx, req.Email, title, body, code, data.ToJSONString())
return code, nil
return nil
}
// UseRePassword

View File

@ -35,6 +35,17 @@ func Markdown2HTML(source string) string {
return buf.String()
}
// Markdown2BasicHTML convert markdown to html ,Only basic syntax can be used
func Markdown2BasicHTML(source string) string {
content := Markdown2HTML(source)
filter := bluemonday.NewPolicy()
filter.AllowElements("p", "b", "br")
filter.AllowAttrs("src").OnElements("img")
filter.AddSpaceWhenStrippingTag(true)
content = filter.Sanitize(content)
return content
}
type DangerousHTMLFilterExtension struct {
}

View File

@ -6,6 +6,21 @@ export const LOGGED_USER_STORAGE_KEY = '_a_lui_';
export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_';
export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_';
export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_';
export const DRAFT_QUESTION_STORAGE_KEY = '_a_dq_';
export const DRAFT_ANSWER_STORAGE_KEY = '_a_da_';
export const DRAFT_TIMESIGH_STORAGE_KEY = '|_a_t_s_|';
export const IGNORE_PATH_LIST = [
'/users/login',
'/users/register',
'/users/account-recovery',
'/users/change-email',
'/users/password-reset',
'/users/account-activation',
'/users/account-activation/success',
'/users/account-activation/failed',
'/users/confirm-new-email',
];
export const ADMIN_LIST_STATUS = {
// normal;

View File

@ -5,7 +5,6 @@ import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { unionBy } from 'lodash';
import { marked } from 'marked';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
@ -108,15 +107,11 @@ const Comment = ({ objectId, mode, commentId }) => {
const users = matchedUsers(item.value);
const userNames = unionBy(users.map((user) => user.userName));
const commentMarkDown = parseUserInfo(item.value);
const html = marked.parse(commentMarkDown);
// if (!commentMarkDown || !html) {
// return;
// }
const params = {
object_id: objectId,
original_text: commentMarkDown,
mention_username_list: userNames,
parsed_text: html,
...(item.type === 'reply'
? {
reply_comment_id: item.comment_id,
@ -128,13 +123,13 @@ const Comment = ({ objectId, mode, commentId }) => {
return updateComment({
...params,
comment_id: item.comment_id,
}).then(() => {
}).then((res) => {
setComments(
comments.map((comment) => {
if (comment.comment_id === item.comment_id) {
comment.showEdit = false;
comment.parsed_text = html;
comment.original_text = item.value;
comment.parsed_text = res.parsed_text;
comment.original_text = res.original_text;
}
return comment;
}),

View File

@ -69,7 +69,10 @@ const Index: FC<Props> = ({
{objectType !== 'answer' && opts?.showTitle && (
<h5
dangerouslySetInnerHTML={{
__html: diffText(newData.title, oldData?.title),
__html: diffText(
newData.title?.replace(/</gi, '&lt;'),
oldData?.title?.replace(/</gi, '&lt;'),
),
}}
className="mb-3"
/>

View File

@ -114,19 +114,8 @@ 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);
});
// remove change table style to htmlToReact function
/**
* @description: You modify the DOM with other scripts after React has rendered the DOM. This way, on the next render cycle (re-render), React cannot find the DOM node it rendered before, because it has been modified or removed by other scripts.
*/
}

View File

@ -71,6 +71,10 @@ const Header: FC = () => {
};
const onLoginClick = (evt) => {
evt.preventDefault();
if (location.pathname === '/users/login') {
window.location.reload();
return;
}
floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true });
});

View File

@ -206,7 +206,6 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const errors = requiredValidator();
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
console.log('schema.properties[cur]', cur);
acc[cur] = {
...formData[cur],
isInvalid: true,

View File

@ -9,6 +9,7 @@ import useUserModal from './useUserModal';
import useChangePasswordModal from './useChangePasswordModal';
import usePageTags from './usePageTags';
import usePromptWithUnload from './usePrompt';
import useImgViewer from './useImgViewer';
export {
useTagModal,
@ -22,4 +23,5 @@ export {
useChangePasswordModal,
usePageTags,
usePromptWithUnload,
useImgViewer,
};

View File

@ -0,0 +1,78 @@
import { useLayoutEffect, useState, MouseEvent, useEffect } from 'react';
import { Modal } from 'react-bootstrap';
import { useLocation } from 'react-router-dom';
import ReactDOM from 'react-dom/client';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);
const useImgViewer = () => {
const location = useLocation();
const [visible, setVisible] = useState(false);
const [imgSrc, setImgSrc] = useState('');
const onClose = () => {
setVisible(false);
setImgSrc('');
};
const checkIfInLink = (target) => {
let ret = false;
let el = target.parentElement;
while (el) {
if (el.nodeName.toLowerCase() === 'a') {
ret = true;
break;
}
el = el.parentElement;
}
return ret;
};
const checkClickForImgView = (evt: MouseEvent<HTMLElement>) => {
const { target } = evt;
// @ts-ignore
if (target.nodeName.toLowerCase() !== 'img') {
return;
}
const img = target as HTMLImageElement;
if (!img.naturalWidth || !img.naturalHeight) {
img.classList.add('broken');
return;
}
const src = img.currentSrc || img.src;
if (src && checkIfInLink(img) === false) {
setImgSrc(src);
setVisible(true);
}
};
useLayoutEffect(() => {
root.render(
<Modal
show={visible}
fullscreen
centered
scrollable
contentClassName="bg-transparent"
onHide={onClose}>
<Modal.Body onClick={onClose} className="p-0 d-flex">
<img
className="cursor-zoom-out img-fluid m-auto"
src={imgSrc}
alt={imgSrc}
/>
</Modal.Body>
</Modal>,
);
});
useEffect(() => {
onClose();
}, [location]);
return {
onClose,
checkClickForImgView,
};
};
export default useImgViewer;

View File

@ -6,7 +6,7 @@ import {
import { useTranslation } from 'react-i18next';
// https://gist.github.com/chaance/2f3c14ec2351a175024f62fd6ba64aa6
// The link above is an example of implementing usePromt with useBlocer.
// The link above is an example of implementing usePrompt with useBlocker.
interface PromptProps {
when: boolean;
beforeUnload?: boolean;

View File

@ -47,7 +47,11 @@ const useReportModal = (callback?: () => void) => {
setShow(true);
});
};
const asyncCallback = () => {
setTimeout(() => {
callback?.();
});
};
const handleRadio = (val) => {
setInvalidState(false);
setContent({
@ -93,8 +97,8 @@ const useReportModal = (callback?: () => void) => {
close_type: reportType.type,
close_msg: content.value,
}).then(() => {
callback?.();
onClose();
asyncCallback();
});
return;
}
@ -109,8 +113,8 @@ const useReportModal = (callback?: () => void) => {
msg: t('flag_success', { keyPrefix: 'toast' }),
variant: 'warning',
});
callback?.();
onClose();
asyncCallback();
});
}
@ -121,8 +125,8 @@ const useReportModal = (callback?: () => void) => {
flagged_type: reportType.type,
id: params.id,
}).then(() => {
callback?.();
onClose();
asyncCallback();
});
}
};

View File

@ -120,6 +120,14 @@ a {
cursor: pointer;
}
.cursor-zoom-out {
cursor: zoom-out !important;
}
img:not(a img, img.broken) {
cursor: zoom-in;
}
.resize-none {
resize: none;
}

View File

@ -1,17 +1,30 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_404' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap');
pageWrap.style.display = 'contents';
return () => {
pageWrap.style.display = 'block';
};
}, []);
return (
<Container className="d-flex flex-column justify-content-center align-items-center page-wrap">
<Container
className="d-flex flex-column justify-content-center align-items-center"
style={{ flex: 1 }}>
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
<div className="text-center mb-4">{t('desc')}</div>
<h4 className="text-center">{t('http_error')}</h4>
<div className="text-center mb-3 fs-5">{t('desc')}</div>
<div className="text-center">
<Button as={Link} to="/" variant="link">
{t('back_home')}

View File

@ -1,9 +1,19 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_50X' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap');
pageWrap.style.display = 'contents';
return () => {
pageWrap.style.display = 'block';
};
}, []);
return (
<Container className="d-flex flex-column justify-content-center align-items-center page-wrap">
<div
@ -11,7 +21,9 @@ const Index = () => {
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=T^T=)
</div>
<div className="text-center mb-3">{t('desc')}</div>
<h4 className="text-center">{t('http_error')}</h4>
<div className="text-center mb-3 fs-5">{t('desc')}</div>
<div className="text-center">
<Button as={Link} to="/" variant="link">
{t('back_home')}

View File

@ -1,10 +1,10 @@
import { FC, memo } from 'react';
import { Outlet } from 'react-router-dom';
import { FC, memo, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import { toastStore, loginToContinueStore } from '@/stores';
import { toastStore, loginToContinueStore, notFoundStore } from '@/stores';
import {
Header,
Footer,
@ -14,13 +14,23 @@ import {
PageTags,
} from '@/components';
import { LoginToContinueModal } from '@/components/Modal';
import { useImgViewer } from '@/hooks';
import Component404 from '@/pages/404';
const Layout: FC = () => {
const location = useLocation();
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const closeToast = () => {
toastClear();
};
const { visible: show404, hide: notFoundHide } = notFoundStore();
const imgViewer = useImgViewer();
const { show: showLoginToContinueModal } = loginToContinueStore();
useEffect(() => {
notFoundHide();
}, [location]);
return (
<HelmetProvider>
<PageTags />
@ -30,8 +40,11 @@ const Layout: FC = () => {
revalidateOnFocus: false,
}}>
<Header />
<div className="position-relative page-wrap">
<Outlet />
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="position-relative page-wrap"
onClick={imgViewer.checkClickForImgView}>
{show404 ? <Component404 /> : <Outlet />}
</div>
<Toast msg={toastMsg} variant={variant} onClose={closeToast} />
<Footer />

View File

@ -10,6 +10,7 @@ import { isEqual } from 'lodash';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { Editor, EditorRef, TagSelector } from '@/components';
import type * as Type from '@/common/interface';
import { DRAFT_QUESTION_STORAGE_KEY } from '@/common/constants';
import {
saveQuestion,
questionDetail,
@ -19,7 +20,7 @@ import {
useQueryQuestionByTitle,
getTagsBySlugName,
} from '@/services';
import { handleFormError } from '@/utils';
import { handleFormError, SaveDraft, storageExpires } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
import SearchQuestion from './components/SearchQuestion';
@ -32,6 +33,8 @@ interface FormDataItem {
edit_summary: Type.FormValue<string>;
}
const saveDraft = new SaveDraft({ type: 'question' });
const Ask = () => {
const initFormData = {
title: {
@ -66,6 +69,7 @@ const Ask = () => {
const [checked, setCheckState] = useState(false);
const [contentChanged, setContentChanged] = useState(false);
const [focusType, setForceType] = useState('');
const [hasDraft, setHasDraft] = useState(false);
const resetForm = () => {
setFormData(initFormData);
setCheckState(false);
@ -98,6 +102,34 @@ const Ask = () => {
isEdit ? '' : formData.title.value,
);
const removeDraft = () => {
saveDraft.save.cancel();
saveDraft.remove();
setHasDraft(false);
};
useEffect(() => {
if (!qid) {
initQueryTags();
const draft = storageExpires.get(DRAFT_QUESTION_STORAGE_KEY);
if (draft) {
formData.title.value = draft.title;
formData.content.value = draft.content;
formData.tags.value = draft.tags;
formData.answer.value = draft.answer;
setCheckState(Boolean(draft.answer));
setHasDraft(true);
setFormData({ ...formData });
} else {
resetForm();
}
}
return () => {
resetForm();
};
}, [qid]);
useEffect(() => {
const { title, tags, content, answer } = formData;
const { title: editTitle, tags: editTags, content: editContent } = immData;
@ -118,11 +150,21 @@ const Ask = () => {
}
return;
}
// write
if (title.value || tags.value.length > 0 || content.value || answer.value) {
// save draft
saveDraft.save({
params: {
title: title.value,
tags: tags.value,
content: content.value,
answer: answer.value,
},
callback: () => setHasDraft(true),
});
setContentChanged(true);
} else {
removeDraft();
setContentChanged(false);
}
}, [formData]);
@ -131,12 +173,6 @@ const Ask = () => {
when: contentChanged,
});
useEffect(() => {
if (!isEdit) {
resetForm();
initQueryTags();
}
}, [isEdit]);
const { data: revisions = [] } = useQueryRevisions(qid);
useEffect(() => {
@ -191,6 +227,14 @@ const Ask = () => {
},
});
const deleteDraft = () => {
const res = window.confirm(t('discard_confirm', { keyPrefix: 'draft' }));
if (res) {
removeDraft();
resetForm();
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
setContentChanged(false);
event.preventDefault();
@ -248,6 +292,7 @@ const Ask = () => {
navigate(pathFactory.questionLanding(id));
}
}
removeDraft();
}
};
const backPage = () => {
@ -376,10 +421,17 @@ const Ask = () => {
<Button type="submit" className="me-2">
{isEdit ? t('btn_save_edits') : t('btn_post_question')}
</Button>
{isEdit && (
<Button variant="link" onClick={backPage}>
{t('cancel', { keyPrefix: 'btns' })}
</Button>
)}
<Button variant="link" onClick={backPage}>
{t('cancel', { keyPrefix: 'btns' })}
</Button>
{hasDraft && (
<Button variant="link" onClick={deleteDraft}>
{t('discard_draft', { keyPrefix: 'btns' })}
</Button>
)}
</div>
)}
{!isEdit && (
@ -411,7 +463,6 @@ const Ask = () => {
}}
/>
<Form.Control
value={formData.answer.value}
type="text"
isInvalid={formData.answer.isInvalid}
hidden
@ -424,9 +475,14 @@ const Ask = () => {
</>
)}
{checked && (
<Button type="submit" className="mt-3">
{t('post_question&answer')}
</Button>
<div className="mt-3">
<Button type="submit">{t('post_question&answer')}</Button>
{hasDraft && (
<Button variant="link" className="ms-2" onClick={deleteDraft}>
{t('discard_draft', { keyPrefix: 'btns' })}
</Button>
)}
</div>
)}
</Form>
</Col>

View File

@ -10,38 +10,38 @@ interface Props {
const Index: FC<Props> = ({ data }) => {
const { t } = useTranslation();
return (
<Alert className="mb-4" variant="info">
<div>
{data.operation_msg.indexOf('http') > -1 ? (
<p>
{data.operation_description}{' '}
<a href={data.operation_msg} style={{ color: '#055160' }}>
<strong>{t('question_detail.show_exist')}</strong>
</a>
</p>
) : (
<p>
{data.operation_msg
? data.operation_msg
: data.operation_description}
</p>
)}
<div className="fs-14">
{t('question_detail.closed_in')}{' '}
<time
dateTime={dayjs.unix(data.operation_time).tz().toISOString()}
title={dayjs
.unix(data.operation_time)
.tz()
.format(t('dates.long_date_with_time'))}>
{dayjs
.unix(data.operation_time)
.tz()
.format(t('dates.long_date_with_year'))}
</time>
.
<Alert className="mb-4" variant={data.level}>
{data.level === 'info' ? (
<div>
{data.msg.indexOf('http') > -1 ? (
<p>
{data.description}{' '}
<a href={data.msg} style={{ color: '#055160' }}>
<strong>{t('question_detail.show_exist')}</strong>
</a>
</p>
) : (
<p>{data.msg ? data.msg : data.description}</p>
)}
<div className="fs-14">
{t('question_detail.closed_in')}{' '}
<time
dateTime={dayjs.unix(data.time).tz().toISOString()}
title={dayjs
.unix(data.time)
.tz()
.format(t('dates.long_date_with_time'))}>
{dayjs
.unix(data.time)
.tz()
.format(t('dates.long_date_with_year'))}
</time>
.
</div>
</div>
</div>
) : (
data.msg
)}
</Alert>
);
};

View File

@ -1,5 +1,5 @@
import { memo, FC, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { Button, Alert } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
@ -72,6 +72,11 @@ const Index: FC<Props> = ({
}
return (
<div id={data.id} ref={answerRef} className="answer-item py-4">
{data.status === 10 && (
<Alert variant="danger" className="mb-4">
{t('post_deleted', { keyPrefix: 'messages' })}
</Alert>
)}
<article
dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap"

View File

@ -1,4 +1,4 @@
import { memo, useState, FC } from 'react';
import { memo, useState, FC, useEffect } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
@ -9,7 +9,8 @@ import { usePromptWithUnload } from '@/hooks';
import { Editor, Modal, TextArea } from '@/components';
import { FormDataType } from '@/common/interface';
import { postAnswer } from '@/services';
import { guard, handleFormError } from '@/utils';
import { guard, handleFormError, SaveDraft, storageExpires } from '@/utils';
import { DRAFT_ANSWER_STORAGE_KEY } from '@/common/constants';
interface Props {
visible?: boolean;
@ -21,6 +22,8 @@ interface Props {
callback?: (obj) => void;
}
const saveDraft = new SaveDraft({ type: 'answer' });
const Index: FC<Props> = ({ visible = false, data, callback }) => {
const { t } = useTranslation('translation', {
keyPrefix: 'question_detail.write_answer',
@ -35,11 +38,51 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
const [showEditor, setShowEditor] = useState<boolean>(visible);
const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false);
const [hasDraft, setHasDraft] = useState(false);
usePromptWithUnload({
when: Boolean(formData.content.value),
});
const removeDraft = () => {
// immediately remove debounced save
saveDraft.save.cancel();
saveDraft.remove();
setHasDraft(false);
};
useEffect(() => {
const draft = storageExpires.get(DRAFT_ANSWER_STORAGE_KEY);
if (draft?.questionId === data.qid && draft?.content) {
setFormData({
content: {
value: draft.content,
isInvalid: false,
errorMsg: '',
},
});
setShowEditor(true);
setHasDraft(true);
}
}, []);
useEffect(() => {
const draft = storageExpires.get(DRAFT_ANSWER_STORAGE_KEY);
const { content } = formData;
if (content.value) {
// save Draft
saveDraft.save({
questionId: data?.qid,
content: content.value,
});
setHasDraft(true);
} else if (draft?.questionId === data.qid && !content.value) {
removeDraft();
}
}, [formData.content.value]);
const checkValidated = (): boolean => {
let bol = true;
const { content } = formData;
@ -65,6 +108,24 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
return bol;
};
const resetForm = () => {
setFormData({
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
};
const deleteDraft = () => {
const res = window.confirm(t('discard_confirm', { keyPrefix: 'draft' }));
if (res) {
removeDraft();
resetForm();
}
};
const handleSubmit = () => {
if (!guard.tryNormalLogged(true)) {
return;
@ -86,6 +147,7 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
errorMsg: '',
},
});
removeDraft();
callback?.(res.info);
})
.catch((ex) => {
@ -128,7 +190,6 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
setShowEditor(true);
setEditorFocusState(true);
};
return (
<Form noValidate className="mt-4">
{(!data.answered || showEditor) && (
@ -187,6 +248,11 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
) : (
<Button onClick={clickBtn}>{t('btn_name')}</Button>
)}
{hasDraft && (
<Button variant="link" className="ms-2" onClick={deleteDraft}>
{t('discard_draft', { keyPrefix: 'btns' })}
</Button>
)}
</Form>
);
};

View File

@ -56,6 +56,7 @@ const Index = () => {
const { setUsers } = usePageUsers();
const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username;
const isAdmin = userInfo?.is_admin;
const isLogged = Boolean(userInfo?.access_token);
const { state: locationState } = useLocation();
@ -76,7 +77,22 @@ const Index = () => {
page_size: 999,
});
if (res) {
setAnswers(res);
res.list = res.list?.filter((v) => {
// delete answers pnly show to author and admin and has searchparams aid
if (v.status === 10) {
if (
(v?.user_info.username === userInfo?.username || isAdmin) &&
aid === v.id
) {
return v;
}
return null;
}
return v;
});
setAnswers({ ...res, count: res.list.length });
if (page > 0 || order) {
// scroll into view;
const element = document.getElementById('answerHeader');
@ -183,9 +199,7 @@ const Index = () => {
<Container className="pt-4 mt-2 mb-5 questionDetailPage">
<Row className="justify-content-center">
<Col xxl={7} lg={8} sm={12} className="mb-5 mb-md-0">
{question?.operation?.operation_type && (
<Alert data={question.operation} />
)}
{question?.operation?.level && <Alert data={question.operation} />}
{isLoading ? (
<ContentLoader />
) : (

View File

@ -18,7 +18,7 @@ const Index: FC<Props> = ({ visible, introduction, data }) => {
<h5 className="mb-3">{t('about_me')}</h5>
{introduction ? (
<div
className="mb-4 text-break"
className="mb-4 text-break fmt"
dangerouslySetInnerHTML={{ __html: introduction }}
/>
) : (

View File

@ -11,6 +11,7 @@ import {
usePersonalTop,
usePersonalListByTabName,
} from '@/services';
import type { UserInfoRes } from '@/common/interface';
import {
UserInfo,
@ -47,8 +48,8 @@ const Personal: FC = () => {
tabName,
);
let pageTitle = '';
if (userInfo) {
pageTitle = `${userInfo.info.display_name} (${userInfo.info.username})`;
if (userInfo?.username) {
pageTitle = `${userInfo?.display_name} (${userInfo?.username})`;
}
const { count = 0, list = [] } = listData?.[tabName] || {};
usePageTags({
@ -57,11 +58,11 @@ const Personal: FC = () => {
return (
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
{userInfo?.info?.status !== 'normal' && userInfo?.info?.status_msg && (
<Alert data={userInfo?.info.status_msg} />
{userInfo?.status !== 'normal' && userInfo?.status_msg && (
<Alert data={userInfo?.status_msg} />
)}
<Col xxl={7} lg={8} sm={12}>
<UserInfo data={userInfo?.info} />
<UserInfo data={userInfo as UserInfoRes} />
</Col>
<Col
xxl={3}
@ -88,11 +89,11 @@ const Personal: FC = () => {
<Col xxl={7} lg={8} sm={12}>
<Overview
visible={tabName === 'overview'}
introduction={userInfo?.info?.bio_html}
introduction={userInfo?.bio_html || ''}
data={topData}
/>
<ListHead
count={tabName === 'reputation' ? userInfo?.info?.rank : count}
count={tabName === 'reputation' ? Number(userInfo?.rank) : count}
sort={order}
visible={tabName !== 'overview'}
tabName={tabName}
@ -120,17 +121,14 @@ const Personal: FC = () => {
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<h5 className="mb-3">{t('stats')}</h5>
{userInfo?.info && (
{userInfo?.created_at && (
<>
<div className="text-secondary">
<FormatTime
time={userInfo.info.created_at}
preFix={t('joined')}
/>
<FormatTime time={userInfo.created_at} preFix={t('joined')} />
</div>
<div className="text-secondary">
<FormatTime
time={userInfo.info.last_login_date}
time={userInfo.last_login_date}
preFix={t('last_login')}
/>
</div>

View File

@ -0,0 +1,8 @@
import Error50X from '@/pages/50X';
// import Page404 from '@/pages/404';
const Index = () => {
return <Error50X />;
};
export default Index;

View File

@ -2,9 +2,10 @@ import { Suspense, lazy } from 'react';
import { RouteObject } from 'react-router-dom';
import Layout from '@/pages/Layout';
import ErrorBoundary from '@/pages/50X';
import baseRoutes, { RouteNode } from '@/router/routes';
import RouteGuard from '@/router/RouteGuard';
import baseRoutes, { RouteNode } from './routes';
import RouteGuard from './RouteGuard';
import RouteErrorBoundary from './RouteErrorBoundary';
const routes: RouteNode[] = [];
@ -18,7 +19,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
) : (
<Layout />
);
rn.errorElement = <ErrorBoundary />;
rn.errorElement = <RouteErrorBoundary />;
} else {
/**
* cannot use a fully dynamic import statement
@ -37,6 +38,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
)}
</Suspense>
);
rn.errorElement = <RouteErrorBoundary />;
}
root.push(rn);
const children = Array.isArray(rn.children) ? rn.children : null;

View File

@ -8,7 +8,10 @@ export const usePersonalInfoByName = (username: string) => {
const apiUrl = '/answer/api/v1/personal/user/info';
const { data, error, mutate } = useSWR<Type.UserInfoRes, Error>(
username ? `${apiUrl}?username=${username}` : null,
request.instance.get,
(url) =>
request.get(url, {
allow404: true,
}),
);
return {
data,

View File

@ -47,7 +47,9 @@ export const useTagInfo = ({ id = '', name = '' }) => {
name = encodeURIComponent(name);
apiUrl = `/answer/api/v1/tag?name=${name}`;
}
const { data, error } = useSWR<Type.TagInfo>(apiUrl, request.instance.get);
const { data, error } = useSWR<Type.TagInfo>(apiUrl, (url) =>
request.get(url, { allow404: true }),
);
return {
data,
isLoading: !data && !error,

View File

@ -171,6 +171,7 @@ export const saveQuestion = (params: Type.QuestionParams) => {
export const questionDetail = (id: string) => {
return request.get<Type.QuestionDetailRes>(
`/answer/api/v1/question/info?id=${id}`,
{ allow404: true },
);
};

View File

@ -10,6 +10,7 @@ import pageTagStore from './pageTags';
import customizeStore from './customize';
import themeSettingStore from './themeSetting';
import loginToContinueStore from './loginToContinue';
import notFoundStore from './notFound';
export {
toastStore,
@ -23,4 +24,5 @@ export {
themeSettingStore,
seoSettingStore,
loginToContinueStore,
notFoundStore,
};

23
ui/src/stores/notFound.ts Normal file
View File

@ -0,0 +1,23 @@
import create from 'zustand';
interface NotFoundType {
visible: boolean;
show: () => void;
hide: () => void;
}
const notFound = create<NotFoundType>((set) => ({
visible: false,
show: () => {
set(() => {
return { visible: true };
});
},
hide: () => {
set(() => {
return { visible: false };
});
},
}));
export default notFound;

View File

@ -237,7 +237,26 @@ function htmlToReact(html: string) {
const cleanedHtml = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
return parse(cleanedHtml);
const ele = document.createElement('div');
ele.innerHTML = cleanedHtml;
ele.querySelectorAll('table').forEach((table) => {
if (
(!table || (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);
});
return parse(ele.innerHTML);
}
export {

View File

@ -63,7 +63,7 @@ export const deriveLoginState = (): TLoginState => {
return ls;
};
const isIgnoredPath = (ignoredPath: string | string[]) => {
export const isIgnoredPath = (ignoredPath: string | string[]) => {
if (!Array.isArray(ignoredPath)) {
ignoredPath = [ignoredPath];
}

View File

@ -1,6 +1,8 @@
export { default as request } from './request';
export { default as Storage } from './storage';
export { floppyNavigation } from './floppyNavigation';
export { default as storageExpires } from './storageWithExpires';
export { default as SaveDraft } from './saveDraft';
export * as guard from './guard';
export * as localize from './localize';

View File

@ -2,19 +2,24 @@ import axios, { AxiosResponse } from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { Modal } from '@/components';
import { loggedUserInfoStore, toastStore } from '@/stores';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
import { loggedUserInfoStore, toastStore, notFoundStore } from '@/stores';
import { LOGGED_TOKEN_STORAGE_KEY, IGNORE_PATH_LIST } from '@/common/constants';
import { RouteAlias } from '@/router/alias';
import { getCurrentLang } from '@/utils/localize';
import Storage from './storage';
import { floppyNavigation } from './floppyNavigation';
import { isIgnoredPath } from './guard';
const baseConfig = {
timeout: 10000,
withCredentials: true,
};
interface APIconfig extends AxiosRequestConfig {
allow404: boolean;
}
class Request {
instance: AxiosInstance;
@ -49,6 +54,9 @@ class Request {
(error) => {
const { status, data: respData } = error.response || {};
const { data = {}, msg = '', reason = '' } = respData || {};
console.log('response error:', error);
if (status === 400) {
// show error message
if (data instanceof Object && data.err_type) {
@ -99,12 +107,14 @@ class Request {
// 401: Re-login required
if (status === 401) {
// clear userinfo
notFoundStore.getState().hide();
loggedUserInfoStore.getState().clear();
floppyNavigation.navigateToLogin();
return Promise.reject(false);
}
if (status === 403) {
// Permission interception
notFoundStore.getState().hide();
if (data?.type === 'url_expired') {
// url expired
floppyNavigation.navigate(RouteAlias.activationFailed, () => {
@ -135,6 +145,14 @@ class Request {
}
return Promise.reject(false);
}
if (status === 404 && error.config?.allow404) {
if (isIgnoredPath(IGNORE_PATH_LIST)) {
return Promise.reject(false);
}
notFoundStore.getState().show();
return Promise.reject(false);
}
if (status >= 500) {
console.error(
`Request failed with status code ${status}, ${msg || ''}`,
@ -149,7 +167,7 @@ class Request {
return this.instance.request(config);
}
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
public get<T = any>(url: string, config?: APIconfig): Promise<T> {
return this.instance.get(url, config);
}

87
ui/src/utils/saveDraft.ts Normal file
View File

@ -0,0 +1,87 @@
import { debounce } from 'lodash';
import {
DRAFT_QUESTION_STORAGE_KEY,
DRAFT_ANSWER_STORAGE_KEY,
} from '@/common/constants';
import { storageExpires as storage } from '@/utils';
export type QuestionDraft = {
params: {
title: string;
content: string;
tags: any[];
answer: string;
};
callback?: () => void;
};
export type AnswerDraft = {
questionId: string;
content: string;
callback?: () => void;
};
type DraftType = {
type: 'question' | 'answer';
};
export type DraftParams = QuestionDraft | AnswerDraft;
class SaveDraft {
type: DraftType['type'];
status: 'save' | 'remove';
constructor({ type = 'question' }: DraftType) {
this.type = type;
this.status = 'save';
}
save = debounce((data: DraftParams) => {
// TODO
if (this.status === 'remove') {
return;
}
if (this.type === 'question') {
const { params, callback } = data as QuestionDraft;
this.storeDraft(params, callback);
}
if (this.type === 'answer') {
const { content, questionId, callback } = data as AnswerDraft;
if (!questionId || !content) {
return;
}
this.storeDraft({ content, questionId }, callback);
}
}, 3000);
remove() {
this.status = 'remove';
const that = this;
if (this.type === 'question') {
storage.remove(DRAFT_QUESTION_STORAGE_KEY, () => {
that.status = 'save';
});
}
if (this.type === 'answer') {
storage.remove(DRAFT_ANSWER_STORAGE_KEY, () => {
that.status = 'save';
});
}
}
private storeDraft = (params: any, callback) => {
const key =
this.type === 'question'
? DRAFT_QUESTION_STORAGE_KEY
: DRAFT_ANSWER_STORAGE_KEY;
storage.set(key, params);
callback?.();
};
}
export default SaveDraft;

View File

@ -0,0 +1,50 @@
import { DRAFT_TIMESIGH_STORAGE_KEY as timeSign } from '@/common/constants';
const store = {
storage: localStorage || window.localStorage,
set(key: string, value, time?: number): void {
const t = time || Date.now() + 1000 * 60 * 60 * 24 * 7; // default 7 days
try {
this.storage.setItem(key, `${t}${timeSign}${JSON.stringify(value)}`);
} catch {
// ignore
console.error('set storage error: the key is', key);
}
},
get(key: string): any {
const timeSignLen = timeSign.length;
let index = 0;
let time = 0;
let res;
try {
res = this.storage.getItem(key);
} catch {
console.error('get storage error: the key is', key);
}
if (res) {
index = res.indexOf(timeSign);
time = +res.slice(0, index);
if (time > new Date().getTime()) {
res = res.slice(index + timeSignLen);
try {
res = JSON.parse(res);
} catch {
// ignore
}
} else {
// timeout remove storage
res = null;
this.storage.removeItem(key);
}
return res;
}
return res;
},
remove(key: string, callback?: () => void): void {
this.storage.removeItem(key);
callback?.();
},
};
export default store;

View File

@ -3,28 +3,28 @@
<div class="justify-content-center row">
<div class="col-xxl-7 col-lg-8 col-sm-12">
<div class="d-flex flex-column flex-md-row mb-4">
<a href="/users/{{.userinfo.Info.Username}}"><img
src="{{.userinfo.Info.Avatar}}"
<a href="/users/{{.userinfo.Username}}"><img
src="{{.userinfo.Avatar}}"
width="160px" height="160px" class="rounded" alt="" /></a>
<div class="ms-0 ms-md-4 mt-4 mt-md-0">
<div class="d-flex align-items-center mb-2">
<a class="link-dark h3 mb-0" href="/users/{{.userinfo.Info.Username}}">{{.userinfo.Info.DisplayName}}</a>
<a class="link-dark h3 mb-0" href="/users/{{.userinfo.Username}}">{{.userinfo.DisplayName}}</a>
</div>
<div class="text-secondary mb-4">@{{.userinfo.Info.Username}}</div>
<div class="text-secondary mb-4">@{{.userinfo.Username}}</div>
<div class="d-flex flex-wrap mb-3">
<div class="me-3">
<strong class="fs-5">{{.userinfo.Info.Rank}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_reputation"}}</span>
<strong class="fs-5">{{.userinfo.Rank}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_reputation"}}</span>
</div>
<div class="me-3">
<strong class="fs-5">{{.userinfo.Info.AnswerCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_answers"}}</span>
<strong class="fs-5">{{.userinfo.AnswerCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_answers"}}</span>
</div>
<div>
<strong class="fs-5">{{.userinfo.Info.QuestionCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_questions"}}</span>
<strong class="fs-5">{{.userinfo.QuestionCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_questions"}}</span>
</div>
</div>
{{if .userinfo.Info.Website }}
<div class="d-flex align-items-center"><i class="br bi-house-door-fill me-2"></i><a class="link-secondary" href="{{.userinfo.Info.Website}}">{{.userinfo.Info.Website}}</a></div>
{{if .userinfo.Website }}
<div class="d-flex align-items-center"><i class="br bi-house-door-fill me-2"></i><a class="link-secondary" href="{{.userinfo.Website}}">{{.userinfo.Website}}</a></div>
{{else}}
{{end}}
<div class="d-flex text-secondary"></div>