Merge branch 'main' into feat/1.0.7/short-id

This commit is contained in:
aichy126 2023-03-09 11:37:48 +08:00
commit 2cf061ea68
81 changed files with 2094 additions and 1468 deletions

View File

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

View File

@ -5984,9 +5984,6 @@ const docTemplate = `{
"schema.GetOtherUserInfoResp": {
"type": "object",
"properties": {
"has": {
"type": "boolean"
},
"info": {
"$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp"
}
@ -7688,7 +7685,6 @@ const docTemplate = `{
],
"properties": {
"status": {
"description": "user status",
"type": "string",
"enum": [
"normal",
@ -7698,7 +7694,6 @@ const docTemplate = `{
]
},
"user_id": {
"description": "user id",
"type": "string"
}
}
@ -7994,6 +7989,10 @@ const docTemplate = `{
"label": {
"type": "string"
},
"progress": {
"description": "Translation completion percentage",
"type": "integer"
},
"value": {
"type": "string"
}

View File

@ -5972,9 +5972,6 @@
"schema.GetOtherUserInfoResp": {
"type": "object",
"properties": {
"has": {
"type": "boolean"
},
"info": {
"$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp"
}
@ -7676,7 +7673,6 @@
],
"properties": {
"status": {
"description": "user status",
"type": "string",
"enum": [
"normal",
@ -7686,7 +7682,6 @@
]
},
"user_id": {
"description": "user id",
"type": "string"
}
}
@ -7982,6 +7977,10 @@
"label": {
"type": "string"
},
"progress": {
"description": "Translation completion percentage",
"type": "integer"
},
"value": {
"type": "string"
}

View File

@ -488,8 +488,6 @@ definitions:
type: object
schema.GetOtherUserInfoResp:
properties:
has:
type: boolean
info:
$ref: '#/definitions/schema.GetOtherUserInfoByUsernameResp'
type: object
@ -1691,7 +1689,6 @@ definitions:
schema.UpdateUserStatusReq:
properties:
status:
description: user status
enum:
- normal
- suspended
@ -1699,7 +1696,6 @@ definitions:
- inactive
type: string
user_id:
description: user id
type: string
required:
- status
@ -1911,6 +1907,9 @@ definitions:
properties:
label:
type: string
progress:
description: Translation completion percentage
type: integer
value:
type: string
type: object

8
go.mod
View File

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

16
go.sum
View File

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

View File

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

View File

@ -1167,7 +1167,7 @@ ui:
validate: Indiquez une URL valide.
text: L'adresse de ce site.
short_desc:
label: Description Courte du Site
label: Description Courte
msg: La description courte ne peut pas être vide.
text: "La description courte, telle qu'elle est utilisée dans le tag titre de la page d'accueil."
desc:

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -14,155 +14,155 @@ backend:
role:
name:
user:
other: User
other: Usuário
admin:
other: Admin
other: Administrador
moderator:
other: Moderator
other: Moderador
description:
user:
other: Default with no special access.
other: Padrão sem acesso especial.
admin:
other: Have the full power to access the site.
other: Possui acesso total ao site.
moderator:
other: Has access to all posts except admin settings.
other: Possui acesso a todas as postagens exceto às configurações de usuários.
email:
other: Email
other: E-mail
password:
other: Password
other: Senha
email_or_password_wrong_error:
other: Email and password do not match.
other: O e-mail e a palavra-passe não coincidem.
error:
admin:
email_or_password_wrong:
other: Email and password do not match.
other: O e-mail e a palavra-passe não coincidem.
answer:
not_found:
other: Answer do not found.
other: Resposta não encontrada.
cannot_deleted:
other: No permission to delete.
other: Sem permissão para remover.
cannot_update:
other: No permission to update.
other: Sem permissão para atualizar.
comment:
edit_without_permission:
other: Comment are not allowed to edit.
other: Não é possível alterar comentários.
not_found:
other: Comment not found.
other: Comentário não encontrado.
cannot_edit_after_deadline:
other: The comment time has been too long to modify.
other: O tempo do comentário foi muito longo para ser modificado.
email:
duplicate:
other: Email already exists.
other: O e-mail já existe.
need_to_be_verified:
other: Email should be verified.
other: O e-mail deve ser verificado.
verify_url_expired:
other: Email verified URL has expired, please resend the email.
other: O e-mail verificado URL expirou, por favor, reenvie o e-mail.
lang:
not_found:
other: Language file not found.
other: Arquivo de idioma não encontrado.
object:
captcha_verification_failed:
other: Captcha wrong.
other: O Captcha está incorreto.
disallow_follow:
other: You are not allowed to follow.
other: Você não possui autorização suficiente para seguir.
disallow_vote:
other: You are not allowed to vote.
other: Você não possui permissão para votar.
disallow_vote_your_self:
other: You can't vote for your own post.
other: Você não pode votar na sua própria postagem.
not_found:
other: Object not found.
other: Objeto não encontrado.
verification_failed:
other: Verification failed.
other: A verificação falhou.
email_or_password_incorrect:
other: Email and password do not match.
other: O e-mail e a senha não correspondem.
old_password_verification_failed:
other: The old password verification failed
other: Falha na verificação de senha antiga
new_password_same_as_previous_setting:
other: The new password is the same as the previous one.
other: A nova senha é a mesma que a anterior.
question:
not_found:
other: Question not found.
other: Pergunta não encontrada.
cannot_deleted:
other: No permission to delete.
other: Sem permissão para remover.
cannot_close:
other: No permission to close.
other: Sem permissão para fechar.
cannot_update:
other: No permission to update.
other: Sem permissão para atualizar.
rank:
fail_to_meet_the_condition:
other: Rank fail to meet the condition.
other: O nível não consegue satisfazer a condição.
report:
handle_failed:
other: Report handle failed.
other: Falha ao manusear relatório.
not_found:
other: Report not found.
other: Relatório não encontrado.
tag:
not_found:
other: Tag not found.
other: Marcador não encontrado.
recommend_tag_not_found:
other: Recommend Tag is not exist.
other: O marcador recomendado não existe.
recommend_tag_enter:
other: Please enter at least one required tag.
other: Por favor, insira pelo menos um marcador obrigatório.
not_contain_synonym_tags:
other: Should not contain synonym tags.
other: Não deve conter marcadores sinónimos.
cannot_update:
other: No permission to update.
other: Sem permissão para atualizar.
is_used_cannot_delete:
other: You cannot delete a tag that is in use
other: Não é possível excluir um marcador em uso
cannot_set_synonym_as_itself:
other: You cannot set the synonym of the current tag as itself.
other: Você não pode definir o sinônimo do marcador atual como a si mesmo.
smtp:
config_from_name_cannot_be_email:
other: The From Name cannot be a email address.
other: Nome do remetente não pode ser um endereço de e-mail.
theme:
not_found:
other: Theme not found.
other: Tema não encontrado.
revision:
review_underway:
other: Can't edit currently, there is a version in the review queue.
other: Não é possível neste momento, há uma versão na fila de análise.
no_permission:
other: No permission to Revision.
other: Sem permissão para realizar Revisão.
user:
email_or_password_wrong:
other:
other: Email and password do not match.
other: O e-mail e a senha não conferem.
not_found:
other: User not found.
other: Usuário não encontrado.
suspended:
other: User has been suspended.
other: O usuário foi suspenso.
username_invalid:
other: Username is invalid.
other: Nome de usuário inválido.
username_duplicate:
other: Username is already in use.
other: O nome de usuário já em uso.
set_avatar:
other: Avatar set failed.
other: Configuração de avatar falhou.
cannot_update_your_role:
other: You cannot modify your role.
other: Você não pode modificar a sua função.
not_allowed_registration:
other: Currently the site is not open for registration
other: O site não está aberto para novos registros
config:
read_config_failed:
other: Read config failed
other: Falha ao ler configuração
database:
connection_failed:
other: Database connection failed
other: Falha ao conectar-se ao banco de dados
create_table_failed:
other: Create table failed
other: Falha ao criar tabela
install:
create_config_failed:
other: Can't create the config.yaml file.
other: Não foi possível criar o arquivo de configuração.
upload:
unsupported_file_format:
other: Unsupported file format.
other: Formato de arquivo não suportado.
report:
spam:
name:
other: spam
desc:
other: This post is an advertisement, or vandalism. It is not useful or relevant to the current topic.
other: Essa postagem é um anúncio, ou vandalismo. Não é útil ou relevante para o tópico atual.
rude:
name:
other: rude or abusive
other: rude ou abusivo
desc:
other: A reasonable person would find this content inappropriate for respectful discourse.
duplicate:
@ -264,73 +264,73 @@ ui:
settings: Settings
notifications: Notifications
login: Log In
sign_up: Sign Up
account_recovery: Account Recovery
account_activation: Account Activation
confirm_email: Confirm Email
account_suspended: Account Suspended
admin: Admin
change_email: Modify Email
install: Answer Installation
upgrade: Answer Upgrade
maintenance: Website Maintenance
users: Users
sign_up: Registar-se
account_recovery: Recuperação de conta
account_activation: Ativação de conta
confirm_email: Confirmar E-mail
account_suspended: Conta suspensa
admin: Administrador
change_email: Modificar e-mail
install: Instalação do Answer
upgrade: Atualização do Answer
maintenance: Manutenção do website
users: Usuários
notifications:
title: Notifications
inbox: Inbox
achievement: Achievements
all_read: Mark all as read
show_more: Show more
title: Notificações
inbox: Caixa de entrada
achievement: Conquistas
all_read: Marcar todos como lida
show_more: Mostrar mais
suspended:
title: Your Account has been Suspended
until_time: "Your account was suspended until {{ time }}."
forever: This user was suspended forever.
end: You don't meet a community guideline.
title: A sua conta foi suspensa
until_time: "Sua conta está suspensa até {{ time }}."
forever: Este usuário foi suspenso permanentemente.
end: Você não atende a uma diretriz da comunidade.
editor:
blockquote:
text: Blockquote
text: Bloco de citação
bold:
text: Strong
text: Negrito
chart:
text: Chart
flow_chart: Flow chart
sequence_diagram: Sequence diagram
class_diagram: Class diagram
state_diagram: State diagram
entity_relationship_diagram: Entity relationship diagram
user_defined_diagram: User defined diagram
gantt_chart: Gantt chart
pie_chart: Pie chart
text: Gráfico
flow_chart: Gráfico de fluxo
sequence_diagram: Diagrama de sequência
class_diagram: Diagrama de classe
state_diagram: Diagrama de estado
entity_relationship_diagram: Diagrama de relacionamento de entidade
user_defined_diagram: Diagrama definido pelo usuário
gantt_chart: Gráfico de Gantt
pie_chart: Gráfico de pizza
code:
text: Code Sample
add_code: Add code sample
text: Exemplo de código
add_code: Adicionar exemplo de código
form:
fields:
code:
label: Code
label: Código
msg:
empty: Code cannot be empty.
empty: Código não pode ser vazio.
language:
label: Language
placeholder: Automatic detection
btn_cancel: Cancel
btn_confirm: Add
label: Idioma
placeholder: Deteção automática
btn_cancel: Cancelar
btn_confirm: Adicionar
formula:
text: Formula
text: Fórmula
options:
inline: Inline formula
block: Block formula
inline: Fórmula na linha
block: Bloco de fórmula
heading:
text: Heading
text: Cabeçalho
options:
h1: Heading 1
h2: Heading 2
h3: Heading 3
h4: Heading 4
h5: Heading 5
h6: Heading 6
h1: Cabeçalho 1
h2: Cabeçalho 2
h3: Cabeçalho 3
h4: Cabeçalho 4
h5: Cabeçalho 5
h6: Cabeçalho 6
help:
text: Help
text: Ajuda
hr:
text: Horizontal Rule
image:

View File

@ -1,4 +1,5 @@
#The following fields are used for back-end
backend:
base:
success:
@ -80,6 +81,8 @@ backend:
new_password_same_as_previous_setting:
other: 新密码与之前的设置相同
question:
already_deleted:
other: 该内容已被删除
not_found:
other: 问题未找到
cannot_deleted:
@ -442,9 +445,9 @@ ui:
delete:
title: 删除标签
tip_with_posts: >-
<p>We do not allowed <strong>deleting tag with posts</strong>.</p> <p>Please remove this tag from the posts first.</p>
<p>我们不允许 <strong>删除带有同义词的标签</strong>。</p> <p>请先从此标签中删除同义词。</p>
tip_with_synonyms: >-
<p>We do not allowed <strong>deleting tag with synonyms</strong>.</p> <p>Please remove the synonyms from this tag first.</p>
<p>我们不允许 <strong>删除带有同义词的标签</strong>。</p> <p>请先从此标签中删除同义词。</p>
tip: 确定要删除吗?
close: 关闭
edit_tag:
@ -486,7 +489,7 @@ ui:
btn_flag: 举报
btn_save_edits: 保存
btn_cancel: 取消
show_more: Show more comments
show_more: 显示更多评论
tip_question: >-
使用评论提问更多信息或者提出改进意见。尽量避免使用评论功能回答问题。
tip_answer: >-
@ -626,7 +629,7 @@ ui:
msg:
empty: 邮箱不能为空
change_email:
page_title: Welcome to {{site_name}}
page_title: 欢迎来到 {{site_name}}
btn_cancel: 取消
btn_update: 更新电子邮件地址
send_success: >-
@ -755,7 +758,7 @@ ui:
confirm_info: >-
<p>您确定要提交一个新的回答吗?</p><p>您可以直接编辑和改善您之前的回答的。</p>
empty: 回答内容不能为空。
characters: content must be at least 6 characters in length.
characters: 内容长度至少 6 个字符
reopen:
title: 重新打开这个帖子
content: 确定要重新打开吗?
@ -816,7 +819,7 @@ ui:
modal_confirm:
title: 发生错误...
account_result:
page_title: Welcome to {{site_name}}
page_title: 欢迎来到 {{site_name}}
success: 你的账号已通过验证,即将返回首页。
link: 返回首页
invalid: >-
@ -827,7 +830,7 @@ ui:
unsubscribe:
page_title: 退订
success_title: 取消订阅成功
success_desc: You have been successfully removed from this subscriber list and won't receive any further emails from us.
success_desc: 您已成功地从此订阅者列表中移除,并且将不会再收到我们的任何电子邮件。
link: 更改设置
question:
following_tags: 已关注的标签
@ -889,7 +892,7 @@ ui:
title: Answer
next: 下一步
done: 完成
config_yaml_error: Can't create the config.yaml file.
config_yaml_error: 无法创建 config.yaml 文件。
lang:
label: 请选择一种语言
db_type:
@ -919,7 +922,7 @@ ui:
label: 已创建 config.yaml 文件。
desc: >-
您可以手动在 <1>/var/wwww/xxx/</1> 目录中创建<1>config.yaml</1> 文件并粘贴以下文本。
info: After you've done that, click "Next" button.
info: 完成后,点击“下一步”按钮。
site_information: 站点信息
admin_account: 管理员账户
site_name:
@ -964,12 +967,12 @@ ui:
您似乎已经安装过了。要重新安装,请先清除旧的数据库表。
db_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 hosts database server is down.
这或者意味着数据库信息在 <1>config.yaml</1> 文件不正确,或者无法与数据库服务器建立联系。这可能意味着您的主机数据库服务器已关闭。
counts:
views: views
votes: votes
answers: answers
accepted: Accepted
views: 次浏览
votes: 个点赞
answers: 个回答
accepted: 已被采纳
page_404:
desc: "很抱歉,此页面不存在。"
back_home: 回到主页
@ -977,7 +980,7 @@ ui:
desc: 服务器遇到了一个错误,无法完成你的请求。
back_home: 回到主页
page_maintenance:
desc: "We are under maintenance, we'll be back soon."
desc: "我们正在进行维护,我们将很快回来。"
nav_menus:
dashboard: 后台管理
contents: 内容管理
@ -1111,7 +1114,7 @@ ui:
password:
label: 密码
text: 用户将被注销,需要再次登录。
msg: Password must be at 8-32 characters in length.
msg: 密码的长度必须是8-32个字符。
btn_cancel: 取消
btn_submit: 提交
user_modal:
@ -1120,13 +1123,13 @@ ui:
fields:
display_name:
label: 昵称
msg: Display Name must be at 3-30 characters in length.
msg: 显示名称长度必须为 3-30 个字符
email:
label: 邮箱
msg: 电子邮箱无效。
password:
label: 密码
msg: Password must be at 8-32 characters in length.
msg: 密码的长度必须是8-32个字符。
btn_cancel: 取消
btn_submit: 提交
questions:
@ -1167,11 +1170,11 @@ ui:
validate: 请输入一个有效的 URL。
text: 此网站的地址。
short_desc:
label: Short Site Description
label: 简短站点描述
msg: 简短网站描述不能为空。
text: "简短的标语作为网站主页的标题Html 的 title 标签)。"
desc:
label: Site Description
label: 站点描述
msg: 网站描述不能为空。
text: "使用一句话描述本站作为网站的描述Html 的 meta 标签)。"
contact_email:
@ -1232,19 +1235,19 @@ ui:
branding:
page_title: 品牌
logo:
label: Logo
label: 网站标志(Logo)
msg: 图标不能为空。
text: 在你的网站左上方的Logo图标。使用一个高度为56长宽比大于3:1的宽长方形图像。如果留空将显示网站标题文本。
mobile_logo:
label: 移动端图标
text: The logo used on mobile version of your site. Use a wide rectangular image with a height of 56. If left blank, the image from the "logo" setting will be used.
label: 移动端 Logo
text: 在你的网站的移动版上使用的标志。使用一个高度为56的宽矩形图像。如果留空将使用 "Logo"设置中的图像。
square_icon:
label: Square Icon
label: 方形图标
msg: 方形图标不能为空。
text: 用作元数据图标的基础的图像。最好是大于512x512。
favicon:
label: 收藏夹图标
text: A favicon for your site. To work correctly over a CDN it must be a png. Will be resized to 32x32. If left blank, "square icon" will be used.
text: 网站的图标。要在 CDN 正常工作,它必须是 png。 将调整大小到32x32。如果留空将使用“方形图标”。
legal:
page_title: 法律条款
terms_of_service:
@ -1309,7 +1312,7 @@ ui:
label: 需要登录
text: 只有登录用户才能访问这个社区。
form:
optional: (optional)
optional: (选填)
empty: 不能为空
invalid: 是无效的
btn_submit: 保存
@ -1360,6 +1363,6 @@ ui:
reputation: 声望值
votes: 投票
prompt:
leave_page: "Are you sure you want to leave the page?"
changes_not_save: "Your changes may not be saved."
leave_page: "确定要离开此页面?"
changes_not_save: "您的更改尚未保存"

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -20,6 +20,8 @@ var GlobalTrans i18n.Translator
type LangOption struct {
Label string `json:"label"`
Value string `json:"value"`
// Translation completion percentage
Progress int `json:"progress"`
}
// DefaultLangOption default language option. If user config the language is default, the language option is admin choose.
@ -47,6 +49,7 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
if filepath.Ext(file.Name()) != ".yaml" && file.Name() != "i18n.yaml" {
continue
}
log.Debugf("try to read file: %s", file.Name())
buf, err := os.ReadFile(filepath.Join(c.BundleDir, file.Name()))
if err != nil {
return nil, fmt.Errorf("read file failed: %s %s", file.Name(), err)
@ -94,6 +97,11 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
return nil, fmt.Errorf("i18n file parsing failed: %s", err)
}
LanguageOptions = s.LangOption
for _, option := range LanguageOptions {
if option.Progress != 100 {
option.Label = fmt.Sprintf("%s (%d%%)", option.Label, option.Progress)
}
}
return GlobalTrans, err
}

View File

@ -136,6 +136,24 @@ func (ac *AnswerController) Add(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, nil)
return
}
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
permission.AnswerDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
objectOwner := ac.rankService.CheckOperationObjectOwner(ctx, req.UserID, info.ID)
req.CanEdit = canList[0] || objectOwner
req.CanDelete = canList[1] || objectOwner
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
info.MemberActions = permission.GetAnswerPermission(ctx, req.UserID, info.UserID, req.CanEdit, req.CanDelete)
handler.HandleResponse(ctx, nil, gin.H{
"info": info,
"question": questionInfo,

View File

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

View File

@ -41,7 +41,6 @@ func (u *LangController) GetLangMapping(ctx *gin.Context) {
// @Tags Lang
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/language/options [get]
// @Router /answer/admin/api/language/options [get]
func (u *LangController) GetAdminLangOptions(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, translator.LanguageOptions)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,7 +78,7 @@ type InitEnvironmentResp struct {
// InitBaseInfoReq init base info request
type InitBaseInfoReq struct {
Language string `validate:"required,gt=0,lte=30" json:"lang"`
SiteName string `validate:"required,gt=0,lte=30" json:"site_name"`
SiteName string `validate:"required,sanitizer,gt=0,lte=30" json:"site_name"`
SiteURL string `validate:"required,gt=0,lte=512,url" json:"site_url"`
ContactEmail string `validate:"required,email,gt=0,lte=500" json:"contact_email"`
AdminName string `validate:"required,gt=3,lte=30" json:"name"`

View File

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

View File

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

View File

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

View File

@ -24,6 +24,8 @@ type AnswerAddReq struct {
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
HTML string `json:"-"`
UserID string `json:"-"`
CanEdit bool `json:"-"`
CanDelete bool `json:"-"`
}
func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err error) {
@ -83,6 +85,7 @@ type AnswerInfo struct {
VoteStatus string `json:"vote_status"`
VoteCount int `json:"vote_count"`
QuestionInfo *QuestionInfo `json:"question_info,omitempty"`
Status int `json:"status"`
// MemberActions
MemberActions []*PermissionMemberAction `json:"member_actions"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -92,19 +92,14 @@ func (as *AnswerService) RemoveAnswer(ctx context.Context, req *schema.RemoveAns
if answerInfo.Accepted == schema.AnswerAcceptedEnable {
return errors.BadRequest(reason.AnswerCannotDeleted)
}
questionInfo, exist, err := as.questionRepo.GetQuestion(ctx, answerInfo.QuestionID)
_, exist, err := as.questionRepo.GetQuestion(ctx, answerInfo.QuestionID)
if err != nil {
return errors.BadRequest(reason.AnswerCannotDeleted)
}
if !exist {
return errors.BadRequest(reason.AnswerCannotDeleted)
}
if questionInfo.AnswerCount > 1 {
return errors.BadRequest(reason.AnswerCannotDeleted)
}
if questionInfo.AcceptedAnswerID != "" {
return errors.BadRequest(reason.AnswerCannotDeleted)
}
}
// user add question count
@ -164,7 +159,7 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) (
if err != nil {
log.Error("UpdateLastAnswer error", err.Error())
}
err = as.questionCommon.UpdataPostTime(ctx, req.QuestionID)
err = as.questionCommon.UpdatePostTime(ctx, req.QuestionID)
if err != nil {
return insertData.ID, err
}
@ -232,6 +227,11 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq
return "", nil
}
if answerInfo.Status == entity.AnswerStatusDeleted {
err = errors.BadRequest(reason.AnswerCannotUpdate)
return "", err
}
//If the content is the same, ignore it
if answerInfo.OriginalText == req.Content {
return "", nil
@ -268,7 +268,7 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq
if err = as.answerRepo.UpdateAnswer(ctx, insertData, []string{"original_text", "parsed_text", "updated_at", "last_edit_user_id"}); err != nil {
return "", err
}
err = as.questionCommon.UpdataPostTime(ctx, req.QuestionID)
err = as.questionCommon.UpdatePostTime(ctx, req.QuestionID)
if err != nil {
return insertData.ID, err
}
@ -473,6 +473,8 @@ func (as *AnswerService) SearchList(ctx context.Context, req *schema.AnswerListR
dbSearch.Page = req.Page
dbSearch.PageSize = req.PageSize
dbSearch.Order = req.Order
dbSearch.IncludeDeleted = req.CanDelete
dbSearch.LoginUserID = req.UserID
answerOriginalList, count, err := as.answerRepo.SearchList(ctx, &dbSearch)
if err != nil {
return list, count, err

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -114,19 +114,8 @@ export function htmlRender(el: HTMLElement | null) {
},
);
el.querySelectorAll('table').forEach((table) => {
if (
(table.parentNode as HTMLDivElement)?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
// remove change table style to htmlToReact function
/**
* @description: You modify the DOM with other scripts after React has rendered the DOM. This way, on the next render cycle (re-render), React cannot find the DOM node it rendered before, because it has been modified or removed by other scripts.
*/
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type { FormDataType } from '@/common/interface';
import Pattern from '@/common/pattern';
import Progress from '../Progress';
interface Props {
@ -54,8 +55,7 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
};
}
const mailReg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/;
if (contact_email.value && !contact_email.value.match(mailReg)) {
if (contact_email.value && !Pattern.email.test(contact_email.value)) {
bol = false;
data.contact_email = {
value: contact_email.value,
@ -91,7 +91,7 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
};
}
if (email.value && !email.value.match(mailReg)) {
if (email.value && !Pattern.email.test(email.value)) {
bol = false;
data.email = {
value: email.value,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -237,7 +237,26 @@ function htmlToReact(html: string) {
const cleanedHtml = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
return parse(cleanedHtml);
const ele = document.createElement('div');
ele.innerHTML = cleanedHtml;
ele.querySelectorAll('table').forEach((table) => {
if (
(!table || (table.parentNode as HTMLDivElement))?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
return parse(ele.innerHTML);
}
export {

View File

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

View File

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

View File

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

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

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

View File

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

View File

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