Merge branch 'release/1.0.8' into github-main

# Conflicts:
#	i18n/zh_CN.yaml
This commit is contained in:
LinkinStars 2023-04-11 10:46:10 +08:00
commit e037d0f005
108 changed files with 1438 additions and 711 deletions

View File

@ -15,7 +15,7 @@ builds:
- id: build
main: ./cmd/answer/.
binary: answer
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
ldflags: -s -w -X github.com/answerdev/answer/cmd.Version={{.Version}} -X github.com/answerdev/answer/cmd.Revision={{.ShortCommit}} -X github.com/answerdev/answer/cmd.Time={{.Date}} -X main.BuildUser=goreleaser
flags: -v
goos:
- linux
@ -26,7 +26,7 @@ builds:
- id: build-windows
main: ./cmd/answer/.
binary: answer
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
ldflags: -s -w -X github.com/answerdev/answer/cmd.Version={{.Version}} -X github.com/answerdev/answer/cmd.Revision={{.ShortCommit}} -X github.com/answerdev/answer/cmd.Time={{.Date}} -X main.BuildUser=goreleaser
flags: -v
goos:
- windows

View File

@ -1,6 +1,6 @@
.PHONY: build clean ui
VERSION=1.0.7
VERSION=1.0.8
BIN=answer
DIR_SRC=./cmd/answer
DOCKER_CMD=docker

View File

@ -53,6 +53,7 @@ func runApp() {
panic(err)
}
constant.Version = Version
constant.Revision = Revision
schema.AppStartTime = time.Now()
defer cleanup()

View File

@ -165,8 +165,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
questionActivityRepo := activity.NewQuestionActivityRepo(dataData, activityRepo, userRankRepo)
answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, questionActivityRepo)
questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, revisionService, metaService, collectionCommon, answerActivityService, dataData)
questionController := controller.NewQuestionController(questionService, rankService)
answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService)
questionController := controller.NewQuestionController(questionService, answerService, rankService)
dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configRepo, siteInfoCommonService, serviceConf, dataData)
answerController := controller.NewAnswerController(answerService, rankService, dashboardService)
searchParser := search_parser.NewSearchParser(tagCommonService, userCommon)

View File

