Merge branch 'release/1.0.4' into 'main'

Release/1.0.4

See merge request opensource/answer!437
This commit is contained in:
linkinstar 2023-02-06 02:49:53 +00:00
commit f93fb6b709
68 changed files with 2262 additions and 665 deletions

41
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: "Goreleaser"
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-and-push:
runs-on: [self-hosted, linux]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Node Build
run: make install-ui-packages ui
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v3
with:
name: answer
path: ./dist/*

View File

@ -12,7 +12,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux]
steps:
- name: Checkout
uses: actions/checkout@v3

View File

@ -17,7 +17,7 @@ env:
jobs:
build-and-push:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux]
permissions:
packages: write

View File

@ -9,7 +9,7 @@ on:
jobs:
build-and-push:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux]
steps:
- name: Checkout

View File

@ -8,8 +8,7 @@ on:
jobs:
build-and-push:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux]
steps:
- name: Checkout
uses: actions/checkout@v3

5
.gitignore vendored
View File

@ -15,8 +15,11 @@
/configs/config-dev.yaml
/go.work*
/logs
/ui/build
/ui/node_modules
/ui/build/*/*/*
/ui/build/*.json
/ui/build/*.html
/ui/build/*.txt
/vendor
Thumbs*.db
tmp

View File

@ -2,7 +2,7 @@ env:
- GO11MODULE=on
- GO111MODULE=on
- GOPROXY=https://goproxy.io
- CGO_ENABLED=1
- CGO_ENABLED=0
before:
hooks:
@ -16,74 +16,48 @@ builds:
- linux
goarch:
- amd64
# linux windows need cgomingw64-gcc
- 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
env:
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
goos:
- windows
goarch:
- amd64
# linux arm64 need cgo arm64
- id: build-arm64
main: ./cmd/answer/.
binary: answer
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
env:
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
goos:
- linux
goarch:
- arm64
- id: build-arm7
main: ./cmd/answer/.
binary: answer
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
env:
- CC=arm-linux-gnueabihf-gcc
- CXX=arm-linux-gnueabihf-g++
- AR=arm-linux-gnueabihf-ar
goos:
- linux
goarch:
- arm
goarm:
- 7
- arm64
- id: build-darwin-arm64
main: ./cmd/answer/.
binary: answer
env:
- CC=oa64-clang
- CXX=oa64-clang++
goos:
- darwin
goarch:
- arm64
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
flags: -v
- id: build-darwin-amd64
main: ./cmd/answer/.
binary: answer
env:
- CC=o64-clang
- CXX=o64-clang++
goos:
- darwin
goarch:
- amd64
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
flags: -v
archives:
- replacements:
darwin: Darwin
darwin: macOS
amd64: x86_64
linux: Linux
windows: Windows
checksum:
name_template: 'checksums.txt'
snapshot:
@ -95,10 +69,4 @@ changelog:
- '^docs:'
- '^test:'
# sudo apt-get install build-essential
# sudo apt-get install gcc-multilib g++-multilib
# sudo apt-get install gcc-mingw-w64
# sudo apt-get -y install gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
# sudo apt-get install clang llvm
# goreleaser release --snapshot --rm-dist
# goreleaser release --snapshot --rm-dist

View File

@ -1,30 +1,32 @@
.PHONY: build clean ui
VERSION=1.0.3
VERSION=1.0.4
BIN=answer
DIR_SRC=./cmd/answer
DOCKER_CMD=docker
#GO_ENV=CGO_ENABLED=0
GO_ENV=CGO_ENABLED=0 GO111MODULE=on
Revision=$(shell git rev-parse --short HEAD)
GO_FLAGS=-ldflags="-X main.Version=$(VERSION) -X 'main.Revision=$(Revision)' -X 'main.Time=`date`' -extldflags -static"
GO=$(GO_ENV) $(shell which go)
build:
@$(GO_ENV) $(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
build: generate
@$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
# https://dev.to/thewraven/universal-macos-binaries-with-go-1-16-3mm3
universal:
universal: generate
@GOOS=darwin GOARCH=amd64 $(GO_ENV) $(GO) build $(GO_FLAGS) -o ${BIN}_amd64 $(DIR_SRC)
@GOOS=darwin GOARCH=arm64 $(GO_ENV) $(GO) build $(GO_FLAGS) -o ${BIN}_arm64 $(DIR_SRC)
@lipo -create -output ${BIN} ${BIN}_amd64 ${BIN}_arm64
@rm -f ${BIN}_amd64 ${BIN}_arm64
generate:
go get github.com/google/wire/cmd/wire@latest
go install github.com/golang/mock/mockgen@v1.6.0
go generate ./...
go mod tidy
@$(GO) get github.com/google/wire/cmd/wire@v0.5.0
@$(GO) get github.com/golang/mock/mockgen@v1.6.0
@$(GO) install github.com/google/wire/cmd/wire@v0.5.0
@$(GO) install github.com/golang/mock/mockgen@v1.6.0
@$(GO) generate ./...
@$(GO) mod tidy
test:
@$(GO) test ./internal/repo/repo_test
@ -39,6 +41,6 @@ install-ui-packages:
@corepack prepare pnpm@v7.12.2 --activate
ui:
@cd ui && pnpm install && pnpm build && cd -
@cd ui && pnpm install && pnpm build && sed -i 's/%AnswerVersion%/'$(VERSION)'/g' ./build/index.html && cd -
all: clean build

View File

@ -2911,6 +2911,45 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/post/render": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "render post content",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "render post content",
"parameters": [
{
"description": "PostRenderReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.PostRenderReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question": {
"put": {
"security": [
@ -5425,8 +5464,7 @@ const docTemplate = `{
"type": "object",
"required": [
"object_id",
"original_text",
"parsed_text"
"original_text"
],
"properties": {
"mention_username_list": {
@ -5442,11 +5480,9 @@ const docTemplate = `{
},
"original_text": {
"description": "original comment content",
"type": "string"
},
"parsed_text": {
"description": "parsed comment content",
"type": "string"
"type": "string",
"maxLength": 600,
"minLength": 2
},
"reply_comment_id": {
"description": "reply comment id",
@ -5535,46 +5571,41 @@ const docTemplate = `{
},
"schema.AnswerAddReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
},
"html": {
"description": "html",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"question_id": {
"description": "question_id",
"type": "string"
}
}
},
"schema.AnswerUpdateReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"edit_summary": {
"description": "edit_summary",
"type": "string"
},
"html": {
"description": "html",
"type": "string"
},
"id": {
"description": "id",
"type": "string"
},
"question_id": {
"description": "question_id",
"type": "string"
},
"title": {
"description": "title",
"type": "string"
}
}
@ -6589,11 +6620,18 @@ const docTemplate = `{
}
}
},
"schema.PostRenderReq": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"schema.QuestionAdd": {
"type": "object",
"required": [
"content",
"html",
"tags",
"title"
],
@ -6604,12 +6642,6 @@ const docTemplate = `{
"maxLength": 65535,
"minLength": 6
},
"html": {
"description": "html",
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"tags": {
"description": "tags",
"type": "array",
@ -6669,6 +6701,9 @@ const docTemplate = `{
"collection_count": {
"type": "integer"
},
"created_at": {
"type": "integer"
},
"description": {
"type": "string"
},
@ -6739,7 +6774,6 @@ const docTemplate = `{
"type": "object",
"required": [
"content",
"html",
"id",
"tags",
"title"
@ -6755,12 +6789,6 @@ const docTemplate = `{
"description": "edit summary",
"type": "string"
},
"html": {
"description": "html",
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"id": {
"description": "question id",
"type": "string"
@ -7326,10 +7354,6 @@ const docTemplate = `{
"description": "original text",
"type": "string"
},
"parsed_text": {
"description": "parsed text",
"type": "string"
},
"slug_name": {
"description": "slug_name",
"type": "string",
@ -7416,7 +7440,8 @@ const docTemplate = `{
"schema.UpdateCommentReq": {
"type": "object",
"required": [
"comment_id"
"comment_id",
"original_text"
],
"properties": {
"comment_id": {
@ -7425,11 +7450,9 @@ const docTemplate = `{
},
"original_text": {
"description": "original comment content",
"type": "string"
},
"parsed_text": {
"description": "parsed comment content",
"type": "string"
"type": "string",
"maxLength": 600,
"minLength": 2
}
}
},
@ -7460,11 +7483,6 @@ const docTemplate = `{
"type": "string",
"maxLength": 4096
},
"bio_html": {
"description": "bio",
"type": "string",
"maxLength": 4096
},
"display_name": {
"description": "display_name",
"type": "string",
@ -7549,10 +7567,6 @@ const docTemplate = `{
"description": "original text",
"type": "string"
},
"parsed_text": {
"description": "parsed text",
"type": "string"
},
"slug_name": {
"description": "slug_name",
"type": "string",

View File

@ -2899,6 +2899,45 @@
}
}
},
"/answer/api/v1/post/render": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "render post content",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "render post content",
"parameters": [
{
"description": "PostRenderReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.PostRenderReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question": {
"put": {
"security": [
@ -5413,8 +5452,7 @@
"type": "object",
"required": [
"object_id",
"original_text",
"parsed_text"
"original_text"
],
"properties": {
"mention_username_list": {
@ -5430,11 +5468,9 @@
},
"original_text": {
"description": "original comment content",
"type": "string"
},
"parsed_text": {
"description": "parsed comment content",
"type": "string"
"type": "string",
"maxLength": 600,
"minLength": 2
},
"reply_comment_id": {
"description": "reply comment id",
@ -5523,46 +5559,41 @@
},
"schema.AnswerAddReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
},
"html": {
"description": "html",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"question_id": {
"description": "question_id",
"type": "string"
}
}
},
"schema.AnswerUpdateReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"edit_summary": {
"description": "edit_summary",
"type": "string"
},
"html": {
"description": "html",
"type": "string"
},
"id": {
"description": "id",
"type": "string"
},
"question_id": {
"description": "question_id",
"type": "string"
},
"title": {
"description": "title",
"type": "string"
}
}
@ -6577,11 +6608,18 @@
}
}
},
"schema.PostRenderReq": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"schema.QuestionAdd": {
"type": "object",
"required": [
"content",
"html",
"tags",
"title"
],
@ -6592,12 +6630,6 @@
"maxLength": 65535,
"minLength": 6
},
"html": {
"description": "html",
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"tags": {
"description": "tags",
"type": "array",
@ -6657,6 +6689,9 @@
"collection_count": {
"type": "integer"
},
"created_at": {
"type": "integer"
},
"description": {
"type": "string"
},
@ -6727,7 +6762,6 @@
"type": "object",
"required": [
"content",
"html",
"id",
"tags",
"title"
@ -6743,12 +6777,6 @@
"description": "edit summary",
"type": "string"
},
"html": {
"description": "html",
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"id": {
"description": "question id",
"type": "string"
@ -7314,10 +7342,6 @@
"description": "original text",
"type": "string"
},
"parsed_text": {
"description": "parsed text",
"type": "string"
},
"slug_name": {
"description": "slug_name",
"type": "string",
@ -7404,7 +7428,8 @@
"schema.UpdateCommentReq": {
"type": "object",
"required": [
"comment_id"
"comment_id",
"original_text"
],
"properties": {
"comment_id": {
@ -7413,11 +7438,9 @@
},
"original_text": {
"description": "original comment content",
"type": "string"
},
"parsed_text": {
"description": "parsed comment content",
"type": "string"
"type": "string",
"maxLength": 600,
"minLength": 2
}
}
},
@ -7448,11 +7471,6 @@
"type": "string",
"maxLength": 4096
},
"bio_html": {
"description": "bio",
"type": "string",
"maxLength": 4096
},
"display_name": {
"description": "display_name",
"type": "string",
@ -7537,10 +7555,6 @@
"description": "original text",
"type": "string"
},
"parsed_text": {
"description": "parsed text",
"type": "string"
},
"slug_name": {
"description": "slug_name",
"type": "string",

View File

@ -147,9 +147,8 @@ definitions:
type: string
original_text:
description: original comment content
type: string
parsed_text:
description: parsed comment content
maxLength: 600
minLength: 2
type: string
reply_comment_id:
description: reply comment id
@ -157,7 +156,6 @@ definitions:
required:
- object_id
- original_text
- parsed_text
type: object
schema.AddReportReq:
properties:
@ -217,35 +215,30 @@ definitions:
schema.AnswerAddReq:
properties:
content:
description: content
type: string
html:
description: html
maxLength: 65535
minLength: 6
type: string
question_id:
description: question_id
type: string
required:
- content
type: object
schema.AnswerUpdateReq:
properties:
content:
description: content
maxLength: 65535
minLength: 6
type: string
edit_summary:
description: edit_summary
type: string
html:
description: html
type: string
id:
description: id
type: string
question_id:
description: question_id
type: string
title:
description: title
type: string
required:
- content
type: object
schema.AvatarInfo:
properties:
@ -976,6 +969,11 @@ definitions:
type:
type: string
type: object
schema.PostRenderReq:
properties:
content:
type: string
type: object
schema.QuestionAdd:
properties:
content:
@ -983,11 +981,6 @@ definitions:
maxLength: 65535
minLength: 6
type: string
html:
description: html
maxLength: 65535
minLength: 6
type: string
tags:
description: tags
items:
@ -1000,7 +993,6 @@ definitions:
type: string
required:
- content
- html
- tags
- title
type: object
@ -1036,6 +1028,8 @@ definitions:
type: integer
collection_count:
type: integer
created_at:
type: integer
description:
type: string
follow_count:
@ -1090,11 +1084,6 @@ definitions:
edit_summary:
description: edit summary
type: string
html:
description: html
maxLength: 65535
minLength: 6
type: string
id:
description: question id
type: string
@ -1110,7 +1099,6 @@ definitions:
type: string
required:
- content
- html
- id
- tags
- title
@ -1490,9 +1478,6 @@ definitions:
original_text:
description: original text
type: string
parsed_text:
description: parsed text
type: string
slug_name:
description: slug_name
maxLength: 35
@ -1558,12 +1543,12 @@ definitions:
type: string
original_text:
description: original comment content
type: string
parsed_text:
description: parsed comment content
maxLength: 600
minLength: 2
type: string
required:
- comment_id
- original_text
type: object
schema.UpdateFollowTagsReq:
properties:
@ -1582,10 +1567,6 @@ definitions:
description: bio
maxLength: 4096
type: string
bio_html:
description: bio
maxLength: 4096
type: string
display_name:
description: display_name
maxLength: 30
@ -1648,9 +1629,6 @@ definitions:
original_text:
description: original text
type: string
parsed_text:
description: parsed text
type: string
slug_name:
description: slug_name
maxLength: 35
@ -3684,6 +3662,30 @@ paths:
summary: user's votes
tags:
- Activity
/answer/api/v1/post/render:
post:
consumes:
- application/json
description: render post content
parameters:
- description: PostRenderReq
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.PostRenderReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: render post content
tags:
- Upload
/answer/api/v1/question:
delete:
consumes:

18
go.mod
View File

@ -5,6 +5,7 @@ go 1.18
require (
github.com/Chain-Zhang/pinyin v0.1.3
github.com/anargu/gin-brotli v0.0.0-20220116052358-12bf532d5267
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/bwmarrin/snowflake v0.3.0
github.com/davecgh/go-spew v1.1.1
github.com/disintegration/imaging v1.6.2
@ -14,7 +15,7 @@ require (
github.com/go-playground/validator/v10 v10.11.1
github.com/go-sql-driver/mysql v1.6.0
github.com/goccy/go-json v0.9.11
github.com/golang/mock v1.4.4
github.com/golang/mock v1.6.0
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0
github.com/gosimple/slug v1.13.1
@ -22,7 +23,6 @@ require (
github.com/jinzhu/copier v0.3.5
github.com/jinzhu/now v1.1.5
github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.16
github.com/microcosm-cc/bluemonday v1.0.21
github.com/mojocn/base64Captcha v1.3.5
github.com/ory/dockertest/v3 v3.9.1
@ -43,6 +43,7 @@ require (
golang.org/x/net v0.1.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.14.2
xorm.io/builder v0.3.12
xorm.io/core v0.7.3
xorm.io/xorm v1.3.2
@ -79,12 +80,14 @@ require (
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible // indirect
github.com/lestrrat-go/strftime v1.0.6 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@ -97,6 +100,7 @@ require (
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
@ -113,6 +117,7 @@ require (
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/tools v0.2.0 // indirect
@ -120,5 +125,14 @@ require (
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
lukechampine.com/uint128 v1.1.1 // indirect
modernc.org/cc/v3 v3.35.18 // indirect
modernc.org/ccgo/v3 v3.12.82 // indirect
modernc.org/libc v1.11.87 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.0.5 // indirect
modernc.org/opt v0.1.1 // indirect
modernc.org/strutil v1.1.1 // indirect
modernc.org/token v1.0.0 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)

16
go.sum
View File

@ -79,6 +79,8 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
@ -142,6 +144,7 @@ github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh
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/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=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
@ -237,8 +240,9 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -696,6 +700,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@ -793,6 +798,7 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -832,6 +838,7 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -912,9 +919,11 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -1009,6 +1018,7 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
@ -1219,7 +1229,9 @@ modernc.org/ccgo/v3 v3.12.73/go.mod h1:hngkB+nUUqzOf3iqsM48Gf1FZhY599qzVg1iX+BT3
modernc.org/ccgo/v3 v3.12.81/go.mod h1:p2A1duHoBBg1mFtYvnhAnQyI6vL0uw5PGYLSIgF6rYY=
modernc.org/ccgo/v3 v3.12.82 h1:wudcnJyjLj1aQQCXF3IM9Gz2X6UNjw+afIghzdtn0v8=
modernc.org/ccgo/v3 v3.12.82/go.mod h1:ApbflUfa5BKadjHynCficldU1ghjen84tuM5jRynB7w=
modernc.org/ccorpus v1.11.1 h1:K0qPfpVG1MJh5BYazccnmhywH4zHuOgJXgbjzyp6dWA=
modernc.org/ccorpus v1.11.1/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
@ -1271,9 +1283,11 @@ modernc.org/sqlite v1.14.2 h1:ohsW2+e+Qe2To1W6GNezzKGwjXwSax6R+CrhRxVaFbE=
modernc.org/sqlite v1.14.2/go.mod h1:yqfn85u8wVOE6ub5UT8VI9JjhrwBUUCNyTACN0h6Sx8=
modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/tcl v1.8.13 h1:V0sTNBw0Re86PvXZxuCub3oO9WrSTqALgrwNZNvLFGw=
modernc.org/tcl v1.8.13/go.mod h1:V+q/Ef0IJaNUSECieLU4o+8IScapxnMyFV6i/7uQlAY=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.2.19 h1:BGyRFWhDVn5LFS5OcX4Yd/MlpRTOc7hOPTdcIpCiUao=
modernc.org/z v1.2.19/go.mod h1:+ZpP0pc4zz97eukOzW3xagV/lS82IpPN9NGG5pNF9vY=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

View File

@ -52,6 +52,8 @@ backend:
other: "Comment are not allowed to edit."
not_found:
other: "Comment not found."
cannot_edit_after_deadline:
other: "The comment time has been too long to modify."
email:
duplicate:
other: "Email already exists."
@ -508,6 +510,8 @@ ui:
label: Revision
answer:
label: Answer
feedback:
characters: content must be at least 6 characters in length.
edit_summary:
label: Edit Summary
placeholder: >-
@ -638,7 +642,7 @@ ui:
msg:
empty: Email cannot be empty.
change_email:
page_title: Welcome to Answer
page_title: Welcome to {{site_name}}
btn_cancel: Cancel
btn_update: Update email address
send_success: >-
@ -772,6 +776,7 @@ ui:
<p>Are you sure you want to add another answer?</p><p>You could use the
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.
reopen:
title: Reopen this post
content: Are you sure you want to reopen?
@ -838,7 +843,7 @@ ui:
modal_confirm:
title: Error...
account_result:
page_title: Welcome to Answer
page_title: Welcome to {{site_name}}
success: Your new account is confirmed; you will be redirected to the home page.
link: Continue to homepage
invalid: >-
@ -993,8 +998,12 @@ ui:
database tables first.
db_failed: Database connection failed
db_failed_desc: >-
This either means that the database information in your <1>config.yaml</1> file is incorrect or that contact with the database server could not be established. This could mean your host's database server is down.
This either means that the database information in your <1>config.yaml</1> file is incorrect or that contact with the database server could not be established. This could mean your hosts database server is down.
counts:
views: views
votes: votes
answers: answers
accepted: Accepted
page_404:
desc: "Unfortunately, this page doesn't exist."
back_home: Back to homepage

View File

@ -0,0 +1,7 @@
package constant
import "time"
const (
CommentEditDeadline = time.Minute * 5
)

View File

@ -7,10 +7,10 @@ import (
"github.com/answerdev/answer/pkg/dir"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/segmentfault/pacman/cache"
"github.com/segmentfault/pacman/contrib/cache/memory"
"github.com/segmentfault/pacman/log"
_ "modernc.org/sqlite"
"xorm.io/core"
"xorm.io/xorm"
ormlog "xorm.io/xorm/log"
@ -38,11 +38,13 @@ func NewDB(debug bool, dataConf *Database) (*xorm.Engine, error) {
dataConf.Driver = string(schemas.MYSQL)
}
if dataConf.Driver == string(schemas.SQLITE) {
dataConf.Driver = "sqlite"
dbFileDir := filepath.Dir(dataConf.Connection)
log.Debugf("try to create database directory %s", dbFileDir)
if err := dir.CreateDirIfNotExist(dbFileDir); err != nil {
log.Errorf("create database dir failed: %s", err)
}
dataConf.MaxOpenConn = 1
}
engine, err := xorm.NewEngine(dataConf.Driver, dataConf.Connection)
if err != nil {

View File

@ -16,6 +16,7 @@ const (
const (
EmailOrPasswordWrong = "error.object.email_or_password_incorrect"
CommentNotFound = "error.comment.not_found"
CommentCannotEditAfterDeadline = "error.comment.cannot_edit_after_deadline"
QuestionNotFound = "error.question.not_found"
QuestionCannotDeleted = "error.question.cannot_deleted"
QuestionCannotClose = "error.question.cannot_close"

View File

@ -97,8 +97,29 @@ func getTran(lo locales.Translator) ut.Translator {
return tran
}
func NotBlank(fl validator.FieldLevel) (res bool) {
field := fl.Field()
switch field.Kind() {
case reflect.String:
trimSpace := strings.TrimSpace(field.String())
res := len(trimSpace) > 0
if !res {
field.SetString(trimSpace)
}
return true
case reflect.Chan, reflect.Map, reflect.Slice, reflect.Array:
return field.Len() > 0
case reflect.Ptr, reflect.Interface, reflect.Func:
return !field.IsNil()
default:
return field.IsValid() && field.Interface() != reflect.Zero(field.Type()).Interface()
}
}
func createDefaultValidator(la i18n.Language) *validator.Validate {
validate := validator.New()
// _ = validate.RegisterValidation("notblank", validators.NotBlank)
_ = validate.RegisterValidation("notblank", NotBlank)
validate.RegisterTagNameFunc(func(fld reflect.StructField) (res string) {
defer func() {
if len(res) > 0 {

View File

@ -111,6 +111,7 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetIsAdminFromContext(ctx)
can, err := cc.rankService.CheckOperationPermission(ctx, req.UserID, permission.CommentEdit, req.CommentID)
if err != nil {
handler.HandleResponse(ctx, err, nil)

View File

@ -139,6 +139,9 @@ func (tc *TemplateController) QuestionList(ctx *gin.Context) {
}
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/questions", siteInfo.General.SiteUrl)
if page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/questions?page=%d", siteInfo.General.SiteUrl, page)
}
UrlUseTitle := false
if siteInfo.SiteSeo.PermaLink == schema.PermaLinkQuestionIDAndTitle {
@ -327,6 +330,9 @@ func (tc *TemplateController) TagList(ctx *gin.Context) {
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/tags", siteInfo.General.SiteUrl)
if req.Page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/tags?page=%d", siteInfo.General.SiteUrl, req.Page)
}
siteInfo.Title = fmt.Sprintf("%s - %s", "Tags", siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "tags.html", siteInfo, gin.H{
"page": page,
@ -353,6 +359,9 @@ func (tc *TemplateController) TagInfo(ctx *gin.Context) {
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/tags/%s", siteInfo.General.SiteUrl, tag)
if req.Page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/tags/%s?page=%d", siteInfo.General.SiteUrl, tag, req.Page)
}
siteInfo.Description = htmltext.FetchExcerpt(taginifo.ParsedText, "...", 240)
if len(taginifo.ParsedText) == 0 {
siteInfo.Description = "The tag has no description."
@ -437,6 +446,7 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI
data["HeadCode"] = siteInfo.CustomCssHtml.CustomHead
data["HeaderCode"] = siteInfo.CustomCssHtml.CustomHeader
data["FooterCode"] = siteInfo.CustomCssHtml.CustomFooter
data["Version"] = constant.Version
_, ok := data["path"]
if !ok {
data["path"] = ""

View File

@ -157,8 +157,8 @@ func (uc *UserController) RetrievePassWord(ctx *gin.Context) {
return
}
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP())
code, err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, code)
_, err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// UseRePassWord godoc

View File

@ -5,6 +5,10 @@ import (
"net/url"
"strings"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/pkg/checker"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm/schemas"
)
@ -82,6 +86,18 @@ type InitBaseInfoReq struct {
AdminEmail string `validate:"required,email,gt=0,lte=500" json:"email"`
}
func (r *InitBaseInfoReq) Check() (errFields []*validator.FormErrorField, err error) {
if checker.IsInvalidUsername(r.AdminName) {
errField := &validator.FormErrorField{
ErrorField: "name",
ErrorMsg: reason.UsernameInvalid,
}
errFields = append(errFields, errField)
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
return
}
func (r *InitBaseInfoReq) FormatSiteUrl() {
parsedUrl, err := url.Parse(r.SiteURL)
if err != nil {

View File

@ -11,6 +11,7 @@ import (
collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/unique"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm"
)
// collectionRepo collection repository
@ -29,15 +30,28 @@ func NewCollectionRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) collec
// AddCollection add collection
func (cr *collectionRepo) AddCollection(ctx context.Context, collection *entity.Collection) (err error) {
id, err := cr.uniqueIDRepo.GenUniqueIDStr(ctx, collection.TableName())
if err == nil {
collection.ID = id
_, err = cr.data.DB.Insert(collection)
_, err = cr.data.DB.Transaction(func(session *xorm.Session) (result any, err error) {
var has bool
dbcollection := &entity.Collection{}
result = nil
has, err = session.Where("user_id = ? and object_id = ?", collection.UserID, collection.ObjectID).Get(dbcollection)
if err != nil {
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
return
}
}
return nil
if has {
return
}
id, err := cr.uniqueIDRepo.GenUniqueIDStr(ctx, collection.TableName())
if err == nil {
collection.ID = id
_, err = session.Insert(collection)
if err != nil {
return result, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
}
return
})
return err
}
// RemoveCollection delete collection

View File

@ -33,7 +33,7 @@ var (
"`user_id`",
"`vote_count`",
"`answer_count`",
"0 as `accepted`",
"CASE WHEN `accepted_answer_id` > 0 THEN 2 ELSE 0 END as `accepted`",
"`question`.`status` as `status`",
"`post_update_time`",
}

View File

@ -20,10 +20,10 @@ const (
)
type AnswerAddReq struct {
QuestionID string `json:"question_id" ` // question_id
Content string `json:"content" ` // content
HTML string `json:"html" ` // html
UserID string `json:"-" ` // user_id
QuestionID string `json:"question_id"`
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
HTML string `json:"-"`
UserID string `json:"-"`
}
func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err error) {
@ -32,13 +32,13 @@ func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err err
}
type AnswerUpdateReq struct {
ID string `json:"id"` // id
QuestionID string `json:"question_id" ` // question_id
UserID string `json:"-" ` // user_id
Title string `json:"title" ` // title
Content string `json:"content"` // content
HTML string `json:"html" ` // html
EditSummary string `validate:"omitempty" json:"edit_summary"` // edit_summary
ID string `json:"id"`
QuestionID string `json:"question_id"`
Title string `json:"title"`
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
EditSummary string `validate:"omitempty" json:"edit_summary"`
HTML string `json:"-"`
UserID string `json:"-"`
NoNeedReview bool `json:"-"`
// whether user can edit it
CanEdit bool `json:"-"`

View File

@ -14,9 +14,9 @@ type AddCommentReq struct {
// reply comment id
ReplyCommentID string `validate:"omitempty" json:"reply_comment_id"`
// original comment content
OriginalText string `validate:"required" json:"original_text"`
OriginalText string `validate:"required,notblank,gte=2,lte=600" json:"original_text"`
// parsed comment content
ParsedText string `validate:"required" json:"parsed_text"`
ParsedText string `json:"-"`
// @ user id list
MentionUsernameList []string `validate:"omitempty" json:"mention_username_list"`
// user id
@ -47,11 +47,12 @@ type UpdateCommentReq struct {
// comment id
CommentID string `validate:"required" json:"comment_id"`
// original comment content
OriginalText string `validate:"omitempty" json:"original_text"`
OriginalText string `validate:"required,notblank,gte=2,lte=600" json:"original_text"`
// parsed comment content
ParsedText string `validate:"omitempty" json:"parsed_text"`
ParsedText string `json:"-"`
// user id
UserID string `json:"-"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
}
func (req *UpdateCommentReq) Check() (errFields []*validator.FormErrorField, err error) {

View File

@ -41,11 +41,11 @@ type ReopenQuestionReq struct {
type QuestionAdd struct {
// question title
Title string `validate:"required,gte=6,lte=150" json:"title"`
Title string `validate:"required,notblank,gte=6,lte=150" json:"title"`
// content
Content string `validate:"required,gte=6,lte=65535" json:"content"`
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
// html
HTML string `validate:"required,gte=6,lte=65535" json:"html"`
HTML string `json:"-"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
// user id
@ -90,11 +90,11 @@ type QuestionUpdate struct {
// question id
ID string `validate:"required" json:"id"`
// question title
Title string `validate:"required,gte=6,lte=150" json:"title"`
Title string `validate:"required,notblank,gte=6,lte=150" json:"title"`
// content
Content string `validate:"required,gte=6,lte=65535" json:"content"`
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
// html
HTML string `validate:"required,gte=6,lte=65535" json:"html"`
HTML string `json:"-"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
// edit summary
@ -219,7 +219,7 @@ type UserQuestionInfo struct {
ViewCount int `json:"view_count"`
AnswerCount int `json:"answer_count"`
CollectionCount int `json:"collection_count"`
CreateTime int `json:"create_time"`
CreatedAt int64 `json:"created_at"`
AcceptedAnswerID string `json:"accepted_answer_id"`
Status string `json:"status"`
}
@ -253,6 +253,7 @@ const (
type QuestionPageResp struct {
ID string `json:"id" `
CreatedAt int64 `json:"created_at"`
Title string `json:"title"`
UrlTitle string `json:"url_title"`
Description string `json:"description"`

View File

@ -144,7 +144,7 @@ type TagItem struct {
// original text
OriginalText string `validate:"omitempty" json:"original_text"`
// parsed text
ParsedText string `validate:"omitempty" json:"parsed_text"`
ParsedText string `json:"-"`
}
// RemoveTagReq delete tag request
@ -166,7 +166,7 @@ type UpdateTagReq struct {
// original text
OriginalText string `validate:"omitempty" json:"original_text"`
// parsed text
ParsedText string `validate:"omitempty" json:"parsed_text"`
ParsedText string `json:"-"`
// edit summary
EditSummary string `validate:"omitempty" json:"edit_summary"`
// user id

View File

@ -2,12 +2,12 @@ package schema
import (
"encoding/json"
"regexp"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/pkg/checker"
"github.com/answerdev/answer/pkg/converter"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
)
@ -283,7 +283,7 @@ type UpdateInfoRequest struct {
// bio
Bio string `validate:"omitempty,gt=0,lte=4096" json:"bio"`
// bio
BioHTML string `validate:"omitempty,gt=0,lte=4096" json:"bio_html"`
BioHTML string `json:"-"`
// website
Website string `validate:"omitempty,gt=0,lte=500" json:"website"`
// location
@ -298,12 +298,9 @@ type AvatarInfo struct {
Custom string `validate:"omitempty,gt=0,lte=200" json:"custom"`
}
func (u *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
if len(u.Username) > 0 {
errFields := make([]*validator.FormErrorField, 0)
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(u.Username)
if !match {
func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
if len(req.Username) > 0 {
if checker.IsInvalidUsername(req.Username) {
errField := &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameInvalid,
@ -312,6 +309,7 @@ func (u *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
}
req.BioHTML = converter.Markdown2HTML(req.Bio)
return nil, nil
}

View File

@ -121,33 +121,14 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
return nil, err
}
if objInfo.ObjectType == constant.QuestionObjectType {
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID,
objInfo.QuestionID, objInfo.Title, comment.ID, req.UserID, comment.OriginalText)
} else if objInfo.ObjectType == constant.AnswerObjectType {
cs.notificationAnswerComment(ctx, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID,
objInfo.ObjectCreatorUserID, comment.ID, req.UserID, comment.OriginalText)
}
if len(req.MentionUsernameList) > 0 {
cs.notificationMention(ctx, req.MentionUsernameList, comment.ID, req.UserID)
}
resp = &schema.GetCommentResp{}
resp.SetFromComment(comment)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID, req.CanEdit, req.CanDelete)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
time.Now(), req.CanEdit, req.CanDelete)
// get reply user info
if len(resp.ReplyUserID) > 0 {
replyUser, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, resp.ReplyUserID)
if err != nil {
return nil, err
}
if exist {
resp.ReplyUsername = replyUser.Username
resp.ReplyUserDisplayName = replyUser.DisplayName
resp.ReplyUserStatus = replyUser.Status
}
cs.notificationCommentReply(ctx, replyUser.ID, objInfo.QuestionID, req.UserID)
commentResp, err := cs.addCommentNotification(ctx, req, resp, comment, objInfo)
if err != nil {
return commentResp, err
}
// get user info
@ -178,6 +159,50 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
return resp, nil
}
func (cs *CommentService) addCommentNotification(
ctx context.Context, req *schema.AddCommentReq, resp *schema.GetCommentResp,
comment *entity.Comment, objInfo *schema.SimpleObjectInfo) (*schema.GetCommentResp, error) {
// The priority of the notification
// 1. reply to user
// 2. comment mention to user
// 3. answer or question was commented
alreadyNotifiedUserID := make(map[string]bool)
// get reply user info
if len(resp.ReplyUserID) > 0 && resp.ReplyUserID != req.UserID {
replyUser, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, resp.ReplyUserID)
if err != nil {
return nil, err
}
if exist {
resp.ReplyUsername = replyUser.Username
resp.ReplyUserDisplayName = replyUser.DisplayName
resp.ReplyUserStatus = replyUser.Status
}
cs.notificationCommentReply(ctx, replyUser.ID, comment.ID, req.UserID)
alreadyNotifiedUserID[replyUser.ID] = true
return nil, nil
}
if len(req.MentionUsernameList) > 0 {
alreadyNotifiedUserIDs := cs.notificationMention(
ctx, req.MentionUsernameList, comment.ID, req.UserID, alreadyNotifiedUserID)
for _, userID := range alreadyNotifiedUserIDs {
alreadyNotifiedUserID[userID] = true
}
return nil, nil
}
if objInfo.ObjectType == constant.QuestionObjectType && !alreadyNotifiedUserID[objInfo.ObjectCreatorUserID] {
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID,
objInfo.QuestionID, objInfo.Title, comment.ID, req.UserID, comment.OriginalText)
} else if objInfo.ObjectType == constant.AnswerObjectType && !alreadyNotifiedUserID[objInfo.ObjectCreatorUserID] {
cs.notificationAnswerComment(ctx, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID,
objInfo.ObjectCreatorUserID, comment.ID, req.UserID, comment.OriginalText)
}
return nil, nil
}
// RemoveComment delete comment
func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveCommentReq) (err error) {
return cs.commentRepo.RemoveComment(ctx, req.CommentID)
@ -185,6 +210,19 @@ func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveC
// UpdateComment update comment
func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateCommentReq) (err error) {
old, exist, err := cs.commentCommonRepo.GetComment(ctx, req.CommentID)
if err != nil {
return
}
if !exist {
return errors.BadRequest(reason.CommentNotFound)
}
// user can edit the comment that was posted by himself before deadline.
if !req.IsAdmin && (time.Now().After(old.CreatedAt.Add(constant.CommentEditDeadline))) {
return errors.BadRequest(reason.CommentCannotEditAfterDeadline)
}
comment := &entity.Comment{}
_ = copier.Copy(comment, req)
comment.ID = req.CommentID
@ -198,7 +236,7 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment
return
}
if !exist {
return nil, errors.BadRequest(reason.UnknownError)
return nil, errors.BadRequest(reason.CommentNotFound)
}
resp = &schema.GetCommentResp{
@ -243,7 +281,8 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment
// check if current user vote this comment
resp.IsVote = cs.checkIsVote(ctx, req.UserID, resp.CommentID)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID, req.CanEdit, req.CanDelete)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
comment.CreatedAt, req.CanEdit, req.CanDelete)
return resp, nil
}
@ -339,7 +378,7 @@ func (cs *CommentService) convertCommentEntity2Resp(ctx context.Context, req *sc
commentResp.IsVote = cs.checkIsVote(ctx, req.UserID, commentResp.CommentID)
commentResp.MemberActions = permission.GetCommentPermission(ctx,
req.UserID, commentResp.UserID, req.CanEdit, req.CanDelete)
req.UserID, commentResp.UserID, comment.CreatedAt, req.CanEdit, req.CanDelete)
return commentResp, nil
}
@ -521,14 +560,16 @@ func (cs *CommentService) notificationCommentReply(ctx context.Context, replyUse
notice_queue.AddNotification(msg)
}
func (cs *CommentService) notificationMention(ctx context.Context, mentionUsernameList []string, commentID, commentUserID string) {
func (cs *CommentService) notificationMention(
ctx context.Context, mentionUsernameList []string, commentID, commentUserID string,
alreadyNotifiedUserID map[string]bool) (alreadyNotifiedUserIDs []string) {
for _, username := range mentionUsernameList {
userInfo, exist, err := cs.userCommon.GetUserBasicInfoByUserName(ctx, username)
if err != nil {
log.Error(err)
continue
}
if exist {
if exist && !alreadyNotifiedUserID[userInfo.ID] {
msg := &schema.NotificationMsg{
ReceiverUserID: userInfo.ID,
TriggerUserID: commentUserID,
@ -538,6 +579,8 @@ func (cs *CommentService) notificationMention(ctx context.Context, mentionUserna
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.MentionYou
notice_queue.AddNotification(msg)
alreadyNotifiedUserIDs = append(alreadyNotifiedUserIDs, userInfo.ID)
}
}
return alreadyNotifiedUserIDs
}

View File

@ -2,13 +2,15 @@ package permission
import (
"context"
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/schema"
)
// GetCommentPermission get comment permission
func GetCommentPermission(ctx context.Context, userID string, creatorUserID string, canEdit, canDelete bool) (
actions []*schema.PermissionMemberAction) {
func GetCommentPermission(ctx context.Context, userID string, creatorUserID string,
createdAt time.Time, canEdit, canDelete bool) (actions []*schema.PermissionMemberAction) {
actions = make([]*schema.PermissionMemberAction, 0)
if len(userID) > 0 {
actions = append(actions, &schema.PermissionMemberAction{
@ -17,7 +19,8 @@ func GetCommentPermission(ctx context.Context, userID string, creatorUserID stri
Type: "reason",
})
}
if canEdit || userID == creatorUserID {
deadline := createdAt.Add(constant.CommentEditDeadline)
if canEdit || (userID == creatorUserID && time.Now().Before(deadline)) {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",

View File

@ -254,6 +254,7 @@ func (qs *QuestionCommon) FormatQuestionsPage(
for _, questionInfo := range questionList {
t := &schema.QuestionPageResp{
ID: questionInfo.ID,
CreatedAt: questionInfo.CreatedAt.Unix(),
Title: questionInfo.Title,
UrlTitle: htmltext.UrlTitle(questionInfo.Title),
Description: htmltext.FetchExcerpt(questionInfo.ParsedText, "...", 240),

View File

@ -4,7 +4,6 @@ import (
"context"
"encoding/hex"
"math/rand"
"regexp"
"strings"
"github.com/Chain-Zhang/pinyin"
@ -120,9 +119,7 @@ func (us *UserCommon) MakeUsername(ctx context.Context, displayName string) (use
username = strings.ToLower(username)
suffix := ""
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(username)
if !match {
if checker.IsInvalidUsername(username) {
return "", errors.BadRequest(reason.UsernameInvalid)
}

11
pkg/checker/username.go Normal file
View File

@ -0,0 +1,11 @@
package checker
import "regexp"
var (
usernameReg = regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
)
func IsInvalidUsername(username string) bool {
return !usernameReg.MatchString(username)
}

View File

@ -3,6 +3,7 @@ package converter
import (
"bytes"
"github.com/asaskevich/govalidator"
"github.com/microcosm-cc/bluemonday"
"github.com/segmentfault/pacman/log"
"github.com/yuin/goldmark"
@ -10,6 +11,7 @@ import (
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
goldmarkHTML "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
@ -54,6 +56,7 @@ type DangerousHTMLRenderer struct {
func (r *DangerousHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
reg.Register(ast.KindLink, r.renderLink)
}
func (r *DangerousHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
@ -85,3 +88,30 @@ func (r *DangerousHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte,
}
return ast.WalkContinue, nil
}
func (r *DangerousHTMLRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering && r.renderLinkIsUrl(string(n.Destination)) {
_, _ = w.WriteString("<a href=\"")
if r.Unsafe || !html.IsDangerousURL(n.Destination) {
_, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
}
_ = w.WriteByte('"')
if n.Title != nil {
_, _ = w.WriteString(` title="`)
r.Writer.Write(w, n.Title)
_ = w.WriteByte('"')
}
if n.Attributes() != nil {
html.RenderAttributes(w, n, html.LinkAttributeFilter)
}
_ = w.WriteByte('>')
} else {
_, _ = w.WriteString("</a>")
}
return ast.WalkContinue, nil
}
func (r *DangerousHTMLRenderer) renderLinkIsUrl(verifyUrl string) bool {
return govalidator.IsURL(verifyUrl)
}

6
ui/.gitignore vendored
View File

@ -9,7 +9,11 @@
/coverage
# production
/build
/build/*/*/*
/build/*.json
/build/*.html
/build/*.txt
# misc
.DS_Store

BIN
ui/build/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

@ -22,7 +22,9 @@
"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",
@ -51,6 +53,7 @@
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/color": "^3.0.3",
"@types/dompurify": "^2.4.0",
"@types/jest": "^27.5.2",
"@types/lodash": "^4.14.184",
"@types/marked": "^4.0.6",

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="generator" content="Answer %AnswerVersion% - https://github.com/answerdev/answer">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
</head>
<body>

View File

@ -56,7 +56,7 @@ export interface QuestionParams {
title: string;
url_title?: string;
content: string;
html: string;
html?: string;
tags: Tag[];
}
@ -207,7 +207,7 @@ export interface AnswerItem {
export interface PostAnswerReq {
content: string;
html: string;
html?: string;
question_id: string;
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, memo } from 'react';
import { Button } from 'react-bootstrap';
import { Button, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
@ -7,7 +7,7 @@ import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers } from '@/hooks';
const Form = ({
const Index = ({
className = '',
value: initialValue = '',
onSendReply,
@ -18,7 +18,7 @@ const Form = ({
const [value, setValue] = useState('');
const pageUsers = usePageUsers();
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
const [validationErrorMsg, setValidationErrorMsg] = useState('');
useEffect(() => {
if (!initialValue) {
return;
@ -32,6 +32,13 @@ const Form = ({
const handleSelected = (val) => {
setValue(val);
};
const handleSendReply = () => {
onSendReply(value).catch((ex) => {
if (ex.isError) {
setValidationErrorMsg(ex.msg);
}
});
};
return (
<div
className={classNames(
@ -39,17 +46,27 @@ const Form = ({
className,
)}>
<div>
<Mentions pageUsers={pageUsers.getUsers()} onSelected={handleSelected}>
<TextArea size="sm" value={value} onChange={handleChange} />
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
<div
className={classNames('custom-form-control', {
'is-invalid': validationErrorMsg,
})}>
<Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
<TextArea size="sm" value={value} onChange={handleChange} />
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
</div>
<Form.Control.Feedback type="invalid">
{validationErrorMsg}
</Form.Control.Feedback>
</div>
{type === 'edit' ? (
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
<Button
size="sm"
className="text-nowrap "
onClick={() => onSendReply(value)}>
onClick={() => handleSendReply()}>
{t('btn_save_edits')}
</Button>
<Button
@ -64,7 +81,7 @@ const Form = ({
<Button
size="sm"
className="text-nowrap ms-0 ms-md-2 mt-2 mt-md-0"
onClick={() => onSendReply(value)}>
onClick={() => handleSendReply()}>
{t('btn_add_comment')}
</Button>
)}
@ -72,4 +89,4 @@ const Form = ({
);
};
export default memo(Form);
export default memo(Index);

View File

@ -1,21 +1,30 @@
import { useState, memo } from 'react';
import { Button } from 'react-bootstrap';
import { Button, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { TextArea, Mentions } from '@/components';
import { usePageUsers } from '@/hooks';
const Form = ({ userName, onSendReply, onCancel, mode }) => {
const Index = ({ userName, onSendReply, onCancel, mode }) => {
const [value, setValue] = useState('');
const pageUsers = usePageUsers();
const { t } = useTranslation('translation', { keyPrefix: 'comment' });
const [validationErrorMsg, setValidationErrorMsg] = useState('');
const handleChange = (e) => {
setValue(e.target.value);
};
const handleSelected = (val) => {
setValue(val);
};
const handleSendReply = () => {
onSendReply(value).catch((ex) => {
if (ex.isError) {
setValidationErrorMsg(ex.msg);
}
});
};
return (
<div className="mb-2">
@ -24,18 +33,26 @@ const Form = ({ userName, onSendReply, onCancel, mode }) => {
</div>
<div className="d-flex mb-1 align-items-start flex-column flex-md-row">
<div>
<Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
<TextArea size="sm" value={value} onChange={handleChange} />
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
<div
className={classNames('custom-form-control', {
'is-invalid': validationErrorMsg,
})}>
<Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
<TextArea size="sm" value={value} onChange={handleChange} />
</Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div>
</div>
<Form.Control.Feedback type="invalid">
{validationErrorMsg}
</Form.Control.Feedback>
</div>
<div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
<Button
size="sm"
className="text-nowrap"
onClick={() => onSendReply(value)}>
onClick={() => handleSendReply()}>
{t('btn_add_comment')}
</Button>
<Button
@ -51,4 +68,4 @@ const Form = ({ userName, onSendReply, onCancel, mode }) => {
);
};
export default memo(Form);
export default memo(Index);

View File

@ -10,7 +10,12 @@ import { marked } from 'marked';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
import { usePageUsers, useReportModal } from '@/hooks';
import { matchedUsers, parseUserInfo, scrollTop, bgFadeOut } from '@/utils';
import {
matchedUsers,
parseUserInfo,
scrollToElementTop,
bgFadeOut,
} from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import {
useQueryComments,
@ -43,7 +48,7 @@ const Comment = ({ objectId, mode, commentId }) => {
const scrollCallback = useCallback((el, co) => {
if (pageIndex === 0 && co.comment_id === commentId) {
setTimeout(() => {
scrollTop(el);
scrollToElementTop(el);
bgFadeOut(el);
}, 100);
}
@ -102,13 +107,14 @@ const Comment = ({ objectId, mode, commentId }) => {
const handleSendReply = (item) => {
const users = matchedUsers(item.value);
const userNames = unionBy(users.map((user) => user.userName));
const html = marked.parse(parseUserInfo(item.value));
if (!item.value || !html) {
return;
}
const commentMarkDown = parseUserInfo(item.value);
const html = marked.parse(commentMarkDown);
// if (!commentMarkDown || !html) {
// return;
// }
const params = {
object_id: objectId,
original_text: item.value,
original_text: commentMarkDown,
mention_username_list: userNames,
parsed_text: html,
...(item.type === 'reply'
@ -119,7 +125,7 @@ const Comment = ({ objectId, mode, commentId }) => {
};
if (item.type === 'edit') {
updateComment({
return updateComment({
...params,
comment_id: item.comment_id,
}).then(() => {
@ -134,30 +140,29 @@ const Comment = ({ objectId, mode, commentId }) => {
}),
);
});
} else {
addComment(params).then((res) => {
if (item.type === 'reply') {
const index = comments.findIndex(
(comment) => comment.comment_id === item.comment_id,
);
comments[index].showReply = false;
comments.splice(index + 1, 0, res);
setComments([...comments]);
} else {
setComments([
...comments.map((comment) => {
if (comment.comment_id === item.comment_id) {
comment.showReply = false;
}
return comment;
}),
res,
]);
}
setVisibleComment(false);
});
}
return addComment(params).then((res) => {
if (item.type === 'reply') {
const index = comments.findIndex(
(comment) => comment.comment_id === item.comment_id,
);
comments[index].showReply = false;
comments.splice(index + 1, 0, res);
setComments([...comments]);
} else {
setComments([
...comments.map((comment) => {
if (comment.comment_id === item.comment_id) {
comment.showReply = false;
}
return comment;
}),
res,
]);
}
setVisibleComment(false);
});
};
const handleDelete = (id) => {

View File

@ -0,0 +1,77 @@
import { FC, memo } from 'react';
import { useTranslation } from 'react-i18next';
import classname from 'classnames';
import { Icon } from '@/components';
interface Props {
data: {
votes: number;
answers: number;
views: number;
};
showVotes?: boolean;
showAnswers?: boolean;
showViews?: boolean;
showAccepted?: boolean;
isAccepted?: boolean;
className?: string;
}
const Index: FC<Props> = ({
data,
showVotes = true,
showAnswers = true,
showViews = true,
isAccepted = false,
showAccepted = false,
className = '',
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'counts' });
return (
<div className={classname('d-flex align-items-center', className)}>
{showVotes && (
<div className="d-flex align-items-center">
<Icon name="hand-thumbs-up-fill me-1" />
<span>
{data.votes} {t('votes')}
</span>
</div>
)}
{showAccepted && (
<div className="d-flex align-items-center ms-3 text-success">
<Icon name="check-circle-fill me-1" />
<span>{t('accepted')}</span>
</div>
)}
{showAnswers && (
<div
className={`d-flex align-items-center ms-3 ${
isAccepted ? 'text-success' : ''
}`}>
{isAccepted ? (
<Icon name="check-circle-fill me-1" />
) : (
<Icon name="chat-square-text-fill me-1" />
)}
<span>
{data.answers} {t('answers')}
</span>
</div>
)}
{showViews && (
<span className="summary-stat ms-3">
<Icon name="eye-fill" />
<em className="fst-normal ms-1">
{data.views} {t('views')}
</em>
</span>
)}
</div>
);
};
export default memo(Index);

View File

@ -8,6 +8,7 @@ import {
} from 'react';
import { markdownToHtml } from '@/services';
import { htmlToReact } from '@/utils';
import { htmlRender } from './utils';
@ -38,6 +39,7 @@ const Index = ({ value }, ref) => {
}
previewRef.current?.scrollTo(0, scrollTop);
htmlRender(previewRef.current);
}, [html]);
useImperativeHandle(ref, () => {
@ -49,9 +51,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"
dangerouslySetInnerHTML={{ __html: html }}
/>
className="preview-wrap position-relative p-3 bg-light rounded text-break text-wrap mt-2 fmt">
{htmlToReact(html)}
</div>
);
};

View File

@ -3,6 +3,8 @@ import { Pagination } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { scrollToDocTop } from '@/utils';
interface Props {
currentPage: number;
pageSize: number;
@ -49,7 +51,7 @@ const PageItem = ({ page, currentPage, path }: PageItemProps) => {
e.preventDefault();
e.stopPropagation();
navigate(path);
window.scrollTo(0, 0);
scrollToDocTop();
}}>
{page}
</Pagination.Item>
@ -91,7 +93,7 @@ const Index: FC<Props> = ({
onClick={(e) => {
e.preventDefault();
navigate(handleParams(currentPage - 1));
window.scrollTo(0, 0);
scrollToDocTop();
}}>
{t('prev')}
</Pagination.Prev>
@ -186,7 +188,7 @@ const Index: FC<Props> = ({
onClick={(e) => {
e.preventDefault();
navigate(handleParams(currentPage + 1));
window.scrollTo(0, 0);
scrollToDocTop();
}}>
{t('next')}
</Pagination.Next>

View File

@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import type * as Type from '@/common/interface';
import {
Icon,
Tag,
Pagination,
FormatTime,
@ -14,6 +13,7 @@ import {
BaseUserCard,
QueryGroup,
QuestionListLoader,
Counts,
} from '@/components';
import { useQuestionList } from '@/services';
@ -95,29 +95,15 @@ const QuestionList: FC<Props> = ({ source }) => {
preFix={t(li.operation_type)}
/>
</div>
<div className="ms-0 ms-md-3 mt-2 mt-md-0">
<span>
<Icon name="hand-thumbs-up-fill" />
<em className="fst-normal ms-1">{li.vote_count}</em>
</span>
<span
className={`ms-3 ${
li.accepted_answer_id >= 1 ? 'text-success' : ''
}`}>
<Icon
name={
li.accepted_answer_id >= 1
? 'check-circle-fill'
: 'chat-square-text-fill'
}
/>
<em className="fst-normal ms-1">{li.answer_count}</em>
</span>
<span className="summary-stat ms-3">
<Icon name="eye-fill" />
<em className="fst-normal ms-1">{li.view_count}</em>
</span>
</div>
<Counts
data={{
votes: li.vote_count,
answers: li.answer_count,
views: li.view_count,
}}
isAccepted={li.accepted_answer_id >= 1}
className="ms-0 ms-md-3 mt-2 mt-md-0"
/>
</div>
<div className="question-tags m-n1">
{Array.isArray(li.tags)
@ -139,7 +125,7 @@ const QuestionList: FC<Props> = ({ source }) => {
currentPage={curPage}
totalSize={count}
pageSize={pageSize}
pathname="/questions"
pathname={source === 'questions' ? '/questions' : ''}
/>
</div>
</div>

View File

@ -155,6 +155,7 @@ const TagSelector: FC<IProps> = ({
};
const handleKeyDown = (e) => {
e.stopPropagation();
if (!tags) {
return;
}
@ -166,13 +167,20 @@ const TagSelector: FC<IProps> = ({
if (keyCode === 40 && currentIndex < tags.length - 1) {
setCurrentIndex(currentIndex + 1);
}
if (
keyCode === 13 &&
currentIndex > -1 &&
currentIndex <= tags.length - 1
) {
if (keyCode === 13 && currentIndex > -1) {
e.preventDefault();
handleClick(tags[currentIndex]);
if (tags.length === 0) {
tagModal.onShow(tag);
return;
}
if (currentIndex <= tags.length - 1) {
handleClick(tags[currentIndex]);
if (currentIndex === tags.length - 1 && currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
}
}
};
return (

View File

@ -32,6 +32,7 @@ import CustomizeTheme from './CustomizeTheme';
import PageTags from './PageTags';
import QuestionListLoader from './QuestionListLoader';
import TagsLoader from './TagsLoader';
import Counts from './Counts';
export {
Avatar,
@ -70,5 +71,6 @@ export {
PageTags,
QuestionListLoader,
TagsLoader,
Counts,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -143,21 +143,7 @@ const Ask = () => {
const checkValidated = (): boolean => {
const bol = true;
const { title, content, tags, answer } = formData;
if (!title.value) {
// bol = false;
// formData.title = {
// value: '',
// isInvalid: true,
// errorMsg: t('form.fields.title.msg.empty'),
// };
} else if (Array.from(title.value).length > 150) {
// bol = false;
// formData.title = {
// value: title.value,
// isInvalid: true,
// errorMsg: t('form.fields.title.msg.range'),
// };
} else {
if (title.value && Array.from(title.value).length <= 150) {
formData.title = {
value: title.value,
isInvalid: false,
@ -165,14 +151,7 @@ const Ask = () => {
};
}
if (!content.value) {
// bol = false;
// formData.content = {
// value: '',
// isInvalid: true,
// errorMsg: t('form.fields.body.msg.empty'),
// };
} else {
if (content.value) {
formData.content = {
value: content.value,
isInvalid: false,
@ -180,29 +159,16 @@ const Ask = () => {
};
}
if (tags.value.length === 0) {
// bol = false;
// formData.tags = {
// value: [],
// isInvalid: true,
// errorMsg: t('form.fields.tags.msg.empty'),
// };
} else {
if (Array.isArray(tags.value) && tags.value.length > 0) {
formData.tags = {
value: tags.value,
isInvalid: false,
errorMsg: '',
};
}
if (checked) {
if (!answer.value) {
// bol = false;
// formData.answer = {
// value: '',
// isInvalid: true,
// errorMsg: t('form.fields.answer.msg.empty'),
// };
} else {
if (answer.value) {
formData.answer = {
value: answer.value,
isInvalid: false,
@ -227,7 +193,6 @@ const Ask = () => {
const params: Type.QuestionParams = {
title: formData.title.value,
content: formData.content.value,
html: editorRef.current.getHtml(),
tags: formData.tags.value,
};
if (isEdit) {
@ -261,7 +226,6 @@ const Ask = () => {
postAnswer({
question_id: id,
content: formData.answer.value,
html: editorRef2.current.getHtml(),
})
.then(() => {
navigate(pathFactory.questionLanding(id, params.url_title));

View File

@ -12,7 +12,7 @@ import {
FormatTime,
htmlRender,
} from '@/components';
import { scrollTop, bgFadeOut } from '@/utils';
import { scrollToElementTop, bgFadeOut } from '@/utils';
import { AnswerItem } from '@/common/interface';
import { acceptanceAnswer } from '@/services';
@ -60,7 +60,7 @@ const Index: FC<Props> = ({
if (aid === data.id) {
setTimeout(() => {
const element = answerRef.current;
scrollTop(element);
scrollToElementTop(element);
if (!searchParams.get('commentId')) {
bgFadeOut(answerRef.current);
}

View File

@ -8,7 +8,7 @@ import classNames from 'classnames';
import { Editor, Modal, TextArea } from '@/components';
import { FormDataType } from '@/common/interface';
import { postAnswer } from '@/services';
import { guard } from '@/utils';
import { guard, handleFormError } from '@/utils';
interface Props {
visible?: boolean;
@ -35,35 +35,60 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false);
const checkValidated = (): boolean => {
let bol = true;
const { content } = formData;
if (!content.value || Array.from(content.value.trim()).length < 6) {
bol = false;
formData.content = {
value: content.value,
isInvalid: true,
errorMsg: t('characters'),
};
} else {
formData.content = {
value: content.value,
isInvalid: false,
errorMsg: '',
};
}
setFormData({
...formData,
});
return bol;
};
const handleSubmit = () => {
if (!guard.tryNormalLogged(true)) {
return;
}
if (!formData.content.value) {
setFormData({
content: {
value: '',
isInvalid: true,
errorMsg: t('empty'),
},
});
if (!checkValidated()) {
return;
}
postAnswer({
question_id: data?.qid,
content: formData.content.value,
html: marked.parse(formData.content.value),
}).then((res) => {
setShowEditor(false);
setFormData({
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
})
.then((res) => {
setShowEditor(false);
setFormData({
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
callback?.(res.info);
})
.catch((ex) => {
if (ex.isError) {
const stateData = handleFormError(ex, formData);
setFormData({ ...stateData });
}
});
callback?.(res.info);
});
};
const clickBtn = () => {

View File

@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
import Pattern from '@/common/pattern';
import { Pagination } from '@/components';
import { loggedUserInfoStore, toastStore } from '@/stores';
import { scrollTop } from '@/utils';
import { scrollToElementTop } from '@/utils';
import { usePageTags, usePageUsers } from '@/hooks';
import type {
ListResult,
@ -80,7 +80,7 @@ const Index = () => {
if (page > 0 || order) {
// scroll into view;
const element = document.getElementById('answerHeader');
scrollTop(element);
scrollToElementTop(element);
}
res.list.forEach((item) => {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useRef } 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,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { handleFormError } from '@/utils';
import { usePageTags } from '@/hooks';
import { pathFactory } from '@/router/pathFactory';
import { Editor, EditorRef, Icon } from '@/components';
@ -19,11 +20,11 @@ import {
import './index.scss';
interface FormDataItem {
answer: Type.FormValue<string>;
content: Type.FormValue<string>;
description: Type.FormValue<string>;
}
const initFormData = {
answer: {
content: {
value: '',
isInvalid: false,
errorMsg: '',
@ -35,7 +36,6 @@ const initFormData = {
},
};
const Index = () => {
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const { aid = '', qid = '' } = useParams();
const [focusType, setForceType] = useState('');
@ -43,6 +43,10 @@ const Index = () => {
const navigate = useNavigate();
const { data } = useQueryAnswerInfo(aid);
const [formData, setFormData] = useState<FormDataItem>(initFormData);
initFormData.content.value = data?.info.content || '';
const { data: revisions = [] } = useQueryRevisions(aid);
const editorRef = useRef<EditorRef>({
@ -51,18 +55,10 @@ const Index = () => {
const questionContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!data) {
return;
}
formData.answer.value = data.info.content;
setFormData({ ...formData });
}, [data]);
const handleAnswerChange = (value: string) =>
setFormData({
...formData,
answer: { ...formData.answer, value },
content: { ...formData.content, value },
});
const handleSummaryChange = (evt) => {
const v = evt.currentTarget.value;
@ -74,18 +70,18 @@ const Index = () => {
const checkValidated = (): boolean => {
let bol = true;
const { answer } = formData;
const { content } = formData;
if (!answer.value) {
if (!content.value || Array.from(content.value.trim()).length < 6) {
bol = false;
formData.answer = {
value: '',
formData.content = {
value: content.value,
isInvalid: true,
errorMsg: '标题不能为空',
errorMsg: t('form.fields.answer.feedback.characters'),
};
} else {
formData.answer = {
value: answer.value,
formData.content = {
value: content.value,
isInvalid: false,
errorMsg: '',
};
@ -105,29 +101,36 @@ const Index = () => {
}
const params: Type.AnswerParams = {
content: formData.answer.value,
content: formData.content.value,
html: editorRef.current.getHtml(),
question_id: qid,
id: aid,
edit_summary: formData.description.value,
};
modifyAnswer(params).then((res) => {
navigate(
pathFactory.answerLanding({
questionId: qid,
slugTitle: data?.question?.url_title,
answerId: aid,
}),
{
state: { isReview: res?.wait_for_review },
},
);
});
modifyAnswer(params)
.then((res) => {
navigate(
pathFactory.answerLanding({
questionId: qid,
slugTitle: data?.question?.url_title,
answerId: aid,
}),
{
state: { isReview: res?.wait_for_review },
},
);
})
.catch((ex) => {
if (ex.isError) {
const stateData = handleFormError(ex, formData);
setFormData({ ...stateData });
}
});
};
const handleSelectedRevision = (e) => {
const index = e.target.value;
const revision = revisions[index];
formData.answer.value = revision.content.content;
formData.content.value = revision.content.content;
setFormData({ ...formData });
};
@ -190,7 +193,7 @@ const Index = () => {
<Form.Group controlId="answer" className="mt-3">
<Form.Label>{t('form.fields.answer.label')}</Form.Label>
<Editor
value={formData.answer.value}
value={formData.content.value}
onChange={handleAnswerChange}
className={classNames(
'form-control p-0',
@ -205,14 +208,14 @@ const Index = () => {
ref={editorRef}
/>
<Form.Control
value={formData.answer.value}
value={formData.content.value}
type="text"
isInvalid={formData.answer.isInvalid}
isInvalid={formData.content.isInvalid}
readOnly
hidden
/>
<Form.Control.Feedback type="invalid">
{formData.answer.errorMsg}
{formData.content.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="edit_summary" className="my-3">

View File

@ -3,7 +3,7 @@ import { ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { pathFactory } from '@/router/pathFactory';
import { Icon, Tag, FormatTime, BaseUserCard } from '@/components';
import { Tag, FormatTime, BaseUserCard, Counts } from '@/components';
import type { SearchResItem } from '@/common/interface';
import { escapeRemove } from '@/utils';
@ -51,23 +51,17 @@ const Index: FC<Props> = ({ data }) => {
className="me-3"
preFix={data.object_type === 'question' ? 'asked' : 'answered'}
/>
<div className="d-flex align-items-center my-2 my-sm-0">
<div className="d-flex align-items-center me-3">
<Icon name="hand-thumbs-up-fill me-1" />
<span> {data.object?.vote_count}</span>
</div>
<div
className={`d-flex align-items-center ${
data.object?.accepted ? 'text-success' : ''
}`}>
{data.object?.accepted ? (
<Icon name="check-circle-fill me-1" />
) : (
<Icon name="chat-square-text-fill me-1" />
)}
<span>{data.object?.answer_count}</span>
</div>
</div>
<Counts
className="my-2 my-sm-0"
showViews={false}
isAccepted={data.object?.accepted}
data={{
votes: data.object?.vote_count,
answers: data.object?.answer_count,
views: 0,
}}
/>
</div>
{data.object?.excerpt && (

View File

@ -3,10 +3,12 @@ import { Container, Row, Col } from 'react-bootstrap';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { siteInfoStore } from '@/stores';
import { usePageTags } from '@/hooks';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'account_result' });
const siteName = siteInfoStore((state) => state.siteInfo.name);
const location = useLocation();
usePageTags({
title: t('account_activation', { keyPrefix: 'page_title' }),
@ -15,7 +17,9 @@ const Index: FC = () => {
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
<Col lg={6}>
<h3 className="text-center mt-3 mb-5">{t('page_title')}</h3>
<h3 className="text-center mt-3 mb-5">
{t('page_title', { site_name: siteName })}
</h3>
{location.pathname?.includes('success') && (
<>
<p className="text-center">{t('success')}</p>

View File

@ -2,18 +2,22 @@ import { FC, memo } from 'react';
import { Container, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { siteInfoStore } from '@/stores';
import { usePageTags } from '@/hooks';
import SendEmail from './components/sendEmail';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'change_email' });
const siteName = siteInfoStore((state) => state.siteInfo.name);
usePageTags({
title: t('change_email', { keyPrefix: 'page_title' }),
});
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
<h3 className="text-center mb-5">
{t('page_title', { site_name: siteName })}
</h3>
<Col className="mx-auto" md={3}>
<SendEmail />
</Col>

View File

@ -4,7 +4,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { loggedUserInfoStore } from '@/stores';
import { loggedUserInfoStore, siteInfoStore } from '@/stores';
import { changeEmailVerify, getLoggedUserInfo } from '@/services';
const Index: FC = () => {
@ -13,6 +13,7 @@ const Index: FC = () => {
const [step, setStep] = useState('loading');
const updateUser = loggedUserInfoStore((state) => state.update);
const siteName = siteInfoStore((state) => state.siteInfo.name);
useEffect(() => {
const code = searchParams.get('code');
@ -38,7 +39,9 @@ const Index: FC = () => {
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
<Col lg={6}>
<h3 className="text-center mt-3 mb-5">{t('page_title')}</h3>
<h3 className="text-center mt-3 mb-5">
{t('page_title', { site_name: siteName })}
</h3>
{step === 'success' && (
<>
<p className="text-center">{t('confirm_new_email')}</p>

View File

@ -2,7 +2,7 @@ import { FC, memo } from 'react';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon, FormatTime, Tag } from '@/components';
import { FormatTime, Tag, Counts } from '@/components';
import { pathFactory } from '@/router/pathFactory';
interface Props {
@ -35,21 +35,16 @@ const Index: FC<Props> = ({ visible, data }) => {
<div className="d-flex align-items-center fs-14 text-secondary mb-2">
<FormatTime
time={item.create_time}
className="me-4"
className="me-3"
preFix={t('answered')}
/>
<div className="d-flex align-items-center me-3">
<Icon name="hand-thumbs-up-fill me-1" />
<span>{item?.vote_count}</span>
</div>
{item.accepted === 2 && (
<div className="d-flex align-items-center me-3 text-success">
<Icon name="check-circle-fill me-1" />
<span>{t('accepted')}</span>
</div>
)}
<Counts
data={{ votes: item?.vote_count, views: 0, answers: 0 }}
showAnswers={false}
showViews={false}
showAccepted={item.accepted === 2}
/>
</div>
<div>
{item.question_info?.tags?.map((tag) => {

View File

@ -2,7 +2,7 @@ import { FC, memo } from 'react';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon, FormatTime, Tag, BaseUserCard } from '@/components';
import { FormatTime, Tag, BaseUserCard, Counts } from '@/components';
import { pathFactory } from '@/router/pathFactory';
interface Props {
@ -44,35 +44,23 @@ const Index: FC<Props> = ({ visible, tabName, data }) => {
<span className="split-dot" />
</>
)}
<FormatTime
time={item.create_time}
time={
tabName === 'bookmarks' ? item.create_time : item.created_at
}
className="me-3"
preFix={t('asked')}
/>
<div className="d-flex align-items-center me-3">
<Icon name="hand-thumbs-up-fill me-1" />
<span>{item.vote_count}</span>
</div>
{tabName !== 'answers' && (
<div
className={`d-flex align-items-center me-3 ${
Number(item.accepted_answer_id) > 0 ? 'text-success' : ''
}`}>
{Number(item.accepted_answer_id) > 0 ? (
<Icon name="check-circle-fill me-1" />
) : (
<Icon name="chat-square-text-fill me-1" />
)}
<span>{item.answer_count}</span>
</div>
)}
<div className="d-flex align-items-center me-3">
<Icon name="eye-fill me-1" />
<span>{item.view_count}</span>
</div>
<Counts
isAccepted={Number(item.accepted_answer_id) > 0}
data={{
votes: item.vote_count,
answers: item.answer_count,
views: item.view_count,
}}
/>
</div>
<div>
{item.tags?.map((tag) => {

View File

@ -32,9 +32,12 @@ const Index: FC<Props> = ({ data, type }) => {
}>
{type === 'answer' ? item.question_info.title : item.title}
</a>
<div className="d-inline-block text-secondary ms-3 fs-14">
<Icon name="hand-thumbs-up-fill" />
<span> {item.vote_count}</span>
<Icon name="hand-thumbs-up-fill me-1" />
<span>
{item.vote_count} {t('votes', { keyPrefix: 'counts' })}
</span>
</div>
{type === 'question' && (
<div
@ -47,7 +50,10 @@ const Index: FC<Props> = ({ data, type }) => {
<Icon name="chat-square-text-fill" />
)}
<span> {item.answer_count}</span>
<span>
{' '}
{item.answer_count} {t('answers', { keyPrefix: 'counts' })}
</span>
</div>
)}

View File

@ -2,7 +2,6 @@ import React, { FormEvent, useState, useEffect } from 'react';
import { Form, Button, Stack, ButtonGroup } from 'react-bootstrap';
import { Trans, useTranslation } from 'react-i18next';
import { marked } from 'marked';
import MD5 from 'md5';
import type { FormDataType } from '@/common/interface';
@ -205,7 +204,6 @@ const Index: React.FC = () => {
bio: formData.bio.value,
website: formData.website.value,
location: formData.location.value,
bio_html: marked.parse(formData.bio.value),
};
modifyUserInfo(params)

View File

@ -1,4 +1,6 @@
import i18next from 'i18next';
import parse from 'html-react-parser';
import * as DOMPurify from 'dompurify';
const Diff = require('diff');
@ -21,7 +23,7 @@ function formatCount($num: number): string {
return res;
}
function scrollTop(element) {
function scrollToElementTop(element) {
if (!element) {
return;
}
@ -36,6 +38,15 @@ function scrollTop(element) {
});
}
const scrollToDocTop = () => {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
});
};
const bgFadeOut = (el) => {
if (el && !el.classList.contains('bg-fade-out')) {
el.classList.add('bg-fade-out');
@ -160,14 +171,17 @@ function handleFormError(
) {
if (error.list?.length > 0) {
error.list.forEach((item) => {
data[item.error_field].isInvalid = true;
data[item.error_field].errorMsg = item.error_msg;
const errorFieldObject = data[item.error_field];
if (errorFieldObject) {
errorFieldObject.isInvalid = true;
errorFieldObject.errorMsg = item.error_msg;
}
});
}
return data;
}
function diffText(newText: string, oldText: string): string {
function diffText(newText: string, oldText?: string): string {
if (!newText) {
return '';
}
@ -181,8 +195,6 @@ function diffText(newText: string, oldText: string): string {
?.replace(/<input/gi, '&lt;input');
}
const diff = Diff.diffChars(oldText, newText);
console.log(diff);
const result = diff.map((part) => {
if (part.added) {
if (part.value.replace(/\n/g, '').length <= 0) {
@ -214,10 +226,18 @@ function diffText(newText: string, oldText: string): string {
?.replace(/<input/gi, '&lt;input');
}
function htmlToReact(html: string) {
const cleanedHtml = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
return parse(cleanedHtml);
}
export {
thousandthDivision,
formatCount,
scrollTop,
scrollToElementTop,
scrollToDocTop,
bgFadeOut,
matchedUsers,
parseUserInfo,
@ -228,4 +248,5 @@ export {
labelStyle,
handleFormError,
diffText,
htmlToReact,
};

View File

@ -267,7 +267,7 @@ export const initAppSettingsStore = async () => {
};
export const shouldInitAppFetchData = () => {
if (isIgnoredPath('/install')) {
if (isIgnoredPath('/install') && window.location.pathname === '/install') {
return false;
}

View File

@ -48,7 +48,7 @@ class Request {
},
(error) => {
const { status, data: respData } = error.response || {};
const { data = {}, msg = '' } = respData || {};
const { data = {}, msg = '', reason = '' } = respData || {};
if (status === 400) {
// show error message
if (data instanceof Object && data.err_type) {
@ -79,7 +79,13 @@ class Request {
if (data instanceof Array && data.length > 0) {
// handle form error
return Promise.reject({ isError: true, list: data });
return Promise.reject({
code: status,
msg,
reason,
isError: true,
list: data,
});
}
if (!data || Object.keys(data).length <= 0) {

View File

@ -6,9 +6,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{.title}}</title>
<meta name="description" content="{{.description}}" data-rh="true" />
{{if .keywords }}
<meta name="keywords" content="{{.keywords}}" data-rh="true" />
{{end}}
<meta name="generator" content="Answer {{.Version}} - https://github.com/answerdev/answer">
{{if .keywords }}<meta name="keywords" content="{{.keywords}}" data-rh="true" />{{end}}
<link rel="canonical" href="{{.siteinfo.Canonical}}" />
<link rel="manifest" href="/manifest.json" />
<link href="{{.cssPath}}" rel="stylesheet" />