@ -3063,6 +3063,45 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/question/answer": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "add question and answer",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Question"
],
"summary": "add question and answer",
"parameters": [
{
"description": "question",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionAddByAnswer"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question/closemsglist": {
"get": {
"security": [
@ -6751,6 +6790,41 @@ const docTemplate = `{
}
}
},
"schema.QuestionAddByAnswer": {
"type": "object",
"required": [
"answer_content",
"content",
"tags",
"title"
],
"properties": {
"answer_content": {
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"content": {
"description": "content",
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"tags": {
"description": "tags",
"type": "array",
"items": {
"$ref": "#/definitions/schema.TagItem"
}
},
"title": {
"description": "question title",
"type": "string",
"maxLength": 150,
"minLength": 6
}
}
},
"schema.QuestionPageReq": {
"type": "object",
"properties": {
@ -7230,6 +7304,9 @@ const docTemplate = `{
"login": {
"$ref": "#/definitions/schema.SiteLoginResp"
},
"revision": {
"type": "string"
},
"site_seo": {
"$ref": "#/definitions/schema.SiteSeoReq"
},

View File

@ -3051,6 +3051,45 @@
}
}
},
"/answer/api/v1/question/answer": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "add question and answer",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Question"
],
"summary": "add question and answer",
"parameters": [
{
"description": "question",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionAddByAnswer"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question/closemsglist": {
"get": {
"security": [
@ -6739,6 +6778,41 @@
}
}
},
"schema.QuestionAddByAnswer": {
"type": "object",
"required": [
"answer_content",
"content",
"tags",
"title"
],
"properties": {
"answer_content": {
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"content": {
"description": "content",
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"tags": {
"description": "tags",
"type": "array",
"items": {
"$ref": "#/definitions/schema.TagItem"
}
},
"title": {
"description": "question title",
"type": "string",
"maxLength": 150,
"minLength": 6
}
}
},
"schema.QuestionPageReq": {
"type": "object",
"properties": {
@ -7218,6 +7292,9 @@
"login": {
"$ref": "#/definitions/schema.SiteLoginResp"
},
"revision": {
"type": "string"
},
"site_seo": {
"$ref": "#/definitions/schema.SiteSeoReq"
},

View File

@ -1019,6 +1019,33 @@ definitions:
- tags
- title
type: object
schema.QuestionAddByAnswer:
properties:
answer_content:
maxLength: 65535
minLength: 6
type: string
content:
description: content
maxLength: 65535
minLength: 6
type: string
tags:
description: tags
items:
$ref: '#/definitions/schema.TagItem'
type: array
title:
description: question title
maxLength: 150
minLength: 6
type: string
required:
- answer_content
- content
- tags
- title
type: object
schema.QuestionPageReq:
properties:
orderCond:
@ -1354,6 +1381,8 @@ definitions:
$ref: '#/definitions/schema.SiteInterfaceResp'
login:
$ref: '#/definitions/schema.SiteLoginResp'
revision:
type: string
site_seo:
$ref: '#/definitions/schema.SiteSeoReq'
theme:
@ -3794,6 +3823,30 @@ paths:
summary: update question
tags:
- Question
/answer/api/v1/question/answer:
post:
consumes:
- application/json
description: add question and answer
parameters:
- description: question
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.QuestionAddByAnswer'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: add question and answer
tags:
- Question
/answer/api/v1/question/closemsglist:
get:
consumes:

13
go.mod
View File

@ -27,6 +27,7 @@ require (
github.com/mojocn/base64Captcha v1.3.5
github.com/ory/dockertest/v3 v3.9.1
github.com/robfig/cron/v3 v3.0.1
github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405
github.com/segmentfault/pacman v1.0.3
github.com/segmentfault/pacman/contrib/cache/memory v0.0.0-20221219081300-f734f4a16aa0
github.com/segmentfault/pacman/contrib/conf/viper v0.0.0-20221018072427-a15dd1434e05
@ -41,7 +42,7 @@ require (
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.2.0
golang.org/x/net v0.7.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.14.2
@ -64,14 +65,20 @@ require (
github.com/docker/docker v20.10.7+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/dsoprea/go-exif v0.0.0-20190901173045-3ce78807c90f // indirect
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20190422055009-d6f9ba25cf48 // indirect
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696 // indirect
github.com/dsoprea/go-png-image-structure v0.0.0-20190624104353-c9b28dcdc5c8 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.7 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/geo v0.0.0-20190812012225-f41920e961ce // 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
@ -121,8 +128,8 @@ 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.2.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/tools v0.2.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect

26
go.sum
View File

@ -143,6 +143,14 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsoprea/go-exif v0.0.0-20190901173045-3ce78807c90f h1:vqfYiZ+xF0xJvl9SZ1kovmMgKjaGZIz/Hn8JDQdyd9A=
github.com/dsoprea/go-exif v0.0.0-20190901173045-3ce78807c90f/go.mod h1:DmMpU91/Ax6BAwoRkjgRCr2rmgEgS4tsmatfV7M+U+c=
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20190422055009-d6f9ba25cf48 h1:9zARagUAxQJjibcDy+0+koUMR6sbX38L49Bk2Vni628=
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20190422055009-d6f9ba25cf48/go.mod h1:H1hAaFyv9cRV1ywoHvaqVoNSThBvWZ0JarRBcV+FSnE=
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696 h1:VGFnZAcLwPpt1sHlAxml+pGLZz9A2s+K/s1YNhPC91Y=
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
github.com/dsoprea/go-png-image-structure v0.0.0-20190624104353-c9b28dcdc5c8 h1:SVQfy5rBFZXzvGkU2MZ0RzpS912/1sJrEJ+FMmeaC9U=
github.com/dsoprea/go-png-image-structure v0.0.0-20190624104353-c9b28dcdc5c8/go.mod h1:Bf0nmcDFFRQBjZwr9qY6c0zTxKQa+Q8YWZmlYxXGxY0=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@ -173,6 +181,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-gonic/gin v1.7.0/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -229,6 +239,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/geo v0.0.0-20190812012225-f41920e961ce h1:rqIKPpIcEgiNn0KYNFYD34TbMO86l4woyhNzSP+Oegs=
github.com/golang/geo v0.0.0-20190812012225-f41920e961ce/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -605,6 +617,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405 h1:2ieGkj4z/YPXVyQ2ayZUg3GwE1pYWd5f1RB6DzAOXKM=
github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405/go.mod h1:rIxVzVLKlBwLxO+lC+k/I4HJfRQcemg/f/76Xmmzsec=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
github.com/segmentfault/pacman v1.0.3 h1:/K8LJHQMiCaCIvC/e8GQITpYTEG6RH4KTLTZjPTghl4=
@ -851,8 +865,9 @@ 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.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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=
@ -945,12 +960,14 @@ 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.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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=
@ -960,8 +977,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -1144,6 +1161,7 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gG
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@ -12,6 +12,17 @@ backend:
other: Unauthorized.
database_error:
other: Data server error.
action:
report:
other: Flag
edit:
other: Edit
delete:
other: Delete
close:
other: Close
reopen:
other: Reopen
role:
name:
user:
@ -100,6 +111,8 @@ backend:
rank:
fail_to_meet_the_condition:
other: Rank fail to meet the condition.
vote_fail_to_meet_the_condition:
other: Thanks for the feedback. You need at least {{ rank }} reputation to cast a vote.
report:
handle_failed:
other: Report handle failed.
@ -303,6 +316,7 @@ ui:
users: Users
http_404: HTTP Error 404
http_50X: HTTP Error 500
http_403: HTTP Error 403
notifications:
title: Notifications
inbox: Inbox
@ -525,6 +539,7 @@ ui:
tip_answer: >-
Use comments to reply to other users or notify them of changes. If you are
adding new information, edit your post instead of commenting.
tip_vote: It adds something useful to the post
edit_answer:
title: Edit Answer
default_reason: Edit answer
@ -784,6 +799,11 @@ ui:
answered: answered
closed_in: Closed in
show_exist: Show existing question.
useful: Useful
question_useful: It is useful and clear
question_un_useful: It is unclear or not useful
answer_useful: It is useful
answer_un_useful: It is not useful
answers:
title: Answers
score: Score
@ -801,10 +821,19 @@ ui:
edit link to refine and improve your existing answer, instead.</p>
empty: Answer cannot be empty.
characters: content must be at least 6 characters in length.
tips:
header_1: Thanks for your answer
li1_1: Please be sure to <strong>answer the question</strong>. Provide details and share your research.
li1_2: Back up any statements you make with references or personal experience.
header_2: But <strong>avoid</strong> ...
li2_1: Asking for help, seeking clarification, or responding to other answers.
reopen:
confirm_btn: Reopen
title: Reopen this post
content: Are you sure you want to reopen?
success: This post has been reopened
delete:
title: Delete this post
question: >-
@ -1029,13 +1058,11 @@ ui:
votes: votes
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.
page_error:
http_error: HTTP Error {{ code }}
desc_403: You dont have permission to access this page.
desc_404: Unfortunately, this page doesn't exist.
desc_50X: The server encountered an error and could not complete your request.
back_home: Back to homepage
page_maintenance:
desc: "We are under maintenance, we'll be back soon."
@ -1421,8 +1448,8 @@ ui:
no_data: "We couldn't find anything."
users:
title: Users
users_with_the_most_reputation: Users with the highest reputation scores
users_with_the_most_vote: Users who voted the most
users_with_the_most_reputation: Users with the highest reputation scores this week
users_with_the_most_vote: Users who voted the most this week
staffs: Our community staff
reputation: reputation
votes: votes

View File

@ -11,6 +11,17 @@ backend:
other: 未授权。
database_error:
other: 数据服务器错误。
action:
report:
other: 举报
edit:
other: 编辑
delete:
other: 删除
close:
other: 关闭
reopen:
other: 重新打开
role:
name:
user:
@ -99,6 +110,8 @@ backend:
rank:
fail_to_meet_the_condition:
other: 级别不符合条件
vote_fail_to_meet_the_condition:
other: 感谢您的投票。您至少需要{{ rank }}声望才能投票。
report:
handle_failed:
other: 报告处理失败
@ -985,13 +998,11 @@ ui:
votes: 个点赞
answers: 个回答
accepted: 已被采纳
page_404:
http_error: HTTP 错误 404
desc: "很抱歉,此页面不存在。"
back_home: 回到主页
page_50X:
http_error: HTTP 错误 500
desc: 服务器遇到了一个错误,无法完成你的请求。
page_error:
http_error: HTTP Error {{ code }}
desc_403: 你无权访问此页面。
desc_404: 很抱歉,此页面不存在。
desc_50X: 服务器遇到了一个错误,无法完成你的请求。
back_home: 回到主页
page_maintenance:
desc: "我们正在进行维护,我们将很快回来。"
@ -1374,8 +1385,8 @@ ui:
no_data: "空空如也"
users:
title: 用户
users_with_the_most_reputation: 信誉积分最高的用户
users_with_the_most_vote: 投票最多的用户
users_with_the_most_reputation: 本周信誉积分最高的用户
users_with_the_most_vote: 本周投票最多的用户
staffs: 我们的社区工作人员
reputation: 声望值
votes: 投票

View File

@ -985,13 +985,11 @@ ui:
votes: 得票
answers: 回答
accepted: 已採納
page_404:
http_error: HTTP Error 404
desc: "很抱歉,此頁面不存在。"
back_home: 回到首頁
page_50X:
http_error: HTTP Error 500
desc: 伺服器遇到了一個錯誤,無法完成你的請求。
page_error:
http_error: HTTP Error {{ code }}
desc_403: 你无权访问此頁面。
desc_404: 很抱歉,此頁面不存在。
desc_50X: 伺服器遇到了一個錯誤,無法完成你的請求。
back_home: 回到首頁
page_maintenance:
desc: "我們正在維護中,很快就會回來。"

View File

@ -30,7 +30,8 @@ const (
// object TagID AnswerList
// key equal database's table name
var (
Version string = ""
Version string = ""
Revision string = ""
PathIgnoreMap map[string]bool

View File

@ -3,6 +3,7 @@ package handler
import (
"errors"
"net/http"
"strings"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/reason"
@ -73,3 +74,10 @@ func BindAndCheckReturnErr(ctx *gin.Context, data interface{}) (errFields []*val
errFields, _ = validator.GetValidatorByLang(lang).Check(data)
return errFields
}
func MsgWithParameter(msg string, list map[string]string) string {
for k, v := range list {
msg = strings.Replace(msg, "{{ "+k+" }}", v, -1)
}
return msg
}

View File

@ -124,7 +124,7 @@ func (am *AuthUserMiddleware) AdminAuth() gin.HandlerFunc {
}
userInfo, err := am.authService.GetAdminUserCacheInfo(ctx, token)
if err != nil {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
handler.HandleResponse(ctx, errors.Forbidden(reason.UnauthorizedError), nil)
ctx.Abort()
return
}

View File

@ -48,6 +48,7 @@ const (
TagIsUsedCannotDelete = "error.tag.is_used_cannot_delete"
TagAlreadyExist = "error.tag.already_exist"
RankFailToMeetTheCondition = "error.rank.fail_to_meet_the_condition"
VoteRankFailToMeetTheCondition = "error.rank.vote_fail_to_meet_the_condition"
ThemeNotFound = "error.theme.not_found"
LangNotFound = "error.lang.not_found"
ReportHandleFailed = "error.report.handle_failed"

View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/controller"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/pkg/converter"
"github.com/answerdev/answer/pkg/day"
@ -41,6 +42,9 @@ var funcMap = template.FuncMap{
"templateHTML": func(data string) template.HTML {
return template.HTML(data)
},
"formatLinkNofollow": func(data string) template.HTML {
return template.HTML(FormatLinkNofollow(data))
},
"translator": func(la i18n.Language, data string, params ...interface{}) string {
trans := translator.GlobalTrans.Tr(la, data)
@ -116,3 +120,21 @@ var funcMap = template.FuncMap{
return htmltext.UrlTitle(title)
},
}
func FormatLinkNofollow(html string) string {
var hrefRegexp = regexp.MustCompile("(?m)<a.*?[^<]>.*?</a>")
match := hrefRegexp.FindAllString(html, -1)
if match != nil {
for _, v := range match {
hasNofollow := strings.Contains(v, "rel=\"nofollow\"")
hasSiteUrl := strings.Contains(v, controller.SiteUrl)
if !hasSiteUrl {
if !hasNofollow {
nofollowUrl := strings.Replace(v, "<a", "<a rel=\"nofollow\"", 1)
html = strings.Replace(html, v, nofollowUrl, 1)
}
}
}
}
return html
}

View File

@ -81,6 +81,7 @@ func (ac *AnswerController) RemoveAnswer(ctx *gin.Context) {
// @Success 200 {string} string ""
func (ac *AnswerController) Get(ctx *gin.Context) {
id := ctx.Query("id")
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
info, questionInfo, has, err := ac.answerService.Get(ctx, id, userID)
@ -137,7 +138,6 @@ func (ac *AnswerController) Add(ctx *gin.Context) {
return
}
if !has {
// todo !has
handler.HandleResponse(ctx, nil, nil)
return
}
@ -181,6 +181,7 @@ func (ac *AnswerController) Update(ctx *gin.Context) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.QuestionID = uid.DeShortID(req.QuestionID)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
@ -232,6 +233,7 @@ func (ac *AnswerController) AnswerList(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.QuestionID = uid.DeShortID(req.QuestionID)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
@ -272,6 +274,8 @@ func (ac *AnswerController) Accepted(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.AnswerID = uid.DeShortID(req.AnswerID)
req.QuestionID = uid.DeShortID(req.QuestionID)
can, err := ac.rankService.CheckOperationPermission(ctx, req.UserID, permission.AnswerAccept, req.QuestionID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
@ -301,6 +305,7 @@ func (ac *AnswerController) AdminSetAnswerStatus(ctx *gin.Context) {
if handler.BindAndCheck(ctx, req) {
return
}
req.AnswerID = uid.DeShortID(req.AnswerID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)

View File

@ -14,18 +14,28 @@ import (
"github.com/answerdev/answer/pkg/converter"
"github.com/answerdev/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
)
// QuestionController question controller
type QuestionController struct {
questionService *service.QuestionService
answerService *service.AnswerService
rankService *rank.RankService
}
// NewQuestionController new controller
func NewQuestionController(questionService *service.QuestionService, rankService *rank.RankService) *QuestionController {
return &QuestionController{questionService: questionService, rankService: rankService}
func NewQuestionController(
questionService *service.QuestionService,
answerService *service.AnswerService,
rankService *rank.RankService,
) *QuestionController {
return &QuestionController{
questionService: questionService,
answerService: answerService,
rankService: rankService,
}
}
// RemoveQuestion delete question
@ -159,6 +169,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
return
}
info.ID = uid.EnShortID(info.ID)
handler.HandleResponse(ctx, nil, info)
}
@ -280,6 +291,109 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, resp)
}
// AddQuestionByAnswer add question
// @Summary add question and answer
// @Description add question and answer
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionAddByAnswer true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/answer [post]
func (qc *QuestionController) AddQuestionByAnswer(ctx *gin.Context) {
req := &schema.QuestionAddByAnswer{}
errFields := handler.BindAndCheckReturnErr(ctx, req)
if ctx.IsAborted() {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAdd,
permission.QuestionEdit,
permission.QuestionDelete,
permission.QuestionClose,
permission.QuestionReopen,
permission.TagUseReservedTag,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanAdd = canList[0]
req.CanEdit = canList[1]
req.CanDelete = canList[2]
req.CanClose = canList[3]
req.CanReopen = canList[4]
req.CanUseReservedTag = canList[5]
if !req.CanAdd {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
questionReq := new(schema.QuestionAdd)
err = copier.Copy(questionReq, req)
if err != nil {
handler.HandleResponse(ctx, errors.Forbidden(reason.RequestFormatError), nil)
return
}
errList, err := qc.questionService.CheckAddQuestion(ctx, questionReq)
if err != nil {
errlist, ok := errList.([]*validator.FormErrorField)
if ok {
errFields = append(errFields, errlist...)
}
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
resp, err := qc.questionService.AddQuestion(ctx, questionReq)
if err != nil {
errlist, ok := resp.([]*validator.FormErrorField)
if ok {
errFields = append(errFields, errlist...)
}
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
//add the question id to the answer
questionInfo, ok := resp.(*schema.QuestionInfo)
if ok {
answerReq := &schema.AnswerAddReq{}
answerReq.QuestionID = uid.DeShortID(questionInfo.ID)
answerReq.UserID = middleware.GetLoginUserIDFromContext(ctx)
answerReq.Content = req.AnswerContent
answerReq.HTML = req.AnswerHTML
answerID, err := qc.answerService.Insert(ctx, answerReq)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
info, questionInfo, has, err := qc.answerService.Get(ctx, answerID, req.UserID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !has {
handler.HandleResponse(ctx, nil, nil)
return
}
handler.HandleResponse(ctx, err, gin.H{
"info": info,
"question": questionInfo,
})
return
}
handler.HandleResponse(ctx, err, resp)
}
// UpdateQuestion update question
// @Summary update question
// @Description update question

View File

@ -5,6 +5,7 @@ import (
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/middleware"
"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"
"github.com/answerdev/answer/internal/service/permission"
@ -52,7 +53,13 @@ func (rc *RevisionController) GetRevisionList(ctx *gin.Context) {
}
resp, err := rc.revisionListService.GetRevisionList(ctx, req)
handler.HandleResponse(ctx, err, resp)
list := make([]schema.GetRevisionResp, 0)
for _, item := range resp {
if item.Status == entity.RevisioNnormalStatus || item.Status == entity.RevisionReviewPassStatus {
list = append(list, item)
}
}
handler.HandleResponse(ctx, err, list)
}
// GetUnreviewedRevisionList godoc

View File

@ -31,7 +31,7 @@ func NewSiteinfoController(siteInfoService *siteinfo_common.SiteInfoCommonServic
// @Router /answer/api/v1/siteinfo [get]
func (sc *SiteinfoController) GetSiteInfo(ctx *gin.Context) {
var err error
resp := &schema.SiteInfoResp{Version: constant.Version}
resp := &schema.SiteInfoResp{Version: constant.Version, Revision: constant.Revision}
resp.General, err = sc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error(err)
@ -103,6 +103,7 @@ func (sc *SiteinfoController) GetManifestJson(ctx *gin.Context) {
resp := &schema.GetManifestJsonResp{
ManifestVersion: 3,
Version: constant.Version,
Revision: constant.Revision,
ShortName: "Answer",
Name: "Answer.dev",
Icons: map[string]string{

View File

@ -23,6 +23,8 @@ import (
"github.com/segmentfault/pacman/log"
)
var SiteUrl = ""
type TemplateController struct {
scriptPath string
cssPath string
@ -67,6 +69,7 @@ func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInf
if err != nil {
log.Error(err)
}
SiteUrl = resp.General.SiteUrl
resp.Interface, err = tc.siteInfoService.GetSiteInterface(ctx)
if err != nil {
log.Error(err)
@ -471,6 +474,7 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI
data["HeaderCode"] = siteInfo.CustomCssHtml.CustomHeader
data["FooterCode"] = siteInfo.CustomCssHtml.CustomFooter
data["Version"] = constant.Version
data["Revision"] = constant.Revision
_, ok := data["path"]
if !ok {
data["path"] = ""

View File

@ -529,9 +529,9 @@ func (uc *UserController) UserChangeEmailVerify(ctx *gin.Context) {
return
}
err := uc.userService.UserChangeEmailVerify(ctx, req.Content)
resp, err := uc.userService.UserChangeEmailVerify(ctx, req.Content)
uc.actionService.ActionRecordDel(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP())
handler.HandleResponse(ctx, err, nil)
handler.HandleResponse(ctx, err, resp)
}
// UserRanking get user ranking

View File

@ -1,9 +1,12 @@
package controller
import (
"fmt"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/middleware"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service"
"github.com/answerdev/answer/internal/service/rank"
@ -41,13 +44,16 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) {
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, _, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true)
can, rank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
lang := handler.GetLang(ctx)
msg := translator.Tr(lang, reason.VoteRankFailToMeetTheCondition)
msg = handler.MsgWithParameter(msg, map[string]string{"rank": fmt.Sprintf("%d", rank)})
handler.HandleResponse(ctx, errors.Forbidden(reason.VoteRankFailToMeetTheCondition).WithMsg(msg), nil)
return
}
@ -78,13 +84,16 @@ func (vc *VoteController) VoteDown(ctx *gin.Context) {
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, _, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false)
can, rank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
lang := handler.GetLang(ctx)
msg := translator.Tr(lang, reason.VoteRankFailToMeetTheCondition)
msg = handler.MsgWithParameter(msg, map[string]string{"rank": fmt.Sprintf("%d", rank)})
handler.HandleResponse(ctx, errors.Forbidden(reason.VoteRankFailToMeetTheCondition).WithMsg(msg), nil)
return
}

View File

@ -5,6 +5,8 @@ import (
)
const (
// RevisioNnormalStatus this revision is Nnormal
RevisioNnormalStatus = 0
// RevisionUnreviewedStatus this revision is unreviewed
RevisionUnreviewedStatus = 1
// RevisionReviewPassStatus this revision is reviewed and approved by operator

View File

@ -2,6 +2,7 @@ package tag_common
import (
"context"
"fmt"
"github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/base/pager"
@ -45,7 +46,7 @@ func (tr *tagCommonRepo) GetTagListByIDs(ctx context.Context, ids []string) (tag
// GetTagBySlugName get tag by slug name
func (tr *tagCommonRepo) GetTagBySlugName(ctx context.Context, slugName string) (tagInfo *entity.Tag, exist bool, err error) {
tagInfo = &entity.Tag{}
session := tr.data.DB.Where("slug_name = ?", slugName)
session := tr.data.DB.Where("slug_name = LOWER(?)", slugName)
session.Where(builder.Eq{"status": entity.TagStatusAvailable})
exist, err = session.Get(tagInfo)
if err != nil {
@ -60,7 +61,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 ? or display_name LIKE ?", name+"%", name+"%")
session.Where("slug_name LIKE LOWER(?) or display_name LIKE ?", name+"%", name+"%")
} else {
session.UseBool("recommend")
cond.Recommend = true
@ -106,6 +107,7 @@ func (tr *tagCommonRepo) GetReservedTagList(ctx context.Context) (tagList []*ent
// GetTagListByNames get tag list all like name
func (tr *tagCommonRepo) GetTagListByNames(ctx context.Context, names []string) (tagList []*entity.Tag, err error) {
tagList = make([]*entity.Tag, 0)
session := tr.data.DB.In("slug_name", names).UseBool("recommend", "reserved")
// session.Where(builder.Eq{"status": entity.TagStatusAvailable})
@ -140,7 +142,7 @@ func (tr *tagCommonRepo) GetTagPage(ctx context.Context, page, pageSize int, tag
session := tr.data.DB.NewSession()
if len(tag.SlugName) > 0 {
session.Where(builder.Or(builder.Like{"slug_name", tag.SlugName}, builder.Like{"display_name", tag.SlugName}))
session.Where(builder.Or(builder.Like{"slug_name", fmt.Sprintf("LOWER(%s)", tag.SlugName)}, builder.Like{"display_name", tag.SlugName}))
tag.SlugName = ""
}
session.Where(builder.Eq{"status": entity.TagStatusAvailable})

View File

@ -186,6 +186,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
// question
r.POST("/question", a.questionController.AddQuestion)
r.POST("/question/answer", a.questionController.AddQuestionByAnswer)
r.PUT("/question", a.questionController.UpdateQuestion)
r.DELETE("/question", a.questionController.RemoveQuestion)
r.PUT("/question/status", a.questionController.CloseQuestion)

View File

@ -27,6 +27,7 @@ type DashboardInfo struct {
type DashboardInfoVersion struct {
Version string `json:"version"`
Revision string `json:"revision"`
RemoteVersion string `json:"remote_version"`
}

View File

@ -63,6 +63,33 @@ func (req *QuestionAdd) Check() (errFields []*validator.FormErrorField, err erro
return nil, nil
}
type QuestionAddByAnswer struct {
// question title
Title string `validate:"required,notblank,gte=6,lte=150" json:"title"`
// content
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
// html
HTML string `json:"-"`
AnswerContent string `validate:"required,notblank,gte=6,lte=65535" json:"answer_content"`
AnswerHTML string `json:"-"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
// user id
UserID string `json:"-"`
QuestionPermission
}
func (req *QuestionAddByAnswer) Check() (errFields []*validator.FormErrorField, err error) {
req.HTML = converter.Markdown2HTML(req.Content)
req.AnswerHTML = converter.Markdown2HTML(req.AnswerContent)
for _, tag := range req.Tags {
if len(tag.OriginalText) > 0 {
tag.ParsedText = converter.Markdown2HTML(tag.OriginalText)
}
}
return nil, nil
}
type QuestionPermission struct {
// whether user can add it
CanAdd bool `json:"-"`

View File

@ -170,6 +170,7 @@ type SiteInfoResp struct {
CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"`
SiteSeo *SiteSeoReq `json:"site_seo"`
Version string `json:"version"`
Revision string `json:"revision"`
}
type TemplateSiteInfoResp struct {
General *SiteGeneralResp `json:"general"`
@ -225,6 +226,7 @@ type GetSMTPConfigResp struct {
type GetManifestJsonResp struct {
ManifestVersion int `json:"manifest_version"`
Version string `json:"version"`
Revision string `json:"revision"`
ShortName string `json:"short_name"`
Name string `json:"name"`
Icons map[string]string `json:"icons"`

View File

@ -18,6 +18,7 @@ import (
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/converter"
"github.com/answerdev/answer/pkg/obj"
"github.com/answerdev/answer/pkg/uid"
"github.com/segmentfault/pacman/log"
)
@ -91,6 +92,10 @@ func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.Ge
item.CancelledAt = act.CancelledAt.Unix()
}
if item.ObjectType == constant.QuestionObjectType || item.ObjectType == constant.AnswerObjectType {
item.ObjectID = uid.EnShortID(act.ObjectID)
}
// database save activity type is number, change to activity type string is like "question.asked".
// so we need to cut the front part of '.'
_, item.ActivityType, _ = strings.Cut(config.ID2KeyMapping[act.ActivityType], ".")

View File

@ -326,6 +326,7 @@ func (as *AnswerService) UpdateAccepted(ctx context.Context, req *schema.AnswerA
if err != nil {
return err
}
newAnswerInfo.ID = uid.DeShortID(newAnswerInfo.ID)
if !newAnswerInfoexist {
return errors.BadRequest(reason.AnswerNotFound)
}
@ -335,12 +336,13 @@ func (as *AnswerService) UpdateAccepted(ctx context.Context, req *schema.AnswerA
if err != nil {
return err
}
questionInfo.ID = uid.DeShortID(questionInfo.ID)
if !exist {
return errors.BadRequest(reason.QuestionNotFound)
}
if questionInfo.UserID != req.UserID {
return fmt.Errorf("no permission to set answer")
}
// if questionInfo.UserID != req.UserID {
// return fmt.Errorf("no permission to set answer")
// }
if questionInfo.AcceptedAnswerID == req.AnswerID {
return nil
}
@ -351,6 +353,7 @@ func (as *AnswerService) UpdateAccepted(ctx context.Context, req *schema.AnswerA
if err != nil {
return err
}
oldAnswerInfo.ID = uid.DeShortID(oldAnswerInfo.ID)
}
err = as.answerRepo.UpdateAccepted(ctx, req.AnswerID, req.QuestionID)

View File

@ -20,7 +20,6 @@ import (
"github.com/answerdev/answer/pkg/encryption"
"github.com/answerdev/answer/pkg/htmltext"
"github.com/answerdev/answer/pkg/uid"
"github.com/davecgh/go-spew/spew"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
@ -448,7 +447,6 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s
if err != nil {
log.Error(err)
} else {
spew.Dump("==", objInfo)
commentResp.ObjectType = objInfo.ObjectType
commentResp.Title = objInfo.Title
commentResp.UrlTitle = htmltext.UrlTitle(objInfo.Title)

View File

@ -90,6 +90,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
dashboardInfo.VersionInfo.Revision = constant.Revision
return dashboardInfo, nil
}
@ -194,6 +195,7 @@ func (ds *DashboardService) Statistical(ctx context.Context) (*schema.DashboardI
dashboardInfo.AppStartTime = fmt.Sprintf("%d", startTime)
dashboardInfo.TimeZone = siteInfoInterface.TimeZone
dashboardInfo.VersionInfo.Version = constant.Version
dashboardInfo.VersionInfo.Revision = constant.Revision
dashboardInfo.VersionInfo.RemoteVersion = ds.RemoteVersion(ctx)
return dashboardInfo, nil
}

View File

@ -11,6 +11,7 @@ import (
questioncommon "github.com/answerdev/answer/internal/service/question_common"
tagcommon "github.com/answerdev/answer/internal/service/tag_common"
"github.com/answerdev/answer/pkg/obj"
"github.com/answerdev/answer/pkg/uid"
"github.com/segmentfault/pacman/errors"
)
@ -50,6 +51,7 @@ func (os *ObjService) GetUnreviewedRevisionInfo(ctx context.Context, objectID st
if err != nil {
return nil, err
}
questionInfo.ID = uid.EnShortID(questionInfo.ID)
if !exist {
break
}
@ -85,6 +87,7 @@ func (os *ObjService) GetUnreviewedRevisionInfo(ctx context.Context, objectID st
if !exist {
break
}
questionInfo.ID = uid.EnShortID(questionInfo.ID)
objInfo = &schema.UnreviewedRevisionInfoInfo{
ObjectID: answerInfo.ID,
Title: questionInfo.Title,

View File

@ -3,24 +3,27 @@ package permission
import (
"context"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/schema"
)
// GetAnswerPermission get answer permission
func GetAnswerPermission(ctx context.Context, userID string, creatorUserID string, canEdit, canDelete bool) (
actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0)
if len(userID) > 0 {
actions = append(actions, &schema.PermissionMemberAction{
Action: "report",
Name: "Flag",
Name: translator.Tr(lang, reportActionName),
Type: "reason",
})
}
if canEdit || userID == creatorUserID {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",
Name: translator.Tr(lang, editActionName),
Type: "edit",
})
}
@ -28,7 +31,7 @@ func GetAnswerPermission(ctx context.Context, userID string, creatorUserID strin
if canDelete || userID == creatorUserID {
actions = append(actions, &schema.PermissionMemberAction{
Action: "delete",
Name: "Delete",
Name: translator.Tr(lang, deleteActionName),
Type: "confirm",
})
}

View File

@ -5,17 +5,20 @@ import (
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/schema"
)
// GetCommentPermission get comment permission
func GetCommentPermission(ctx context.Context, userID string, creatorUserID string,
createdAt time.Time, canEdit, canDelete bool) (actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0)
if len(userID) > 0 {
actions = append(actions, &schema.PermissionMemberAction{
Action: "report",
Name: "Flag",
Name: translator.Tr(lang, reportActionName),
Type: "reason",
})
}
@ -23,7 +26,7 @@ func GetCommentPermission(ctx context.Context, userID string, creatorUserID stri
if canEdit || (userID == creatorUserID && time.Now().Before(deadline)) {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",
Name: translator.Tr(lang, editActionName),
Type: "edit",
})
}
@ -31,7 +34,7 @@ func GetCommentPermission(ctx context.Context, userID string, creatorUserID stri
if canDelete || userID == creatorUserID {
actions = append(actions, &schema.PermissionMemberAction{
Action: "delete",
Name: "Delete",
Name: translator.Tr(lang, deleteActionName),
Type: "reason",
})
}

View File

@ -36,3 +36,11 @@ const (
TagAudit = "tag.audit"
TagUseReservedTag = "tag.use_reserved_tag"
)
const (
reportActionName = "action.report"
editActionName = "action.edit"
deleteActionName = "action.delete"
closeActionName = "action.close"
reopenActionName = "action.reopen"
)

View File

@ -3,6 +3,8 @@ package permission
import (
"context"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/schema"
)
@ -10,39 +12,40 @@ import (
func GetQuestionPermission(ctx context.Context, userID string, creatorUserID string,
canEdit, canDelete, canClose, canReopen bool) (
actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0)
if len(userID) > 0 {
actions = append(actions, &schema.PermissionMemberAction{
Action: "report",
Name: "Flag",
Name: translator.Tr(lang, reportActionName),
Type: "reason",
})
}
if canEdit || userID == creatorUserID {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",
Name: translator.Tr(lang, editActionName),
Type: "edit",
})
}
if canClose {
actions = append(actions, &schema.PermissionMemberAction{
Action: "close",
Name: "Close",
Name: translator.Tr(lang, closeActionName),
Type: "confirm",
})
}
if canReopen {
actions = append(actions, &schema.PermissionMemberAction{
Action: "reopen",
Name: "Reopen",
Name: translator.Tr(lang, reopenActionName),
Type: "confirm",
})
}
if canDelete || userID == creatorUserID {
actions = append(actions, &schema.PermissionMemberAction{
Action: "delete",
Name: "Delete",
Name: translator.Tr(lang, deleteActionName),
Type: "confirm",
})
}

View File

@ -3,17 +3,20 @@ package permission
import (
"context"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/schema"
)
// GetTagPermission get tag permission
func GetTagPermission(ctx context.Context, canEdit, canDelete bool) (
actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0)
if canEdit {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",
Name: translator.Tr(lang, editActionName),
Type: "edit",
})
}
@ -21,7 +24,7 @@ func GetTagPermission(ctx context.Context, canEdit, canDelete bool) (
if canDelete {
actions = append(actions, &schema.PermissionMemberAction{
Action: "delete",
Name: "Delete",
Name: translator.Tr(lang, deleteActionName),
Type: "reason",
})
}
@ -31,11 +34,12 @@ func GetTagPermission(ctx context.Context, canEdit, canDelete bool) (
// GetTagSynonymPermission get tag synonym permission
func GetTagSynonymPermission(ctx context.Context, canEdit bool) (
actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0)
if canEdit {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",
Name: translator.Tr(lang, editActionName),
Type: "edit",
})
}

View File

@ -335,12 +335,15 @@ func (qs *QuestionCommon) FormatQuestionsPage(
} else {
item.Tags = make([]*schema.TagResp, 0)
}
userInfo := userInfoMap[item.Operator.ID]
if userInfo != nil {
item.Operator.DisplayName = userInfo.DisplayName
item.Operator.Username = userInfo.Username
item.Operator.Rank = userInfo.Rank
userInfo, ok := userInfoMap[item.Operator.ID]
if ok {
if userInfo != nil {
item.Operator.DisplayName = userInfo.DisplayName
item.Operator.Username = userInfo.Username
item.Operator.Rank = userInfo.Rank
}
}
}
return formattedQuestions, nil
}
@ -414,6 +417,11 @@ func (qs *QuestionCommon) RemoveQuestion(ctx context.Context, req *schema.Remove
if !has {
return nil
}
if questionInfo.Status == entity.QuestionStatusDeleted {
return nil
}
questionInfo.Status = entity.QuestionStatusDeleted
err = qs.questionRepo.UpdateQuestionStatus(ctx, questionInfo)
if err != nil {

View File

@ -572,6 +572,7 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
// It's not you or the administrator that needs to be reviewed
if !canUpdate {
revisionDTO.Status = entity.RevisionUnreviewedStatus
revisionDTO.UserID = req.UserID //use revision userid
} else {
//Direct modification
revisionDTO.Status = entity.RevisionReviewPassStatus

View File

@ -85,13 +85,42 @@ func (ts *TagCommonService) SearchTagLike(ctx context.Context, req *schema.Searc
return
}
ts.TagsFormatRecommendAndReserved(ctx, tags)
mainTagId := make([]string, 0)
for _, tag := range tags {
item := schema.SearchTagLikeResp{}
item.SlugName = tag.SlugName
item.DisplayName = tag.DisplayName
item.Recommend = tag.Recommend
item.Reserved = tag.Reserved
resp = append(resp, item)
if tag.MainTagID != 0 {
mainTagId = append(mainTagId, converter.IntToString(tag.MainTagID))
}
}
mainTagList, err := ts.tagCommonRepo.GetTagListByIDs(ctx, mainTagId)
if err != nil {
return
}
mainTagMap := make(map[string]*entity.Tag)
for _, tag := range mainTagList {
mainTagMap[tag.ID] = tag
}
for _, tag := range tags {
if tag.MainTagID != 0 {
_, ok := mainTagMap[converter.IntToString(tag.MainTagID)]
if ok {
tag.SlugName = mainTagMap[converter.IntToString(tag.MainTagID)].SlugName
tag.DisplayName = mainTagMap[converter.IntToString(tag.MainTagID)].DisplayName
tag.Reserved = mainTagMap[converter.IntToString(tag.MainTagID)].Reserved
tag.Recommend = mainTagMap[converter.IntToString(tag.MainTagID)].Recommend
}
}
}
RepetitiveTag := make(map[string]bool)
for _, tag := range tags {
if _, ok := RepetitiveTag[tag.SlugName]; !ok {
item := schema.SearchTagLikeResp{}
item.SlugName = tag.SlugName
item.DisplayName = tag.DisplayName
item.Recommend = tag.Recommend
item.Reserved = tag.Reserved
resp = append(resp, item)
RepetitiveTag[tag.SlugName] = true
}
}
return resp, nil
}
@ -233,8 +262,10 @@ func (ts *TagCommonService) AddTag(ctx context.Context, req *schema.AddTagReq) (
if exist {
return nil, errors.BadRequest(reason.TagAlreadyExist)
}
SlugName := strings.ReplaceAll(req.SlugName, " ", "-")
SlugName = strings.ToLower(SlugName)
tagInfo := &entity.Tag{
SlugName: strings.ReplaceAll(req.SlugName, " ", "-"),
SlugName: SlugName,
DisplayName: req.DisplayName,
OriginalText: req.OriginalText,
ParsedText: req.ParsedText,
@ -535,7 +566,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)
}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"os"
@ -19,6 +20,7 @@ import (
"github.com/answerdev/answer/pkg/uid"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
exifremove "github.com/scottleedavis/go-exif-remove"
"github.com/segmentfault/pacman/errors"
)
@ -192,6 +194,7 @@ func (us *UploaderService) uploadFile(ctx *gin.Context, file *multipart.FileHead
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
defer src.Close()
Dexif(filePath, filePath)
if !checker.IsSupportedImageFile(src, filepath.Ext(fileSubPath)) {
return "", errors.BadRequest(reason.UploadFileUnsupportedFileFormat)
@ -200,3 +203,19 @@ func (us *UploaderService) uploadFile(ctx *gin.Context, file *multipart.FileHead
url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath)
return url, nil
}
func Dexif(filepath string, destpath string) error {
img, err := ioutil.ReadFile(filepath)
if err != nil {
return err
}
noExifBytes, err := exifremove.Remove(img)
if err != nil {
return err
}
err = os.WriteFile(destpath, noExifBytes, 0644)
if err != nil {
return err
}
return nil
}

View File

@ -535,37 +535,66 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
}
// UserChangeEmailVerify user change email verify code
func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string) (err error) {
func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string) (resp *schema.GetUserResp, err error) {
data := &schema.EmailCodeContent{}
err = data.FromJSONString(content)
if err != nil {
return errors.BadRequest(reason.EmailVerifyURLExpired)
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
}
_, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
if err != nil {
return err
return nil, err
}
if exist {
return errors.BadRequest(reason.EmailDuplicate)
return nil, errors.BadRequest(reason.EmailDuplicate)
}
_, exist, err = us.userRepo.GetByUserID(ctx, data.UserID)
userInfo, exist, err := us.userRepo.GetByUserID(ctx, data.UserID)
if err != nil {
return err
return nil, err
}
if !exist {
return errors.BadRequest(reason.UserNotFound)
return nil, errors.BadRequest(reason.UserNotFound)
}
err = us.userRepo.UpdateEmail(ctx, data.UserID, data.Email)
if err != nil {
return errors.BadRequest(reason.UserNotFound)
return nil, errors.BadRequest(reason.UserNotFound)
}
err = us.userRepo.UpdateEmailStatus(ctx, data.UserID, entity.EmailStatusAvailable)
if err != nil {
return err
return nil, err
}
return nil
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
if err != nil {
log.Error(err)
}
resp = &schema.GetUserResp{}
resp.GetFromUserEntity(userInfo)
userCacheInfo := &entity.UserCacheInfo{
UserID: userInfo.ID,
EmailStatus: entity.EmailStatusAvailable,
UserStatus: userInfo.Status,
RoleID: roleID,
}
resp.AccessToken, err = us.authService.SetUserCacheInfo(ctx, userCacheInfo)
if err != nil {
return nil, err
}
// User verified email will update user email status. So user status cache should be updated.
if err = us.authService.SetUserStatus(ctx, userCacheInfo); err != nil {
return nil, err
}
resp.RoleID = userCacheInfo.RoleID
if resp.RoleID == role.RoleAdminID {
err = us.authService.SetAdminUserCacheInfo(ctx, resp.AccessToken, &entity.UserCacheInfo{UserID: userInfo.ID})
if err != nil {
return nil, err
}
}
return resp, nil
}
// getSiteUrl get site url

View File

@ -34,6 +34,10 @@ func Markdown2HTML(source string) string {
}
html := buf.String()
filter := bluemonday.UGCPolicy()
filter.AllowStyling()
filter.RequireNoFollowOnLinks(false)
filter.RequireParseableURLs(false)
filter.RequireNoFollowOnFullyQualifiedLinks(false)
html = filter.Sanitize(html)
return html
}
@ -110,6 +114,7 @@ func (r *DangerousHTMLRenderer) renderLink(w util.BufWriter, source []byte, node
n := node.(*ast.Link)
if entering && r.renderLinkIsUrl(string(n.Destination)) {
_, _ = w.WriteString("<a href=\"")
// _, _ = w.WriteString("<a test=\"1\" rel=\"nofollow\" href=\"")
if r.Unsafe || !html.IsDangerousURL(n.Destination) {
_, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
}

View File

@ -64,6 +64,5 @@ func TestUrlTitle(t *testing.T) {
for _, title := range list {
formatTitle := UrlTitle(title)
spew.Dump(formatTitle)
}
}

View File

@ -48,7 +48,7 @@ func EnShortID(id string) string {
if ShortIDSwitch {
num, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return ""
return id
}
return NumToShortID(num)
}

View File

@ -22,9 +22,7 @@
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5",
"diff": "^5.1.0",
"dompurify": "^2.4.3",
"emoji-regex": "^10.2.1",
"html-react-parser": "^3.0.8",
"i18next": "^21.9.0",
"katex": "^0.16.2",
"lodash": "^4.17.21",
@ -84,7 +82,7 @@
"react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1",
"sass": "^1.54.4",
"typescript": "^4.8.3",
"typescript": "^4.9.5",
"yaml-loader": "^0.8.0"
},
"packageManager": "pnpm@7.9.5",

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,9 @@
{
name: 'Safari',
version: '15'
},
{
name: 'IE',
}
];
function getBrowerTypeAndVersion(){
@ -95,6 +98,7 @@
};
var ua = navigator.userAgent.toLowerCase();
var s;
((ua.indexOf("compatible") > -1 && ua.indexOf("MSIE") > -1) || (ua.indexOf('Trident') > -1 && ua.indexOf("rv:11.0") > -1)) ? brower = { name: 'IE', version: '' } :
(s = ua.match(/edge\/([\d\.]+)/)) ? brower = { name: 'Edge', version: s[1] } :
(s = ua.match(/firefox\/([\d\.]+)/)) ? brower = { name: 'Firefox', version: s[1] } :
(s = ua.match(/chrome\/([\d\.]+)/)) ? brower = { name: 'Chrome', version: s[1] } :
@ -126,16 +130,24 @@
}
const browerInfo = getBrowerTypeAndVersion();
const notSupport = defaultList.some(item => {
if (item.name === browerInfo.name) {
return compareVersion(browerInfo.version, item.version) === -1;
}
return false;
});
if (notSupport) {
if (browerInfo.name === 'IE') {
const div = document.getElementById('protect-brower');
div.innerText = 'The current browser version is too low, in order not to affect the normal use of the function, please upgrade the browser to the latest version.'
div.innerText = 'Sorry, this site does not support Internet Explorer. In order to avoid affecting the normal use of our features, please use a more modern browser such as Edge, Firefox, Chrome, or Safari.'
} else {
const notSupport = defaultList.some(item => {
if (item.name === browerInfo.name) {
return compareVersion(browerInfo.version, item.version) === -1;
}
return false;
});
if (notSupport) {
const div = document.getElementById('protect-brower');
div.innerText = 'The current browser version is too low, in order not to affect the normal use of the function, please upgrade the browser to the latest version.'
}
}
</script>
</html>

View File

@ -56,10 +56,13 @@ export interface QuestionParams {
title: string;
url_title?: string;
content: string;
html?: string;
tags: Tag[];
}
export interface QuestionWithAnswer extends QuestionParams {
answer_content: string;
}
export interface ListResult<T = any> {
count: number;
list: T[];

View File

@ -12,6 +12,7 @@ import { bookmark, postVote } from '@/services';
interface Props {
className?: string;
source: 'question' | 'answer';
data: {
id: string;
votesCount: number;
@ -24,7 +25,7 @@ interface Props {
};
}
const Index: FC<Props> = ({ className, data }) => {
const Index: FC<Props> = ({ className, data, source }) => {
const [votes, setVotes] = useState(0);
const [like, setLike] = useState(false);
const [hate, setHated] = useState(false);
@ -102,6 +103,11 @@ const Index: FC<Props> = ({ className, data }) => {
<div className={classNames(className)}>
<ButtonGroup>
<Button
title={
source === 'question'
? t('question_detail.question_useful')
: t('question_detail.answer_useful')
}
variant="outline-secondary"
active={like}
onClick={() => handleVote('up')}>
@ -111,6 +117,11 @@ const Index: FC<Props> = ({ className, data }) => {
{votes}
</Button>
<Button
title={
source === 'question'
? t('question_detail.question_un_useful')
: t('question_detail.answer_un_useful')
}
variant="outline-secondary"
active={hate}
onClick={() => handleVote('down')}>

View File

@ -32,6 +32,7 @@ const ActionBar = ({
<span className="mx-1"></span>
<FormatTime time={createdAt} className="me-3" />
<Button
title={t('tip_vote')}
variant="link"
size="sm"
className={`me-3 btn-no-border p-0 ${isVote ? '' : 'link-secondary'}`}

View File

@ -32,7 +32,7 @@ const ActivateScriptNodes = (el, part) => {
scriptList.push(node);
}
}
scriptList.forEach((so) => {
scriptList?.forEach((so) => {
const script = document.createElement('script');
script.text = so.text;
for (let i = 0; i < so.attributes.length; i += 1) {

View File

@ -8,7 +8,6 @@ import {
} from 'react';
import { markdownToHtml } from '@/services';
import { htmlToReact } from '@/utils';
import { htmlRender } from './utils';
@ -51,9 +50,9 @@ const Index = ({ value }, ref) => {
return (
<div
ref={previewRef}
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt">
{htmlToReact(html)}
</div>
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};

View File

@ -80,6 +80,15 @@ export function createEditorUtils(
export function htmlRender(el: HTMLElement | null) {
if (!el) return;
// Replace all br tags with newlines
// Fixed an issue where the BR tag in the editor block formula HTML caused rendering errors.
el.querySelectorAll('p').forEach((p) => {
if (p.innerHTML.startsWith('$$') && p.innerHTML.endsWith('$$')) {
const str = p.innerHTML.replace(/<br>/g, '\n');
p.innerHTML = str;
}
});
import('mermaid').then(({ default: mermaid }) => {
mermaid.initialize({ startOnLoad: false });
@ -99,6 +108,7 @@ export function htmlRender(el: HTMLElement | null) {
render(el, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$$<br>', right: '<br>$$', display: true },
{
left: '\\begin{equation}',
right: '\\end{equation}',
@ -114,8 +124,31 @@ export function htmlRender(el: HTMLElement | null) {
},
);
// 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.
*/
// change table style
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);
});
// add rel nofollow for link not inlcludes domain
el.querySelectorAll('a').forEach((a) => {
const base = window.location.origin;
const targetUrl = new URL(a.href, base);
if (targetUrl.origin !== base) {
a.rel = 'nofollow';
}
});
}

View File

@ -0,0 +1,47 @@
import { memo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
const Index = ({ httpCode = '', errMsg = '' }) => {
const { t } = useTranslation('translation', { keyPrefix: 'page_error' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap') as HTMLElement;
if (pageWrap) {
pageWrap.style.display = 'contents';
}
return () => {
if (pageWrap) {
pageWrap.style.display = 'block';
}
};
}, []);
usePageTags({
title: t(`http_${httpCode}`, { keyPrefix: 'page_title' }),
});
return (
<div className="d-flex flex-column flex-shrink-1 flex-grow-1 justify-content-center align-items-center">
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=)
</div>
<h4 className="text-center">{t('http_error', { code: httpCode })}</h4>
<div className="text-center mb-3 fs-5">
{errMsg || t(`desc_${httpCode}`)}
</div>
<div className="text-center">
<Link to="/" className="btn btn-link">
{t('back_home')}
</Link>
</div>
</div>
);
};
export default memo(Index);

View File

@ -45,7 +45,7 @@ const Index = ({
cancelBtnVariant={cancelBtnVariant}
confirmBtnVariant={confirmBtnVariant}
{...props}>
<div dangerouslySetInnerHTML={{ __html: content }} />
<p dangerouslySetInnerHTML={{ __html: content }} />
</Modal>,
);
}

View File

@ -69,7 +69,7 @@ const Index: FC<IProps> = ({
if (type === 'question') {
Modal.confirm({
title: t('title'),
content: hasAnswer ? `<p>${t('question')}</p>` : `<p>${t('other')}</p>`,
content: hasAnswer ? t('question') : t('other'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
@ -90,7 +90,7 @@ const Index: FC<IProps> = ({
if (type === 'answer' && aid) {
Modal.confirm({
title: t('title'),
content: isAccepted ? t('answer_accepted') : `<p>${t('other')}</p>`,
content: isAccepted ? t('answer_accepted') : t('other'),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),
@ -128,6 +128,7 @@ const Index: FC<IProps> = ({
title: t('title', { keyPrefix: 'question_detail.reopen' }),
content: t('content', { keyPrefix: 'question_detail.reopen' }),
cancelBtnVariant: 'link',
confirmText: t('confirm_btn', { keyPrefix: 'question_detail.reopen' }),
onConfirm: () => {
reopenQuestion({
question_id: qid,

View File

@ -17,8 +17,8 @@ import {
} from '@/components';
const QuestionOrderKeys: Type.QuestionOrderBy[] = [
'newest',
'active',
'newest',
'frequent',
'score',
'unanswered',

View File

@ -30,7 +30,9 @@ const Index: FC<IProps> = ({
data.recommend && 'badge-tag-required',
className,
)}>
<span className={textClassName}>{data.display_name}</span>
<span className={textClassName}>
{data.display_name || data.slug_name}
</span>
</Link>
);
};

View File

@ -35,6 +35,7 @@ import TagsLoader from './TagsLoader';
import Counts from './Counts';
import QuestionList from './QuestionList';
import HotQuestions from './HotQuestions';
import HttpErrorContent from './HttpErrorContent';
export {
Avatar,
@ -76,5 +77,6 @@ export {
Counts,
QuestionList,
HotQuestions,
HttpErrorContent,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -218,6 +218,9 @@ img:not(a img, img.broken) {
img {
max-width: 100%;
}
video {
max-width: 100%;
}
p {
> code {
background-color: #e9ecef;

View File

@ -1,44 +0,0 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line import/no-unresolved
import { usePageTags } from '@/hooks';
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';
};
}, []);
usePageTags({
title: t('http_404', { keyPrefix: 'page_title' }),
});
return (
<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>
<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')}
</Button>
</div>
</Container>
);
};
export default Index;

View File

@ -0,0 +1,7 @@
import { HttpErrorContent } from '@/components';
const Index = () => {
return <HttpErrorContent httpCode="404" />;
};
export default Index;

View File

@ -1,45 +0,0 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line import/no-unresolved
import { usePageTags } from '@/hooks';
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';
};
}, []);
usePageTags({
title: t('http_50X', { keyPrefix: 'page_title' }),
});
return (
<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 }}>
(=T^T=)
</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')}
</Button>
</div>
</Container>
);
};
export default Index;

View File

@ -0,0 +1,7 @@
import { HttpErrorContent } from '@/components';
const Index = () => {
return <HttpErrorContent httpCode="50X" />;
};
export default Index;

View File

@ -58,7 +58,7 @@ const Answers: FC = () => {
content:
item.accepted === 2
? t('answer_accepted', { keyPrefix: 'delete' })
: `<p>${t('other', { keyPrefix: 'delete' })}</p>`,
: t('other', { keyPrefix: 'delete' }),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),

View File

@ -67,8 +67,8 @@ const Questions: FC = () => {
title: t('title', { keyPrefix: 'delete' }),
content:
item.answer_count > 0
? `<p>${t('question', { keyPrefix: 'delete' })}</p>`
: `<p>${t('other', { keyPrefix: 'delete' })}</p>`,
? t('question', { keyPrefix: 'delete' })
: t('other', { keyPrefix: 'delete' }),
cancelBtnVariant: 'link',
confirmBtnVariant: 'danger',
confirmText: t('delete', { keyPrefix: 'btns' }),

View File

@ -19,14 +19,14 @@ const Index: FC = () => {
type: 'number',
title: t('permalink.label'),
description: t('permalink.text'),
enum: [1, 2, 3, 4],
enum: [4, 3, 2, 1],
enumNames: [
'/questions/10010000000000001/post-title',
'/questions/10010000000000001',
'/questions/D1D1/post-title',
'/questions/D1D1',
'/questions/D1D1/post-title',
'/questions/10010000000000001',
'/questions/10010000000000001/post-title',
],
default: 1,
default: 4,
},
robots: {
type: 'string',

View File

@ -4,7 +4,7 @@ import { HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import { toastStore, loginToContinueStore, errorCode } from '@/stores';
import { toastStore, loginToContinueStore, errorCodeStore } from '@/stores';
import {
Header,
Footer,
@ -12,11 +12,10 @@ import {
Customize,
CustomizeTheme,
PageTags,
HttpErrorContent,
} from '@/components';
import { LoginToContinueModal } from '@/components/Modal';
import { useImgViewer } from '@/hooks';
import Component404 from '@/pages/404';
import Component50X from '@/pages/50X';
const Layout: FC = () => {
const location = useLocation();
@ -24,8 +23,7 @@ const Layout: FC = () => {
const closeToast = () => {
toastClear();
};
const { code: httpStatusCode, reset: httpStatusReset } = errorCode();
const { code: httpStatusCode, reset: httpStatusReset } = errorCodeStore();
const imgViewer = useImgViewer();
const { show: showLoginToContinueModal } = loginToContinueStore();
@ -45,10 +43,8 @@ const Layout: FC = () => {
<div
className="position-relative page-wrap"
onClick={imgViewer.checkClickForImgView}>
{httpStatusCode === '404' ? (
<Component404 />
) : httpStatusCode === '50X' ? (
<Component50X />
{httpStatusCode ? (
<HttpErrorContent httpCode={httpStatusCode} />
) : (
<Outlet />
)}

View File

@ -1,8 +1,9 @@
import { FC } from 'react';
import { FC, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { useLegalPrivacy } from '@/services';
import { htmlRender } from '@/components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
@ -12,6 +13,15 @@ const Index: FC = () => {
const { data: privacy } = useLegalPrivacy();
const contentText = privacy?.privacy_policy_original_text;
let matchUrl: URL | undefined;
useEffect(() => {
const fmt = document.querySelector('.fmt') as HTMLElement;
if (!fmt) {
return;
}
htmlRender(fmt);
}, [privacy?.privacy_policy_parsed_text]);
try {
if (contentText) {
matchUrl = new URL(contentText);

View File

@ -1,8 +1,9 @@
import { FC } from 'react';
import { FC, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { useLegalTos } from '@/services';
import { htmlRender } from '@/components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
@ -12,6 +13,15 @@ const Index: FC = () => {
const { data: tos } = useLegalTos();
const contentText = tos?.terms_of_service_original_text;
let matchUrl: URL | undefined;
useEffect(() => {
const fmt = document.querySelector('.fmt') as HTMLElement;
if (!fmt) {
return;
}
htmlRender(fmt);
}, [tos?.terms_of_service_parsed_text]);
try {
if (contentText) {
matchUrl = new URL(contentText);
@ -22,8 +32,9 @@ const Index: FC = () => {
window.location.replace(matchUrl.toString());
return null;
}
return (
<>
<div>
<h3 className="mb-4">{t('tos')}</h3>
<div
className="fmt"
@ -31,7 +42,7 @@ const Index: FC = () => {
__html: tos?.terms_of_service_parsed_text || '',
}}
/>
</>
</div>
);
};

View File

@ -16,9 +16,10 @@ import {
questionDetail,
modifyQuestion,
useQueryRevisions,
postAnswer,
// postAnswer,
useQueryQuestionByTitle,
getTagsBySlugName,
saveQuestionWidthAnaser,
} from '@/services';
import { handleFormError, SaveDraft, storageExpires } from '@/utils';
import { pathFactory } from '@/router/pathFactory';
@ -29,7 +30,7 @@ interface FormDataItem {
title: Type.FormValue<string>;
tags: Type.FormValue<Type.Tag[]>;
content: Type.FormValue<string>;
answer: Type.FormValue<string>;
answer_content: Type.FormValue<string>;
edit_summary: Type.FormValue<string>;
}
@ -52,7 +53,7 @@ const Ask = () => {
isInvalid: false,
errorMsg: '',
},
answer: {
answer_content: {
value: '',
isInvalid: false,
errorMsg: '',
@ -92,7 +93,7 @@ const Ask = () => {
return;
}
getTagsBySlugName(queryTags).then((tags) => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
// eslint-disable-next-line
handleTagsChange(tags);
});
};
@ -116,8 +117,8 @@ const Ask = () => {
formData.title.value = draft.title;
formData.content.value = draft.content;
formData.tags.value = draft.tags;
formData.answer.value = draft.answer;
setCheckState(Boolean(draft.answer));
formData.answer_content.value = draft.answer_content;
setCheckState(Boolean(draft.answer_content));
setHasDraft(true);
setFormData({ ...formData });
} else {
@ -131,7 +132,7 @@ const Ask = () => {
}, [qid]);
useEffect(() => {
const { title, tags, content, answer } = formData;
const { title, tags, content, answer_content } = formData;
const { title: editTitle, tags: editTags, content: editContent } = immData;
// edited
@ -151,14 +152,19 @@ const Ask = () => {
return;
}
// write
if (title.value || tags.value.length > 0 || content.value || answer.value) {
if (
title.value ||
tags.value.length > 0 ||
content.value ||
answer_content.value
) {
// save draft
saveDraft.save({
params: {
title: title.value,
tags: tags.value,
content: content.value,
answer: answer.value,
answer_content: answer_content.value,
},
callback: () => setHasDraft(true),
});
@ -215,7 +221,7 @@ const Ask = () => {
const handleAnswerChange = (value: string) =>
setFormData({
...formData,
answer: { ...formData.answer, value, errorMsg: '' },
answer_content: { ...formData.answer_content, value, errorMsg: '' },
});
const handleSummaryChange = (evt: React.ChangeEvent<HTMLInputElement>) =>
@ -263,31 +269,30 @@ const Ask = () => {
}
});
} else {
const res = await saveQuestion(params).catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
let res;
if (checked) {
res = await saveQuestionWidthAnaser({
...params,
answer_content: formData.answer_content.value,
}).catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
} else {
res = await saveQuestion(params).catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
}
const id = res?.id;
const id = res?.id || res?.question?.id;
if (id) {
if (checked) {
postAnswer({
question_id: id,
content: formData.answer.value,
})
.then(() => {
navigate(pathFactory.questionLanding(id, params.url_title));
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData, [
{ from: 'content', to: 'answer' },
]);
setFormData({ ...data });
}
});
navigate(pathFactory.questionLanding(id, res?.question?.url_title));
} else {
navigate(pathFactory.questionLanding(id));
}
@ -448,7 +453,7 @@ const Ask = () => {
<Form.Group controlId="answer" className="mt-4">
<Form.Label>{t('form.fields.answer.label')}</Form.Label>
<Editor
value={formData.answer.value}
value={formData.answer_content.value}
onChange={handleAnswerChange}
ref={editorRef2}
className={classNames(
@ -464,11 +469,11 @@ const Ask = () => {
/>
<Form.Control
type="text"
isInvalid={formData.answer.isInvalid}
isInvalid={formData.answer_content.isInvalid}
hidden
/>
<Form.Control.Feedback type="invalid">
{formData.answer.errorMsg}
{formData.answer_content.errorMsg}
</Form.Control.Feedback>
</Form.Group>
)}

View File

@ -1,5 +1,5 @@
import { memo, FC, useEffect, useRef } from 'react';
import { Button, Alert } from 'react-bootstrap';
import { Button, Alert, Badge } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
@ -20,8 +20,7 @@ interface Props {
data: AnswerItem;
/** router answer id */
aid?: string;
/** is author */
isAuthor: boolean;
canAccept: boolean;
questionTitle: string;
slugTitle: string;
isLogged: boolean;
@ -30,11 +29,11 @@ interface Props {
const Index: FC<Props> = ({
aid,
data,
isAuthor,
isLogged,
questionTitle = '',
slugTitle,
callback,
canAccept = false,
}) => {
const { t } = useTranslation('translation', {
keyPrefix: 'question_detail',
@ -77,12 +76,21 @@ const Index: FC<Props> = ({
{t('post_deleted', { keyPrefix: 'messages' })}
</Alert>
)}
{data?.accepted === 2 && (
<div className="mb-3 lh-1">
<Badge bg="success" pill>
<Icon name="check-circle-fill me-1" />
Best answer
</Badge>
</div>
)}
<article
dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<div className="d-flex align-items-center mt-4">
<Actions
source="answer"
data={{
id: data?.id,
isHate: data?.vote_status === 'vote_down',
@ -95,24 +103,17 @@ const Index: FC<Props> = ({
}}
/>
{data?.accepted === 2 && (
{canAccept && (
<Button
disabled={!isAuthor}
variant="outline-success"
className="ms-3 active opacity-100 bg-success text-white"
onClick={acceptAnswer}>
<Icon name="check-circle-fill" className="me-2" />
<span>{t('answers.btn_accepted')}</span>
</Button>
)}
{isAuthor && data.accepted === 1 && (
<Button
variant="outline-success"
variant={data.accepted === 2 ? 'success' : 'outline-success'}
className="ms-3"
onClick={acceptAnswer}>
<Icon name="check-circle-fill" className="me-2" />
<span>{t('answers.btn_accept')}</span>
<span>
{data.accepted === 2
? t('answers.btn_accepted')
: t('answers.btn_accept')}
</span>
</Button>
)}
</div>

View File

@ -108,12 +108,13 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
</div>
<article
ref={ref}
dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap mt-4"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<Actions
className="mt-4"
source="question"
data={{
id: data?.id,
isHate: data?.vote_status === 'vote_down',

View File

@ -1,6 +1,6 @@
import { memo, useState, FC, useEffect } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Form, Button, Alert } from 'react-bootstrap';
import { useTranslation, Trans } from 'react-i18next';
import { marked } from 'marked';
import classNames from 'classnames';
@ -18,6 +18,7 @@ interface Props {
/** question id */
qid: string;
answered?: boolean;
loggedUserRank: number;
};
callback?: (obj) => void;
}
@ -39,6 +40,7 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false);
const [hasDraft, setHasDraft] = useState(false);
const [showTips, setShowTips] = useState(data.loggedUserRank < 100);
usePromptWithUnload({
when: Boolean(formData.content.value),
@ -212,29 +214,58 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
</div>
)}
{showEditor && (
<Editor
className={classNames(
'form-control p-0',
focusType === 'answer' && 'focus',
)}
value={formData.content.value}
autoFocus={editorFocusState}
onChange={(val) => {
setFormData({
content: {
value: val,
isInvalid: false,
errorMsg: '',
},
});
}}
onFocus={() => {
setFocusType('answer');
}}
onBlur={() => {
setFocusType('');
}}
/>
<>
<Editor
className={classNames(
'form-control p-0',
focusType === 'answer' && 'focus',
)}
value={formData.content.value}
autoFocus={editorFocusState}
onChange={(val) => {
setFormData({
content: {
value: val,
isInvalid: false,
errorMsg: '',
},
});
}}
onFocus={() => {
setFocusType('answer');
}}
onBlur={() => {
setFocusType('');
}}
/>
<Alert
variant="warning"
show={data.loggedUserRank < 100 && showTips}
onClose={() => setShowTips(false)}
dismissible
className="mt-3">
<p>{t('tips.header_1')}</p>
<ul>
<li>
<Trans
i18nKey="question_detail.write_answer.tips.li1_1"
components={{ strong: <strong /> }}
/>
</li>
<li>{t('tips.li1_2')}</li>
</ul>
<p>
<Trans
i18nKey="question_detail.write_answer.tips.header_2"
components={{ strong: <strong /> }}
/>
</p>
<ul className="mb-0">
<li>{t('tips.li2_1')}</li>
</ul>
</Alert>
</>
)}
<Form.Control.Feedback type="invalid">

View File

@ -56,7 +56,9 @@ const Index = () => {
const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username;
const isAdmin = userInfo?.role_id === 2;
const isModerator = userInfo?.role_id === 3;
const isLogged = Boolean(userInfo?.access_token);
const loggedUserRank = userInfo?.rank;
const { state: locationState } = useLocation();
useEffect(() => {
@ -221,7 +223,7 @@ const Index = () => {
data={item}
questionTitle={question?.title || ''}
slugTitle={question?.url_title}
isAuthor={isAuthor}
canAccept={isAuthor || isAdmin || isModerator}
callback={initPage}
isLogged={isLogged}
/>
@ -247,6 +249,7 @@ const Index = () => {
data={{
qid,
answered: question?.answered,
loggedUserRank,
}}
callback={writeAnswerCallback}
/>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { Container, Row, Col, Form, Button, Card } from 'react-bootstrap';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { handleFormError } from '@/utils';
import { handleFormError, scrollToDocTop } from '@/utils';
import { usePageTags, usePromptWithUnload } from '@/hooks';
import { pathFactory } from '@/router/pathFactory';
import { Editor, EditorRef, Icon } from '@/components';
import { Editor, EditorRef, Icon, htmlRender } from '@/components';
import type * as Type from '@/common/interface';
import {
useQueryAnswerInfo,
@ -23,31 +23,47 @@ interface FormDataItem {
content: Type.FormValue<string>;
description: Type.FormValue<string>;
}
const initFormData = {
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
};
const Index = () => {
const { aid = '', qid = '' } = useParams();
const [focusType, setForceType] = useState('');
useLayoutEffect(() => {
scrollToDocTop();
}, []);
const { t } = useTranslation('translation', { keyPrefix: 'edit_answer' });
const navigate = useNavigate();
const initFormData = {
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
description: {
value: '',
isInvalid: false,
errorMsg: '',
},
};
const { data } = useQueryAnswerInfo(aid);
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const [immData, setImmData] = useState(initFormData);
const [contentChanged, setContentChanged] = useState(false);
initFormData.content.value = data?.info.content || '';
useLayoutEffect(() => {
if (data?.info?.content) {
setFormData({
...formData,
content: {
value: data.info.content,
isInvalid: false,
errorMsg: '',
},
});
}
}, [data?.info?.content]);
const { data: revisions = [] } = useQueryRevisions(aid);
@ -57,6 +73,13 @@ const Index = () => {
const questionContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!questionContentRef?.current) {
return;
}
htmlRender(questionContentRef.current);
}, [questionContentRef]);
usePromptWithUnload({
when: contentChanged,
});
@ -147,9 +170,11 @@ const Index = () => {
const handleSelectedRevision = (e) => {
const index = e.target.value;
const revision = revisions[index];
formData.content.value = revision.content.content;
setImmData({ ...formData });
setFormData({ ...formData });
if (revision?.content) {
formData.content.value = revision.content.content;
setImmData({ ...formData });
setFormData({ ...formData });
}
};
const backPage = () => {
@ -192,7 +217,7 @@ const Index = () => {
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="revision" className="mb-3">
<Form.Label>{t('form.fields.revision.label')}</Form.Label>
<Form.Select onChange={handleSelectedRevision}>
<Form.Select onChange={handleSelectedRevision} defaultValue={0}>
{revisions.map(({ create_at, reason, user_info }, index) => {
const date = dayjs(create_at * 1000)
.tz()

View File

@ -14,7 +14,7 @@ const Questions: FC = () => {
const { user: loggedUser } = loggedUserInfoStore((_) => _);
const [urlSearchParams] = useSearchParams();
const curPage = Number(urlSearchParams.get('page')) || 1;
const curOrder = urlSearchParams.get('order') || 'newest';
const curOrder = urlSearchParams.get('order') || 'active';
const reqParams: Type.QueryQuestionsReq = {
page_size: 20,
page: curPage,

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { QueryGroup } from '@/components';
const sortBtns = ['relevance', 'newest', 'active', 'score'];
const sortBtns = ['active', 'newest', 'relevance', 'score'];
interface Props {
count: number;

View File

@ -21,7 +21,7 @@ const Index = () => {
const [searchParams] = useSearchParams();
const page = searchParams.get('page') || 1;
const q = searchParams.get('q') || '';
const order = searchParams.get('order') || 'relevance';
const order = searchParams.get('order') || 'active';
const { data, isLoading } = useSearch({
q,

View File

@ -28,7 +28,7 @@ const Questions: FC = () => {
const routeParams = useParams();
const curTagName = routeParams.tagName || '';
const [urlSearchParams] = useSearchParams();
const curOrder = urlSearchParams.get('order') || 'newest';
const curOrder = urlSearchParams.get('order') || 'active';
const curPage = Number(urlSearchParams.get('page')) || 1;
const reqParams: Type.QueryQuestionsReq = {
page_size: 20,

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { usePageTags } from '@/hooks';
import { Tag, TagSelector, FormatTime, Modal } from '@/components';
import { Tag, TagSelector, FormatTime, Modal, htmlRender } from '@/components';
import {
useTagInfo,
useQuerySynonymsTags,
@ -44,6 +44,15 @@ const TagIntroduction = () => {
});
}
}, [locationState]);
useEffect(() => {
const fmt = document.querySelector('.content.fmt') as HTMLElement;
if (!fmt) {
return;
}
htmlRender(fmt);
}, [tagInfo?.parsed_text]);
if (!tagInfo) {
return null;
}
@ -108,7 +117,9 @@ const TagIntroduction = () => {
confirmText: t('delete', { keyPrefix: 'btns' }),
confirmBtnVariant: 'danger',
onConfirm: () => {
deleteTag(tagInfo.tag_id);
deleteTag(tagInfo.tag_id).then(() => {
navigate('/tags', { replace: true });
});
},
});
};
@ -143,7 +154,7 @@ const TagIntroduction = () => {
</div>
<div
className="content text-break"
className="content text-break fmt"
dangerouslySetInnerHTML={{ __html: tagInfo?.parsed_text }}
/>
<div className="mt-4">
@ -204,7 +215,8 @@ const TagIntroduction = () => {
data={{
slug_name: tagName || '',
main_tag_slug_name: '',
display_name: '',
display_name:
tagInfo?.display_name || tagInfo?.slug_name || '',
recommend: false,
reserved: false,
}}

View File

@ -27,7 +27,7 @@ const Tags = () => {
const { role_id } = loggedUserInfoStore((_) => _.user);
const page = Number(urlSearch.get('page')) || 1;
const sort = urlSearch.get('sort');
const sort = urlSearch.get('sort') || sortBtns[0];
const pageSize = 20;
const {

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { loggedUserInfoStore, siteInfoStore } from '@/stores';
import { changeEmailVerify, getLoggedUserInfo } from '@/services';
import { changeEmailVerify } from '@/services';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'account_result' });
@ -20,12 +20,12 @@ const Index: FC = () => {
if (code) {
// do
changeEmailVerify({ code })
.then(() => {
.then((res) => {
setStep('success');
getLoggedUserInfo().then((res) => {
if (res?.access_token) {
// update user info
updateUser(res);
});
}
})
.catch(() => {
setStep('invalid');

View File

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

View File

@ -1,41 +1,53 @@
import { FC, ReactNode, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useLoaderData } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
import { TGuardFunc } from '@/utils/guard';
const Index: FC<{
import RouteErrorBoundary from './RouteErrorBoundary';
const RouteGuard: FC<{
children: ReactNode;
onEnter?: TGuardFunc;
onEnter: TGuardFunc;
path?: string;
}> = ({
children,
onEnter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
path,
}) => {
page?: string;
}> = ({ children, onEnter, path, page }) => {
const navigate = useNavigate();
const location = useLocation();
const callGuards = () => {
if (onEnter) {
const gr = onEnter();
const redirectUrl = gr.redirect;
if (redirectUrl) {
floppyNavigation.navigate(redirectUrl, () => {
navigate(redirectUrl, { replace: true });
});
}
const loaderData = useLoaderData();
const gr = onEnter({
loaderData,
path,
page,
});
let guardError;
const errCode = gr.error?.code;
if (errCode === '403' || errCode === '404' || errCode === '50X') {
guardError = {
code: errCode,
msg: gr.error?.msg,
};
}
const handleGuardRedirect = () => {
const redirectUrl = gr.redirect;
if (redirectUrl) {
floppyNavigation.navigate(redirectUrl, () => {
navigate(redirectUrl, { replace: true });
});
}
};
useEffect(() => {
callGuards();
handleGuardRedirect();
}, [location]);
return (
<>
{/* Route Guard */}
{children}
{gr.ok ? children : null}
{!gr.ok && guardError ? (
<RouteErrorBoundary errCode={guardError.code} />
) : null}
</>
);
};
export default Index;
export default RouteGuard;

View File

@ -13,7 +13,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
routeNodes.forEach((rn) => {
if (rn.page === 'pages/Layout') {
rn.element = rn.guard ? (
<RouteGuard onEnter={rn.guard} path={rn.path}>
<RouteGuard onEnter={rn.guard} path={rn.path} page={rn.page}>
<Layout />
</RouteGuard>
) : (
@ -30,7 +30,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
rn.element = (
<Suspense>
{rn.guard ? (
<RouteGuard onEnter={rn.guard} path={rn.path}>
<RouteGuard onEnter={rn.guard} path={rn.path} page={rn.page}>
<Ctrl />
</RouteGuard>
) : (

View File

@ -19,6 +19,9 @@ const tagEdit = (tagId: string) => {
};
const questionLanding = (questionId: string, slugTitle: string = '') => {
const { seo } = seoSettingStore.getState();
if (!questionId) {
return slugTitle ? `/questions/null/${slugTitle}` : '/questions/null';
}
// @ts-ignore
if (/[13]/.test(seo.permalink) && slugTitle) {
return urlcat('/questions/:questionId/:slugPermalink', {

View File

@ -2,6 +2,8 @@ import type { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom';
import { guard } from '@/utils';
import type { TGuardFunc } from '@/utils/guard';
import { editCheck } from '@/services';
import { isEditable } from '@/utils/guard';
type IndexRouteNode = Omit<IndexRouteObject, 'children'>;
type NonIndexRouteNode = Omit<NonIndexRouteObject, 'children'>;
@ -70,6 +72,13 @@ const routes: RouteNode[] = [
{
path: 'posts/:qid/:aid/edit',
page: 'pages/Questions/EditAnswer',
loader: async ({ params }) => {
const ret = await editCheck(params.aid as string, true);
return ret;
},
guard: (args) => {
return isEditable(args);
},
},
{
path: '/search',

View File

@ -34,7 +34,7 @@ export const useQueryNotificationStatus = () => {
return useSWR<Type.NotificationStatus>(
tryLoggedAndActivated().ok ? apiUrl : null,
request.instance.get,
(url) => request.get(url, { ignoreError: '50X' }),
{
refreshInterval: 3000,
},

View File

@ -1,9 +1,11 @@
import request from '@/utils/request';
import * as Type from '@/common/interface';
export const editCheck = (id: string) => {
export const editCheck = (id: string, passingError: boolean = false) => {
const apiUrl = `/answer/api/v1/revisions/edit/check?id=${id}`;
return request.get(apiUrl);
return request.get(apiUrl, {
passingError,
});
};
export const revisionAudit = (id: string, operation: 'approve' | 'reject') => {

View File

@ -274,3 +274,7 @@ export const markdownToHtml = (content: string) => {
const apiUrl = '/answer/api/v1/post/render';
return request.post(apiUrl, { content });
};
export const saveQuestionWidthAnaser = (params: Type.QuestionWithAnswer) => {
return request.post('/answer/api/v1/question/answer', params);
};

View File

@ -1,25 +1,27 @@
import create from 'zustand';
type codeType = '404' | '50X' | '';
type codeType = '403' | '404' | '50X' | '';
interface NotFoundType {
interface ErrorCodeType {
code: codeType;
update: (code: codeType) => void;
msg: string;
update: (code: codeType, msg?: string) => void;
reset: () => void;
}
const notFound = create<NotFoundType>((set) => ({
const Index = create<ErrorCodeType>((set) => ({
code: '',
update: (code: codeType) => {
msg: '',
update: (code: codeType, msg: string = '') => {
set(() => {
return { code };
return { code, msg };
});
},
reset: () => {
set(() => {
return { code: '' };
return { code: '', msg: '' };
});
},
}));
export default notFound;
export default Index;

View File

@ -10,7 +10,7 @@ import pageTagStore from './pageTags';
import customizeStore from './customize';
import themeSettingStore from './themeSetting';
import loginToContinueStore from './loginToContinue';
import errorCode from './errorCode';
import errorCodeStore from './errorCode';
export {
toastStore,
@ -24,5 +24,5 @@ export {
themeSettingStore,
seoSettingStore,
loginToContinueStore,
errorCode,
errorCodeStore,
};

View File

@ -7,7 +7,7 @@ interface IProps {
update: (params: AdminSettingsSeo) => void;
}
const siteInfo = create<IProps>((set) => ({
const Index = create<IProps>((set) => ({
seo: {
robots: '',
permalink: 1,
@ -25,4 +25,4 @@ const siteInfo = create<IProps>((set) => ({
}),
}));
export default siteInfo;
export default Index;

View File

@ -32,7 +32,7 @@ const initUser: UserInfoRes = {
const loggedUserInfoStore = create<UserInfoStore>((set) => ({
user: initUser,
update: (params) => {
if (!params.language) {
if (!params?.language) {
params.language = 'Default';
}
set(() => {

View File

@ -1,6 +1,4 @@
import i18next from 'i18next';
import parse from 'html-react-parser';
import * as DOMPurify from 'dompurify';
const Diff = require('diff');
@ -235,32 +233,6 @@ function diffText(newText: string, oldText?: string): string {
return result.join('');
}
function htmlToReact(html: string) {
const cleanedHtml = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
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 {
thousandthDivision,
formatCount,
@ -276,5 +248,4 @@ export {
labelStyle,
handleFormError,
diffText,
htmlToReact,
};

Some files were not shown because too many files have changed in this diff Show More