diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml index bfb8cd99c..3b27f8249 100644 --- a/.github/ISSUE_TEMPLATE/config.yaml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + blank_issues_enabled: true contact_links: - name: Support diff --git a/.github/workflows/uffizzi-build.yml b/.github/workflows/uffizzi-build.yml deleted file mode 100644 index be4ec9513..000000000 --- a/.github/workflows/uffizzi-build.yml +++ /dev/null @@ -1,115 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -name: Build PR Image -on: - pull_request: - types: [opened, synchronize, reopened, closed] - -jobs: - build-answer: - name: Build and push `Answer` - runs-on: ubuntu-latest - outputs: - tags: ${{ steps.meta.outputs.tags }} - if: ${{ github.event.action != 'closed' && github.repository_owner == 'apache' }} - steps: - - name: Checkout git repo - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Generate UUID image name - id: uuid - run: echo "UUID_WORKER=answer-$(uuidgen --time)" >> $GITHUB_ENV - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v4 - with: - images: registry.uffizzi.com/${{ env.UUID_WORKER }} - tags: | - type=raw,value=30d - - - name: Build and Push Image to registry.uffizzi.com - Uffizzi's ephemeral Registry - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - push: true - cache-from: type=gha - cache-to: type=gha, mode=max - - render-compose-file: - name: Render Docker Compose File - # Pass output of this workflow to another triggered by `workflow_run` event. - runs-on: ubuntu-latest - needs: - - build-answer - outputs: - compose-file-cache-key: ${{ steps.hash.outputs.hash }} - steps: - - name: Checkout git repo - uses: actions/checkout@v4 - - name: Render Compose File - run: | - ANSWER_IMAGE=${{ needs.build-answer.outputs.tags }} - export ANSWER_IMAGE - export UFFIZZI_URL=\$UFFIZZI_URL - # Render simple template from environment variables. - envsubst < docker-compose.uffizzi.yml > docker-compose.rendered.yml - cat docker-compose.rendered.yml - - name: Upload Rendered Compose File as Artifact - uses: actions/upload-artifact@v3 - with: - name: preview-spec - path: docker-compose.rendered.yml - retention-days: 2 - - name: Serialize PR Event to File - run: | - cat << EOF > event.json - ${{ toJSON(github.event) }} - - EOF - - name: Upload PR Event as Artifact - uses: actions/upload-artifact@v3 - with: - name: preview-spec - path: event.json - retention-days: 2 - - delete-preview: - name: Call for Preview Deletion - runs-on: ubuntu-latest - if: ${{ github.event.action == 'closed' && github.repository_owner == 'apache' }} - steps: - # If this PR is closing, we will not render a compose file nor pass it to the next workflow. - - name: Serialize PR Event to File - run: | - cat << EOF > event.json - ${{ toJSON(github.event) }} - - EOF - - name: Upload PR Event as Artifact - uses: actions/upload-artifact@v3 - with: - name: preview-spec - path: event.json - retention-days: 2 diff --git a/.github/workflows/uffizzi-preview.yml b/.github/workflows/uffizzi-preview.yml deleted file mode 100644 index c7a38f33f..000000000 --- a/.github/workflows/uffizzi-preview.yml +++ /dev/null @@ -1,113 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -name: Deploy Uffizzi Preview - -on: - workflow_run: - workflows: - - "Build PR Image" - types: - - completed - -jobs: - cache-compose-file: - name: Cache Compose File - runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} - outputs: - compose-file-cache-key: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }} - git-ref: ${{ steps.event.outputs.GIT_REF }} - pr-number: ${{ steps.event.outputs.PR_NUMBER }} - action: ${{ steps.event.outputs.ACTION }} - steps: - - name: Download artifacts - # Fetch output (zip archive) from the workflow run that triggered this workflow. - uses: actions/github-script@v6 - with: - script: | - let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { - return artifact.name == "preview-spec" - })[0]; - if (matchArtifact === undefined) { - throw TypeError('Build Artifact not found!'); - } - let download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: matchArtifact.id, - archive_format: 'zip', - }); - let fs = require('fs'); - fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data)); - - - name: 'Accept event from first stage' - run: unzip preview-spec.zip event.json - - - name: Read Event into ENV - id: event - run: | - echo PR_NUMBER=$(jq '.number | tonumber' < event.json) >> $GITHUB_OUTPUT - echo ACTION=$(jq --raw-output '.action | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT - echo GIT_REF=$(jq --raw-output '.pull_request.head.sha | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT - - - name: Hash Rendered Compose File - id: hash - # If the previous workflow was triggered by a PR close event, we will not have a compose file artifact. - if: ${{ steps.event.outputs.ACTION != 'closed' }} - run: | - unzip preview-spec.zip docker-compose.rendered.yml - echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_OUTPUT - - - name: Cache Rendered Compose File - if: ${{ steps.event.outputs.ACTION != 'closed' }} - uses: actions/cache@v3 - with: - path: docker-compose.rendered.yml - key: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }} - - - name: DEBUG - Print Job Outputs - if: ${{ runner.debug }} - run: | - echo "PR number: ${{ steps.event.outputs.PR_NUMBER }}" - echo "Git Ref: ${{ steps.event.outputs.GIT_REF }}" - echo "Action: ${{ steps.event.outputs.ACTION }}" - echo "Compose file hash: ${{ steps.hash.outputs.COMPOSE_FILE_HASH }}" - cat event.json - - deploy-uffizzi-preview: - name: Use Remote Workflow to Preview on Uffizzi - needs: - - cache-compose-file - if: ${{ github.event.workflow_run.conclusion == 'success' }} - uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2 - with: - # If this workflow was triggered by a PR close event, cache-key will be an empty string - # and this reusable workflow will delete the preview deployment. - compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }} - compose-file-cache-path: docker-compose.rendered.yml - server: https://app.uffizzi.com - pr-number: ${{ needs.cache-compose-file.outputs.pr-number }} - permissions: - contents: read - pull-requests: write - id-token: write diff --git a/Dockerfile b/Dockerfile index 38ca086c3..6278e1a57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,9 @@ RUN mkdir -p /data/uploads && chmod 777 /data/uploads \ FROM alpine LABEL maintainer="linkinstar@apache.org" -ENV TZ "Asia/Shanghai" +ARG TIMEZONE +ENV TIMEZONE=${TIMEZONE:-"Asia/Shanghai"} + RUN apk update \ && apk --no-cache add \ bash \ @@ -58,7 +60,9 @@ RUN apk update \ openssh \ sqlite \ gnupg \ - && echo "Asia/Shanghai" > /etc/timezone + tzdata \ + && ln -sf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime \ + && echo "${TIMEZONE}" > /etc/timezone COPY --from=golang-builder /usr/bin/answer /usr/bin/answer COPY --from=golang-builder /data /data diff --git a/Makefile b/Makefile index 6750d95d2..637257f6e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build clean ui -VERSION=1.2.1 +VERSION=1.2.5 BIN=answer DIR_SRC=./cmd/answer DOCKER_CMD=docker diff --git a/README.md b/README.md index 4d5a65964..c0509510e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache ### Running with docker ```bash -docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.2.1 +docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.2.5 ``` For more information, see [Installation](https://answer.apache.org/docs/installation). @@ -43,11 +43,17 @@ You can also check out the [plugins here](https://answer.apache.org/plugins). - Golang >= 1.18 - Node.js >= 16.17 - pnpm >= 8 +- mockgen >= 1.6.0 +- wire >= 0.5.0 ### Build ```bash +# install wire and mockgen for building +$ make generate +# install frontend dependencies and build $ make ui +# install backend dependencies and build $ make build ``` diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index a057660cd..199c2ce9f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -201,8 +201,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, collectionController := controller.NewCollectionController(collectionService) answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, notificationQueueService) answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) - externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService) - questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, userRepo, revisionService, metaService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService) + externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService, userExternalLoginRepo, siteInfoCommonService) + questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService) answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService) questionController := controller.NewQuestionController(questionService, answerService, rankService, siteInfoCommonService, captchaService, rateLimitMiddleware) answerController := controller.NewAnswerController(answerService, rankService, captchaService, siteInfoCommonService, rateLimitMiddleware) @@ -227,7 +227,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, siteInfoController := controller_admin.NewSiteInfoController(siteInfoService) controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService) notificationRepo := notification2.NewNotificationRepo(dataData) - notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, notificationQueueService) + notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, notificationQueueService, userExternalLoginRepo, siteInfoCommonService) notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo) notificationController := controller.NewNotificationController(notificationService, rankService) dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configService, siteInfoCommonService, serviceConf, dataData) @@ -241,10 +241,12 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, activityController := controller.NewActivityController(activityService) roleController := controller_admin.NewRoleController(roleService) pluginConfigRepo := plugin_config.NewPluginConfigRepo(dataData) - pluginCommonService := plugin_common.NewPluginCommonService(pluginConfigRepo, configService, dataData) + pluginUserConfigRepo := plugin_config.NewPluginUserConfigRepo(dataData) + pluginCommonService := plugin_common.NewPluginCommonService(pluginConfigRepo, pluginUserConfigRepo, configService, dataData) pluginController := controller_admin.NewPluginController(pluginCommonService) permissionController := controller.NewPermissionController(rankService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_adminReportController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController) + userPluginController := controller.NewUserPluginController(pluginCommonService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_adminReportController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) diff --git a/docker-compose.uffizzi.yml b/docker-compose.uffizzi.yml deleted file mode 100644 index 74d75bb72..000000000 --- a/docker-compose.uffizzi.yml +++ /dev/null @@ -1,77 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -version: "3.9" - -# uffizzi integration -x-uffizzi: - ingress: - service: answer - port: 80 - -services: - - answer: - image: "${ANSWER_IMAGE}" - environment: - - AUTO_INSTALL=true - - DB_TYPE=mysql - - DB_USERNAME=root - - DB_PASSWORD=password - - DB_HOST=127.0.0.1:3306 - - DB_NAME=answer - - LANGUAGE=en_US - - SITE_NAME=answer-test - - SITE_URL=http://localhost - - CONTACT_EMAIL=test@answer.com - - ADMIN_NAME=admin123 - - ADMIN_PASSWORD=admin123 - - ADMIN_EMAIL=admin123@admin.com - volumes: - - answer-data:/data - depends_on: - mysql: - condition: service_healthy - links: - - db - deploy: - resources: - limits: - memory: 4000M - - mysql: - image: mysql:latest - environment: - - MYSQL_DATABASE=answer - - MYSQL_ROOT_PASSWORD=password - - MYSQL_USER=mysql - - MYSQL_PASSWORD=mysql - healthcheck: - test: [ "CMD", "mysqladmin" ,"ping", "-uroot", "-ppassword" ] - timeout: 20s - retries: 10 - command: ['mysqld', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci', '--skip-character-set-client-handshake'] - volumes: - - sql_data:/var/lib/mysql - deploy: - resources: - limits: - memory: 500M - -volumes: - answer-data: - sql_data: diff --git a/docs/docs.go b/docs/docs.go index 7a6d08bd2..dc56880a8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -5895,6 +5895,132 @@ const docTemplate = `{ } } }, + "/answer/api/v1/user/plugin/config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user plugin config", + "produces": [ + "application/json" + ], + "tags": [ + "UserPlugin" + ], + "summary": "get user plugin config", + "parameters": [ + { + "type": "string", + "description": "plugin_slug_name", + "name": "plugin_slug_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPluginConfigResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update user plugin config", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserPlugin" + ], + "summary": "update user plugin config", + "parameters": [ + { + "description": "UpdatePluginConfigReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateUserPluginConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/api/v1/user/plugin/configs": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin list that used for user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserPlugin" + ], + "summary": "get plugin list that used for user.", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserPluginListResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/user/ranking": { "get": { "security": [ @@ -7822,22 +7948,13 @@ const docTemplate = `{ "type": "object", "properties": { "all_new_question": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "all_new_question_for_following_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "inbox": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" } } }, @@ -7894,6 +8011,17 @@ const docTemplate = `{ } } }, + "schema.GetUserPluginListResp": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.GetVoteWithPageResp": { "type": "object", "properties": { @@ -9428,22 +9556,13 @@ const docTemplate = `{ "type": "object", "properties": { "all_new_question": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "all_new_question_for_following_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "inbox": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" } } }, @@ -9464,6 +9583,22 @@ const docTemplate = `{ } } }, + "schema.UpdateUserPluginConfigReq": { + "type": "object", + "required": [ + "plugin_slug_name" + ], + "properties": { + "config_fields": { + "type": "object", + "additionalProperties": {} + }, + "plugin_slug_name": { + "type": "string", + "maxLength": 100 + } + } + }, "schema.UpdateUserRoleReq": { "type": "object", "required": [ diff --git a/docs/img/logo.svg b/docs/img/logo.svg index b30be2de7..f07ef0408 100644 --- a/docs/img/logo.svg +++ b/docs/img/logo.svg @@ -1,3 +1,21 @@ + diff --git a/docs/swagger.json b/docs/swagger.json index 77a1ada30..83bde2211 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -5864,6 +5864,132 @@ } } }, + "/answer/api/v1/user/plugin/config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user plugin config", + "produces": [ + "application/json" + ], + "tags": [ + "UserPlugin" + ], + "summary": "get user plugin config", + "parameters": [ + { + "type": "string", + "description": "plugin_slug_name", + "name": "plugin_slug_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPluginConfigResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update user plugin config", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserPlugin" + ], + "summary": "update user plugin config", + "parameters": [ + { + "description": "UpdatePluginConfigReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateUserPluginConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/api/v1/user/plugin/configs": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin list that used for user.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserPlugin" + ], + "summary": "get plugin list that used for user.", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserPluginListResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/user/ranking": { "get": { "security": [ @@ -7791,22 +7917,13 @@ "type": "object", "properties": { "all_new_question": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "all_new_question_for_following_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "inbox": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" } } }, @@ -7863,6 +7980,17 @@ } } }, + "schema.GetUserPluginListResp": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.GetVoteWithPageResp": { "type": "object", "properties": { @@ -9397,22 +9525,13 @@ "type": "object", "properties": { "all_new_question": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "all_new_question_for_following_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" }, "inbox": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.NotificationChannelConfig" - } + "$ref": "#/definitions/schema.NotificationChannelConfig" } } }, @@ -9433,6 +9552,22 @@ } } }, + "schema.UpdateUserPluginConfigReq": { + "type": "object", + "required": [ + "plugin_slug_name" + ], + "properties": { + "config_fields": { + "type": "object", + "additionalProperties": {} + }, + "plugin_slug_name": { + "type": "string", + "maxLength": 100 + } + } + }, "schema.UpdateUserRoleReq": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f3f53a76b..ca78bc499 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1008,17 +1008,11 @@ definitions: schema.GetUserNotificationConfigResp: properties: all_new_question: - items: - $ref: '#/definitions/schema.NotificationChannelConfig' - type: array + $ref: '#/definitions/schema.NotificationChannelConfig' all_new_question_for_following_tags: - items: - $ref: '#/definitions/schema.NotificationChannelConfig' - type: array + $ref: '#/definitions/schema.NotificationChannelConfig' inbox: - items: - $ref: '#/definitions/schema.NotificationChannelConfig' - type: array + $ref: '#/definitions/schema.NotificationChannelConfig' type: object schema.GetUserPageResp: properties: @@ -1059,6 +1053,13 @@ definitions: description: username type: string type: object + schema.GetUserPluginListResp: + properties: + name: + type: string + slug_name: + type: string + type: object schema.GetVoteWithPageResp: properties: answer_id: @@ -2116,17 +2117,11 @@ definitions: schema.UpdateUserNotificationConfigReq: properties: all_new_question: - items: - $ref: '#/definitions/schema.NotificationChannelConfig' - type: array + $ref: '#/definitions/schema.NotificationChannelConfig' all_new_question_for_following_tags: - items: - $ref: '#/definitions/schema.NotificationChannelConfig' - type: array + $ref: '#/definitions/schema.NotificationChannelConfig' inbox: - items: - $ref: '#/definitions/schema.NotificationChannelConfig' - type: array + $ref: '#/definitions/schema.NotificationChannelConfig' type: object schema.UpdateUserPasswordReq: properties: @@ -2140,6 +2135,17 @@ definitions: - password - user_id type: object + schema.UpdateUserPluginConfigReq: + properties: + config_fields: + additionalProperties: {} + type: object + plugin_slug_name: + maxLength: 100 + type: string + required: + - plugin_slug_name + type: object schema.UpdateUserRoleReq: properties: role_id: @@ -6010,6 +6016,79 @@ paths: summary: RetrievePassWord tags: - User + /answer/api/v1/user/plugin/config: + get: + description: get user plugin config + parameters: + - description: plugin_slug_name + in: query + name: plugin_slug_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetPluginConfigResp' + type: object + security: + - ApiKeyAuth: [] + summary: get user plugin config + tags: + - UserPlugin + put: + consumes: + - application/json + description: update user plugin config + parameters: + - description: UpdatePluginConfigReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdateUserPluginConfigReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update user plugin config + tags: + - UserPlugin + /answer/api/v1/user/plugin/configs: + get: + consumes: + - application/json + description: get plugin list that used for user. + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetUserPluginListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get plugin list that used for user. + tags: + - UserPlugin /answer/api/v1/user/ranking: get: consumes: diff --git a/go.mod b/go.mod index 1a3b49f67..f4ab56992 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + module github.com/apache/incubator-answer go 1.18 diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index e99d99bc0..e155edffa 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -83,6 +83,9 @@ backend: level_3: description: other: Level 3 (high reputation required for mature community) + level_custom: + description: + other: Custom Level rank_question_add_label: other: Ask question rank_answer_add_label: @@ -695,6 +698,13 @@ ui: character: URL slug contains unallowed character set. desc: label: Description + revision: + label: Revision + edit_summary: + label: Edit summary + placeholder: >- + Briefly explain your changes (corrected spelling, fixed grammar, + improved formatting) btn_cancel: Cancel btn_submit: Submit btn_post: Post new tag @@ -724,22 +734,6 @@ ui: title: Edit Tag default_reason: Edit tag default_first_reason: Add tag - form: - fields: - revision: - label: Revision - display_name: - label: Display name - slug_name: - label: URL slug - info: URL slug up to 35 characters. - desc: - label: Description - edit_summary: - label: Edit summary - placeholder: >- - Briefly explain your changes (corrected spelling, fixed grammar, - improved formatting) btn_save_edits: Save edits btn_cancel: Cancel dates: @@ -763,7 +757,7 @@ ui: btn_flag: Flag btn_save_edits: Save edits btn_cancel: Cancel - show_more: Show more comments + show_more: "{{count}} more comments" tip_question: >- Use comments to ask for more information or suggest improvements. Avoid answering questions in comments. @@ -999,10 +993,10 @@ ui: label: Location placeholder: "City, Country" notification: - heading: Notifications - email: Email + heading: Email Notifications + turn_on: Turn on inbox: - label: Email notifications + label: Inbox notifications description: Answers to your questions, comments, invites, and more. all_new_question: label: All new questions @@ -1058,7 +1052,7 @@ ui: answers: answers invite_to_answer: title: People Asked - desc: Invite people who you think might know the answer. + desc: Select people who you think might know the answer. invite: Invite to answer add: Add people search: Search people @@ -1159,6 +1153,11 @@ ui: close: Close reopen: Reopen ok: OK + light: Light + dark: Dark + system_setting: System setting + default: Default + reset: Reset search: title: Search Results keywords: Keywords @@ -1687,11 +1686,12 @@ ui: themes: label: Themes text: Select an existing theme. + color_scheme: + label: Color scheme navbar_style: - label: Navbar Style - text: Select an existing theme. + label: Navbar style primary_color: - label: Primary Color + label: Primary color text: Modify the colors used by your themes css_and_html: page_title: CSS and HTML diff --git a/i18n/fr_FR.yaml b/i18n/fr_FR.yaml index 0e8f3ba5c..35b0de61c 100644 --- a/i18n/fr_FR.yaml +++ b/i18n/fr_FR.yaml @@ -746,7 +746,7 @@ ui: btn_flag: Balise btn_save_edits: Enregistrer les modifications btn_cancel: Annuler - show_more: Afficher plus de commentaires + show_more: "{{count}} commentaires restants" tip_question: >- Utilisez les commentaires pour demander plus d'informations ou suggérer des améliorations. Évitez de répondre aux questions dans les commentaires. tip_answer: >- diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index a5a542890..a73c93f42 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -681,6 +681,12 @@ ui: character: URL 固定链接包含非法字符。 desc: label: 描述 + revision: + label: 编辑历史 + edit_summary: + label: 编辑摘要 + placeholder: >- + 简单描述更改原因 (错别字、文字表达、格式等等) btn_cancel: 取消 btn_submit: 提交 btn_post: 发布新标签 @@ -707,22 +713,7 @@ ui: edit_tag: title: 编辑标签 default_reason: 编辑标签 - default_first_reason: Add tag - form: - fields: - revision: - label: 编辑历史 - display_name: - label: 显示名称 - slug_name: - label: URL 固定链接 - info: URL 固定链接不能超过 35 个字符。 - desc: - label: 描述 - edit_summary: - label: 编辑摘要 - placeholder: >- - 简单描述更改原因 (错别字、文字表达、格式等等) + default_first_reason: 添加标签 btn_save_edits: 保存更改 btn_cancel: 取消 dates: @@ -746,7 +737,7 @@ ui: btn_flag: 举报 btn_save_edits: 保存 btn_cancel: 取消 - show_more: 显示更多评论 + show_more: "{{count}}条剩余评论" tip_question: >- 使用评论提问更多信息或者提出改进意见。尽量避免使用评论功能回答问题。 tip_answer: >- diff --git a/i18n/zh_TW.yaml b/i18n/zh_TW.yaml index 3455642ac..8c6ddf7a8 100644 --- a/i18n/zh_TW.yaml +++ b/i18n/zh_TW.yaml @@ -746,7 +746,7 @@ ui: btn_flag: 舉報 btn_save_edits: 保存 btn_cancel: 取消 - show_more: 顯示更多評論 + show_more: "{{count}}條剩餘評論" tip_question: >- 通过評論询问更多问题或提出改進建議。避免在評論中回答問題。 tip_answer: >- diff --git a/internal/base/constant/privilege.go b/internal/base/constant/privilege.go index 018247744..a0fcc6748 100644 --- a/internal/base/constant/privilege.go +++ b/internal/base/constant/privilege.go @@ -24,7 +24,7 @@ import "github.com/apache/incubator-answer/internal/base/reason" type Privilege struct { Key string `json:"key"` Label string `json:"label"` - Value int `json:"value"` + Value int `validate:"gte=1" json:"value"` } const ( diff --git a/internal/base/constant/site_info.go b/internal/base/constant/site_info.go index 94c39f746..de6f4183d 100644 --- a/internal/base/constant/site_info.go +++ b/internal/base/constant/site_info.go @@ -37,3 +37,10 @@ const ( // PermalinkQuestionIDByShortID /questions/11 PermalinkQuestionIDByShortID ) + +const ( + ColorSchemeDefault = "default" + ColorSchemeLight = "light" + ColorSchemeDark = "dark" + ColorSchemeSystem = "system" +) diff --git a/internal/base/middleware/auth.go b/internal/base/middleware/auth.go index 0fdc2c42e..44fd20a2c 100644 --- a/internal/base/middleware/auth.go +++ b/internal/base/middleware/auth.go @@ -89,18 +89,51 @@ func (am *AuthUserMiddleware) EjectUserBySiteInfo() gin.HandlerFunc { return } - _, isLogin := ctx.Get(ctxUUIDKey) - if !isLogin { + // If site in private mode, user must login. + userInfo := GetUserInfoFromContext(ctx) + if userInfo == nil { handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) ctx.Abort() return } + // If user is not active, eject user. + if userInfo.EmailStatus != entity.EmailStatusAvailable { + handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified), + &schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive}) + ctx.Abort() + return + } + ctx.Next() + } +} + +// MustAuthWithoutAccountAvailable auth user info, any login user can access though user is not active. +func (am *AuthUserMiddleware) MustAuthWithoutAccountAvailable() gin.HandlerFunc { + return func(ctx *gin.Context) { + token := ExtractToken(ctx) + if len(token) == 0 { + handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) + ctx.Abort() + return + } + userInfo, err := am.authService.GetUserCacheInfo(ctx, token) + if err != nil || userInfo == nil { + handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) + ctx.Abort() + return + } + if userInfo.UserStatus == entity.UserStatusDeleted { + handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) + ctx.Abort() + return + } + ctx.Set(ctxUUIDKey, userInfo) ctx.Next() } } -// MustAuth auth user info. If the user does not log in, an unauthenticated error is displayed -func (am *AuthUserMiddleware) MustAuth() gin.HandlerFunc { +// MustAuthAndAccountAvailable auth user info and check user status, only allow active user access. +func (am *AuthUserMiddleware) MustAuthAndAccountAvailable() gin.HandlerFunc { return func(ctx *gin.Context) { token := ExtractToken(ctx) if len(token) == 0 { diff --git a/internal/base/reason/privilege.go b/internal/base/reason/privilege.go index e98058c86..1186a2c31 100644 --- a/internal/base/reason/privilege.go +++ b/internal/base/reason/privilege.go @@ -20,9 +20,10 @@ package reason const ( - PrivilegeLevel1Desc = "privilege.level_1.description" - PrivilegeLevel2Desc = "privilege.level_2.description" - PrivilegeLevel3Desc = "privilege.level_3.description" + PrivilegeLevel1Desc = "privilege.level_1.description" + PrivilegeLevel2Desc = "privilege.level_2.description" + PrivilegeLevel3Desc = "privilege.level_3.description" + PrivilegeLevelCustomDesc = "privilege.level_custom.description" RankQuestionAddLabel = "privilege.rank_question_add_label" RankAnswerAddLabel = "privilege.rank_answer_add_label" diff --git a/internal/base/server/http.go b/internal/base/server/http.go index 6e0b6727d..daaf90fbd 100644 --- a/internal/base/server/http.go +++ b/internal/base/server/http.go @@ -74,9 +74,14 @@ func NewHTTPServer(debug bool, unAuthV1.Use(authUserMiddleware.Auth(), authUserMiddleware.EjectUserBySiteInfo()) answerRouter.RegisterUnAuthAnswerAPIRouter(unAuthV1) + // register api that must be authenticated but no need to check account status + authWithoutStatusV1 := r.Group("/answer/api/v1") + authWithoutStatusV1.Use(authUserMiddleware.MustAuthWithoutAccountAvailable()) + answerRouter.RegisterAuthUserWithAnyStatusAnswerAPIRouter(authWithoutStatusV1) + // register api that must be authenticated authV1 := r.Group("/answer/api/v1") - authV1.Use(authUserMiddleware.MustAuth()) + authV1.Use(authUserMiddleware.MustAuthAndAccountAvailable()) answerRouter.RegisterAnswerAPIRouter(authV1) adminauthV1 := r.Group("/answer/admin/api") diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 4e6f2564f..0dd8e3ef5 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -46,4 +46,5 @@ var ProviderSetController = wire.NewSet( NewConnectorController, NewUserCenterController, NewPermissionController, + NewUserPluginController, ) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 88e8ca0a3..6b4c4bbe8 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -22,6 +22,7 @@ package controller import ( "encoding/json" "fmt" + "github.com/apache/incubator-answer/internal/entity" "html/template" "net/http" "regexp" @@ -376,6 +377,7 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) { "detail": detail, "answers": answers, "comments": comments, + "noindex": detail.Show == entity.QuestionHide, }) } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index c9149ca22..4ef50e9e8 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -384,7 +384,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) { req.AccessToken = middleware.ExtractToken(ctx) isAdmin := middleware.GetUserIsAdminModerator(ctx) if !isAdmin { - captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionPassword, req.UserID, + captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEditUserinfo, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{ @@ -394,7 +394,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) { handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields) return } - _, err := uc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionPassword, req.UserID) + _, err := uc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEditUserinfo, req.UserID) if err != nil { log.Error(err) } @@ -424,7 +424,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) { } err = uc.userService.UserModifyPassword(ctx, req) if err == nil { - uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionPassword, req.UserID) + uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionEditUserinfo, req.UserID) } handler.HandleResponse(ctx, err, nil) } diff --git a/internal/controller/user_plugin_controller.go b/internal/controller/user_plugin_controller.go new file mode 100644 index 000000000..f55dc73bc --- /dev/null +++ b/internal/controller/user_plugin_controller.go @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "encoding/json" + "github.com/apache/incubator-answer/internal/base/middleware" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/segmentfault/pacman/errors" + "net/http" + + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/plugin_common" + "github.com/apache/incubator-answer/plugin" + "github.com/gin-gonic/gin" +) + +// UserPluginController role controller +type UserPluginController struct { + pluginCommonService *plugin_common.PluginCommonService +} + +// NewUserPluginController new controller +func NewUserPluginController(pluginCommonService *plugin_common.PluginCommonService) *UserPluginController { + return &UserPluginController{pluginCommonService: pluginCommonService} +} + +// GetUserPluginList get plugin list that used for user. +// @Summary get plugin list that used for user. +// @Description get plugin list that used for user. +// @Tags UserPlugin +// @Security ApiKeyAuth +// @Accept json +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetUserPluginListResp} +// @Router /answer/api/v1/user/plugin/configs [get] +func (pc *UserPluginController) GetUserPluginList(ctx *gin.Context) { + resp := make([]*schema.GetUserPluginListResp, 0) + _ = plugin.CallUserConfig(func(base plugin.UserConfig) error { + info := base.Info() + if plugin.StatusManager.IsEnabled(info.SlugName) { + resp = append(resp, &schema.GetUserPluginListResp{ + Name: info.Name.Translate(ctx), + SlugName: info.SlugName, + }) + } + return nil + }) + handler.HandleResponse(ctx, nil, resp) +} + +// GetUserPluginConfig get user plugin config +// @Summary get user plugin config +// @Description get user plugin config +// @Tags UserPlugin +// @Security ApiKeyAuth +// @Produce json +// @Param plugin_slug_name query string true "plugin_slug_name" +// @Success 200 {object} handler.RespBody{data=schema.GetPluginConfigResp} +// @Router /answer/api/v1/user/plugin/config [get] +func (pc *UserPluginController) GetUserPluginConfig(ctx *gin.Context) { + req := &schema.GetUserPluginConfigReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp := &schema.GetUserPluginConfigResp{} + _ = plugin.CallUserConfig(func(fn plugin.UserConfig) error { + if fn.Info().SlugName != req.PluginSlugName { + return nil + } + info := fn.Info() + resp.Name = info.Name.Translate(ctx) + resp.SlugName = info.SlugName + resp.SetConfigFields(ctx, fn.UserConfigFields()) + return nil + }) + + configValue, err := pc.pluginCommonService.GetUserPluginConfig(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + if len(configValue) > 0 { + configValueMapping := make(map[string]any) + _ = json.Unmarshal([]byte(configValue), &configValueMapping) + for _, field := range resp.ConfigFields { + if value, ok := configValueMapping[field.Name]; ok { + field.Value = value + } + } + } + + handler.HandleResponse(ctx, err, resp) +} + +// UpdatePluginUserConfig update user plugin config +// @Summary update user plugin config +// @Description update user plugin config +// @Tags UserPlugin +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.UpdateUserPluginConfigReq true "UpdatePluginConfigReq" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/user/plugin/config [put] +func (pc *UserPluginController) UpdatePluginUserConfig(ctx *gin.Context) { + req := &schema.UpdateUserPluginConfigReq{} + if handler.BindAndCheck(ctx, req) { + return + } + if !plugin.StatusManager.IsEnabled(req.PluginSlugName) { + handler.HandleResponse(ctx, errors.New(http.StatusBadRequest, reason.RequestFormatError), nil) + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + configFields, _ := json.Marshal(req.ConfigFields) + err := plugin.CallUserConfig(func(fn plugin.UserConfig) error { + if fn.Info().SlugName == req.PluginSlugName { + return fn.UserConfigReceiver(req.UserID, configFields) + } + return nil + }) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + err = pc.pluginCommonService.UpdatePluginUserConfig(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/plugin_controller.go b/internal/controller_admin/plugin_controller.go index 769fc3bde..bfde841d0 100644 --- a/internal/controller_admin/plugin_controller.go +++ b/internal/controller_admin/plugin_controller.go @@ -31,12 +31,12 @@ import ( // PluginController role controller type PluginController struct { - PluginCommonService *plugin_common.PluginCommonService + pluginCommonService *plugin_common.PluginCommonService } // NewPluginController new controller -func NewPluginController(PluginCommonService *plugin_common.PluginCommonService) *PluginController { - return &PluginController{PluginCommonService: PluginCommonService} +func NewPluginController(pluginCommonService *plugin_common.PluginCommonService) *PluginController { + return &PluginController{pluginCommonService: pluginCommonService} } // GetAllPluginStatus get all plugins status @@ -150,7 +150,7 @@ func (pc *PluginController) UpdatePluginStatus(ctx *gin.Context) { } plugin.StatusManager.Enable(req.PluginSlugName, req.Enabled) - err := pc.PluginCommonService.UpdatePluginStatus(ctx) + err := pc.pluginCommonService.UpdatePluginStatus(ctx) handler.HandleResponse(ctx, err, nil) } @@ -220,6 +220,6 @@ func (pc *PluginController) UpdatePluginConfig(ctx *gin.Context) { return } - err = pc.PluginCommonService.UpdatePluginConfig(ctx, req) + err = pc.pluginCommonService.UpdatePluginConfig(ctx, req) handler.HandleResponse(ctx, err, nil) } diff --git a/internal/entity/plugin_user_config_entity.go b/internal/entity/plugin_user_config_entity.go new file mode 100644 index 000000000..edf93c9f3 --- /dev/null +++ b/internal/entity/plugin_user_config_entity.go @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +// PluginUserConfig plugin config +type PluginUserConfig struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + UserID string `xorm:"not null default 0 BIGINT(20) UNIQUE(uk_up) user_id"` + PluginSlugName string `xorm:"VARCHAR(128) UNIQUE(uk_up) plugin_slug_name"` + Value string `xorm:"TEXT value"` +} + +// TableName config table name +func (PluginUserConfig) TableName() string { + return "plugin_user_config" +} diff --git a/internal/entity/user_entity.go b/internal/entity/user_entity.go index 328218640..cfc2b6c29 100644 --- a/internal/entity/user_entity.go +++ b/internal/entity/user_entity.go @@ -65,6 +65,7 @@ type User struct { IPInfo string `xorm:"not null default '' VARCHAR(255) ip_info"` IsAdmin bool `xorm:"not null default false BOOL is_admin"` Language string `xorm:"not null default '' VARCHAR(100) language"` + ColorScheme string `xorm:"not null default '' VARCHAR(100) color_scheme"` } // TableName user table name diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go index b39ece432..494e0cf5b 100644 --- a/internal/migrations/init_data.go +++ b/internal/migrations/init_data.go @@ -67,6 +67,7 @@ var ( &entity.PluginConfig{}, &entity.UserExternalLogin{}, &entity.UserNotificationConfig{}, + &entity.PluginUserConfig{}, } roles = []*entity.Role{ diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 415153839..dbd3acdb8 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -94,6 +94,7 @@ var migrations = []Migration{ NewMigration("v1.1.3", "set default user notification config", setDefaultUserNotificationConfig, false), NewMigration("v1.2.0", "add recover answer permission", addRecoverPermission, true), NewMigration("v1.2.1", "add password login control", addPasswordLoginControl, true), + NewMigration("v1.2.5", "add notification plugin and theme config", addNotificationPluginAndThemeConfig, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v19.go b/internal/migrations/v19.go new file mode 100644 index 000000000..c4478a34e --- /dev/null +++ b/internal/migrations/v19.go @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "github.com/apache/incubator-answer/internal/entity" + "xorm.io/xorm" +) + +func addNotificationPluginAndThemeConfig(ctx context.Context, x *xorm.Engine) error { + type User struct { + ID string `xorm:"not null pk autoincr BIGINT(20) id"` + ColorScheme string `xorm:"not null default '' VARCHAR(100) color_scheme"` + } + return x.Context(ctx).Sync(new(entity.PluginUserConfig), new(User)) +} diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go index 3a71fd706..60a49fa44 100644 --- a/internal/repo/answer/answer_repo.go +++ b/internal/repo/answer/answer_repo.go @@ -416,7 +416,7 @@ func (ar *answerRepo) updateSearch(ctx context.Context, answerID string) (err er // get question var ( - question *entity.Question + question = new(entity.Question) ) exist, err = ar.data.DB.Context(ctx).Where("id = ?", answer.QuestionID).Get(&question) if err != nil { diff --git a/internal/repo/plugin_config/plugin_config_repo.go b/internal/repo/plugin_config/plugin_config_repo.go index ddcd3ed22..1ad2e4957 100644 --- a/internal/repo/plugin_config/plugin_config_repo.go +++ b/internal/repo/plugin_config/plugin_config_repo.go @@ -50,7 +50,7 @@ func (ur *pluginConfigRepo) SavePluginConfig(ctx context.Context, pluginSlugName old.Value = configValue _, err = ur.data.DB.Context(ctx).ID(old.ID).Update(old) } else { - _, err = ur.data.DB.Context(ctx).InsertOne(&entity.PluginConfig{PluginSlugName: pluginSlugName, Value: configValue}) + _, err = ur.data.DB.Context(ctx).Insert(&entity.PluginConfig{PluginSlugName: pluginSlugName, Value: configValue}) } if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() diff --git a/internal/repo/plugin_config/plugin_user_config_repo.go b/internal/repo/plugin_config/plugin_user_config_repo.go new file mode 100644 index 000000000..5b3b54dea --- /dev/null +++ b/internal/repo/plugin_config/plugin_user_config_repo.go @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package plugin_config + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/pager" + "xorm.io/xorm" + + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/plugin_common" + "github.com/segmentfault/pacman/errors" +) + +type pluginUserConfigRepo struct { + data *data.Data +} + +// NewPluginUserConfigRepo new repository +func NewPluginUserConfigRepo(data *data.Data) plugin_common.PluginUserConfigRepo { + return &pluginUserConfigRepo{ + data: data, + } +} + +func (ur *pluginUserConfigRepo) SaveUserPluginConfig(ctx context.Context, userID string, + pluginSlugName, configValue string) (err error) { + _, err = ur.data.DB.Transaction(func(session *xorm.Session) (interface{}, error) { + session = session.Context(ctx) + old := &entity.PluginUserConfig{ + UserID: userID, + PluginSlugName: pluginSlugName, + } + exist, err := session.Get(old) + if err != nil { + return nil, err + } + if exist { + old.Value = configValue + _, err = session.ID(old.ID).Update(old) + } else { + _, err = session.Insert(&entity.PluginUserConfig{ + UserID: userID, + PluginSlugName: pluginSlugName, + Value: configValue, + }) + } + if err != nil { + return nil, err + } + return nil, nil + }) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +func (ur *pluginUserConfigRepo) GetPluginUserConfig(ctx context.Context, userID, pluginSlugName string) ( + pluginUserConfig *entity.PluginUserConfig, exist bool, err error) { + pluginUserConfig = &entity.PluginUserConfig{ + UserID: userID, + PluginSlugName: pluginSlugName, + } + exist, err = ur.data.DB.Context(ctx).Get(pluginUserConfig) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return pluginUserConfig, exist, err +} + +func (ur *pluginUserConfigRepo) GetPluginUserConfigPage(ctx context.Context, page, pageSize int) ( + pluginUserConfigs []*entity.PluginUserConfig, total int64, err error) { + pluginUserConfigs = make([]*entity.PluginUserConfig, 0) + total, err = pager.Help(page, pageSize, &pluginUserConfigs, &entity.PluginUserConfig{}, ur.data.DB.Context(ctx)) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 0814487e3..931ccde19 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -96,4 +96,5 @@ var ProviderSetRepo = wire.NewSet( plugin_config.NewPluginConfigRepo, user_notification_config.NewUserNotificationConfigRepo, limit.NewRateLimitRepo, + plugin_config.NewPluginUserConfigRepo, ) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 2cd9a3258..6c97620a0 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -23,18 +23,13 @@ import ( "context" "encoding/json" "fmt" - "github.com/apache/incubator-answer/plugin" - "github.com/segmentfault/pacman/log" "strings" "time" "unicode" - "xorm.io/xorm" - - "github.com/apache/incubator-answer/internal/base/handler" - "xorm.io/builder" "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/handler" "github.com/apache/incubator-answer/internal/base/pager" "github.com/apache/incubator-answer/internal/base/reason" "github.com/apache/incubator-answer/internal/entity" @@ -43,8 +38,11 @@ import ( "github.com/apache/incubator-answer/internal/service/unique" "github.com/apache/incubator-answer/pkg/htmltext" "github.com/apache/incubator-answer/pkg/uid" - + "github.com/apache/incubator-answer/plugin" "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "xorm.io/builder" + "xorm.io/xorm" ) // questionRepo question repository @@ -77,7 +75,6 @@ func (qr *questionRepo) AddQuestion(ctx context.Context, question *entity.Questi if handler.GetEnableShortID(ctx) { question.ID = uid.EnShortID(question.ID) } - _ = qr.updateSearch(ctx, question.ID) return } @@ -101,7 +98,7 @@ func (qr *questionRepo) UpdateQuestion(ctx context.Context, question *entity.Que if handler.GetEnableShortID(ctx) { question.ID = uid.EnShortID(question.ID) } - _ = qr.updateSearch(ctx, question.ID) + _ = qr.UpdateSearch(ctx, question.ID) return } @@ -112,7 +109,7 @@ func (qr *questionRepo) UpdatePvCount(ctx context.Context, questionID string) (e if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, question.ID) + _ = qr.UpdateSearch(ctx, question.ID) return nil } @@ -124,7 +121,7 @@ func (qr *questionRepo) UpdateAnswerCount(ctx context.Context, questionID string if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, question.ID) + _ = qr.UpdateSearch(ctx, question.ID) return nil } @@ -156,7 +153,7 @@ func (qr *questionRepo) UpdateQuestionStatus(ctx context.Context, questionID str if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, questionID) + _ = qr.UpdateSearch(ctx, questionID) return nil } @@ -166,7 +163,7 @@ func (qr *questionRepo) UpdateQuestionStatusWithOutUpdateTime(ctx context.Contex if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, question.ID) + _ = qr.UpdateSearch(ctx, question.ID) return nil } @@ -176,7 +173,7 @@ func (qr *questionRepo) RecoverQuestion(ctx context.Context, questionID string) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, questionID) + _ = qr.UpdateSearch(ctx, questionID) return nil } @@ -195,7 +192,7 @@ func (qr *questionRepo) UpdateAccepted(ctx context.Context, question *entity.Que if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, question.ID) + _ = qr.UpdateSearch(ctx, question.ID) return nil } @@ -205,7 +202,7 @@ func (qr *questionRepo) UpdateLastAnswer(ctx context.Context, question *entity.Q if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - _ = qr.updateSearch(ctx, question.ID) + _ = qr.UpdateSearch(ctx, question.ID) return nil } @@ -347,19 +344,22 @@ func (qr *questionRepo) SitemapQuestions(ctx context.Context, page, pageSize int } // GetQuestionPage query question page -func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string, inDays int) ( +func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, userID, orderCond string, inDays int, showHidden bool) ( questionList []*entity.Question, total int64, err error) { questionList = make([]*entity.Question, 0) session := qr.data.DB.Context(ctx).Where("question.status = ? OR question.status = ?", entity.QuestionStatusAvailable, entity.QuestionStatusClosed) - if len(tagID) > 0 { + if len(tagIDs) > 0 { session.Join("LEFT", "tag_rel", "question.id = tag_rel.object_id") - session.And("tag_rel.tag_id = ?", tagID) + session.In("tag_rel.tag_id", tagIDs) session.And("tag_rel.status = ?", entity.TagRelStatusAvailable) } if len(userID) > 0 { session.And("question.user_id = ?", userID) + if !showHidden { + session.And("question.show = ?", entity.QuestionShow) + } } else { session.And("question.show = ?", entity.QuestionShow) } @@ -462,8 +462,8 @@ func (qr *questionRepo) AdminQuestionPage(ctx context.Context, search *schema.Ad return rows, count, nil } -// updateSearch update search, if search plugin not enable, do nothing -func (qr *questionRepo) updateSearch(ctx context.Context, questionID string) (err error) { +// UpdateSearch update search, if search plugin not enable, do nothing +func (qr *questionRepo) UpdateSearch(ctx context.Context, questionID string) (err error) { // check search plugin var s plugin.Search _ = plugin.CallSearch(func(search plugin.Search) error { @@ -544,7 +544,7 @@ func (qr *questionRepo) RemoveAllUserQuestion(ctx context.Context, userID string // update search content for _, id := range questionIDs { - _ = qr.updateSearch(ctx, id) + _ = qr.UpdateSearch(ctx, id) } return nil } diff --git a/internal/repo/search_common/search_repo.go b/internal/repo/search_common/search_repo.go index de3c03ebb..0443bfe9a 100644 --- a/internal/repo/search_common/search_repo.go +++ b/internal/repo/search_common/search_repo.go @@ -98,7 +98,7 @@ func NewSearchRepo( } // SearchContents search question and answer data -func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs []string, userID string, votes int, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) { +func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs [][]string, userID string, votes int, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) { words = filterWords(words) var ( @@ -152,16 +152,20 @@ func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs ast := "tag_rel" + strconv.Itoa(ti) b.Join("INNER", "tag_rel as "+ast, "question.id = "+ast+".object_id"). And(builder.Eq{ - ast + ".tag_id": tagID, ast + ".status": entity.TagRelStatusAvailable, - }) + }). + And(builder.In(ast+".tag_id", tagID)) ub.Join("INNER", "tag_rel as "+ast, "question_id = "+ast+".object_id"). And(builder.Eq{ - ast + ".tag_id": tagID, ast + ".status": entity.TagRelStatusAvailable, - }) - argsQ = append(argsQ, entity.TagRelStatusAvailable, tagID) - argsA = append(argsA, entity.TagRelStatusAvailable, tagID) + }). + And(builder.In(ast+".tag_id", tagID)) + argsQ = append(argsQ, entity.TagRelStatusAvailable) + argsA = append(argsA, entity.TagRelStatusAvailable) + for _, t := range tagID { + argsQ = append(argsQ, t) + argsA = append(argsA, t) + } } // check user @@ -236,7 +240,7 @@ func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs } // SearchQuestions search question data -func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagIDs []string, notAccepted bool, views, answers int, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) { +func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagIDs [][]string, notAccepted bool, views, answers int, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) { words = filterWords(words) var ( qfs = qFields @@ -269,10 +273,13 @@ func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagID ast := "tag_rel" + strconv.Itoa(ti) b.Join("INNER", "tag_rel as "+ast, "question.id = "+ast+".object_id"). And(builder.Eq{ - ast + ".tag_id": tagID, ast + ".status": entity.TagRelStatusAvailable, - }) - args = append(args, entity.TagRelStatusAvailable, tagID) + }). + And(builder.In(ast+".tag_id", tagID)) + args = append(args, entity.TagRelStatusAvailable) + for _, t := range tagID { + args = append(args, t) + } } // check need filter has not accepted @@ -343,7 +350,7 @@ func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagID } // SearchAnswers search answer data -func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs []string, accepted bool, questionID string, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) { +func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs [][]string, accepted bool, questionID string, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) { words = filterWords(words) var ( @@ -378,10 +385,13 @@ func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs ast := "tag_rel" + strconv.Itoa(ti) b.Join("INNER", "tag_rel as "+ast, "question_id = "+ast+".object_id"). And(builder.Eq{ - ast + ".tag_id": tagID, ast + ".status": entity.TagRelStatusAvailable, - }) - args = append(args, entity.TagRelStatusAvailable, tagID) + }). + And(builder.In(ast+".tag_id", tagID)) + args = append(args, entity.TagRelStatusAvailable) + for _, t := range tagID { + args = append(args, t) + } } // check limit accepted diff --git a/internal/repo/tag/tag_repo.go b/internal/repo/tag/tag_repo.go index c56e7d58e..eea54995e 100644 --- a/internal/repo/tag/tag_repo.go +++ b/internal/repo/tag/tag_repo.go @@ -21,6 +21,7 @@ package tag import ( "context" + "github.com/apache/incubator-answer/internal/base/data" "github.com/apache/incubator-answer/internal/base/reason" "github.com/apache/incubator-answer/internal/entity" @@ -118,6 +119,15 @@ func (tr *tagRepo) GetTagSynonymCount(ctx context.Context, tagID string) (count return } +func (tr *tagRepo) GetIDsByMainTagId(ctx context.Context, mainTagID string) (tagIDs []string, err error) { + session := tr.data.DB.Context(ctx).Table(entity.Tag{}.TableName()).Where(builder.Eq{"status": entity.TagStatusAvailable, "main_tag_id": converter.StringToInt64(mainTagID)}).Cols("id") + err = session.Find(&tagIDs) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + // GetTagList get tag list all func (tr *tagRepo) GetTagList(ctx context.Context, tag *entity.Tag) (tagList []*entity.Tag, err error) { tagList = make([]*entity.Tag, 0) diff --git a/internal/repo/user/user_repo.go b/internal/repo/user/user_repo.go index deb1427de..628394cf9 100644 --- a/internal/repo/user/user_repo.go +++ b/internal/repo/user/user_repo.go @@ -155,8 +155,9 @@ func (ur *userRepo) UpdateEmail(ctx context.Context, userID, email string) (err return } -func (ur *userRepo) UpdateLanguage(ctx context.Context, userID, language string) (err error) { - _, err = ur.data.DB.Context(ctx).Where("id = ?", userID).Update(&entity.User{Language: language}) +func (ur *userRepo) UpdateUserInterface(ctx context.Context, userID, language, colorSchema string) (err error) { + session := ur.data.DB.Context(ctx).Where("id = ?", userID) + _, err = session.Cols("language", "color_scheme").Update(&entity.User{Language: language, ColorScheme: colorSchema}) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } diff --git a/internal/repo/user_external_login/user_external_login_repo.go b/internal/repo/user_external_login/user_external_login_repo.go index ba0495437..8b78b8b4c 100644 --- a/internal/repo/user_external_login/user_external_login_repo.go +++ b/internal/repo/user_external_login/user_external_login_repo.go @@ -72,6 +72,17 @@ func (ur *userExternalLoginRepo) GetByExternalID(ctx context.Context, provider, return } +// GetByUserID get by user ID +func (ur *userExternalLoginRepo) GetByUserID(ctx context.Context, provider, userID string) ( + userInfo *entity.UserExternalLogin, exist bool, err error) { + userInfo = &entity.UserExternalLogin{} + exist, err = ur.data.DB.Context(ctx).Where("user_id = ?", userID).Where("provider = ?", provider).Get(userInfo) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + // GetUserExternalLoginList get by external ID func (ur *userExternalLoginRepo) GetUserExternalLoginList(ctx context.Context, userID string) ( resp []*entity.UserExternalLogin, err error) { diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 0a6d7b83c..7e856903d 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -53,6 +53,7 @@ type AnswerAPIRouter struct { roleController *controller_admin.RoleController pluginController *controller_admin.PluginController permissionController *controller.PermissionController + userPluginController *controller.UserPluginController } func NewAnswerAPIRouter( @@ -82,6 +83,7 @@ func NewAnswerAPIRouter( roleController *controller_admin.RoleController, pluginController *controller_admin.PluginController, permissionController *controller.PermissionController, + userPluginController *controller.UserPluginController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -110,6 +112,7 @@ func NewAnswerAPIRouter( roleController: roleController, pluginController: pluginController, permissionController: permissionController, + userPluginController: userPluginController, } } @@ -141,9 +144,6 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(authUserMiddleware * func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // user - r.GET("/user/logout", a.userController.UserLogout) - r.POST("/user/email/change/code", middleware.BanAPIForUserCenter, a.userController.UserChangeEmailSendCode) - r.POST("/user/email/verification/send", middleware.BanAPIForUserCenter, a.userController.UserVerifyEmailSend) r.GET("/personal/user/info", a.userController.GetOtherUserInfoByUsername) r.GET("/user/ranking", a.userController.UserRanking) @@ -183,6 +183,12 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/personal/rank/page", a.rankController.GetRankPersonalWithPage) } +func (a *AnswerAPIRouter) RegisterAuthUserWithAnyStatusAnswerAPIRouter(r *gin.RouterGroup) { + r.GET("/user/logout", a.userController.UserLogout) + r.POST("/user/email/change/code", middleware.BanAPIForUserCenter, a.userController.UserChangeEmailSendCode) + r.POST("/user/email/verification/send", middleware.BanAPIForUserCenter, a.userController.UserVerifyEmailSend) +} + func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // revisions r.GET("/revisions/unreviewed", a.revisionController.GetUnreviewedRevisionList) @@ -268,6 +274,10 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/activity/timeline", a.activityController.GetObjectTimeline) r.GET("/activity/timeline/detail", a.activityController.GetObjectTimelineDetail) + // plugin + r.GET("/user/plugin/configs", a.userPluginController.GetUserPluginList) + r.GET("/user/plugin/config", a.userPluginController.GetUserPluginConfig) + r.PUT("/user/plugin/config", a.userPluginController.UpdatePluginUserConfig) } func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go index 0030b819b..4f318d876 100644 --- a/internal/schema/comment_schema.go +++ b/internal/schema/comment_schema.go @@ -130,7 +130,7 @@ type GetCommentWithPageReq struct { // comment id CommentID string `validate:"omitempty" form:"comment_id"` // query condition - QueryCond string `validate:"omitempty,oneof=vote" form:"query_cond"` + QueryCond string `validate:"omitempty,oneof=vote created_at" form:"query_cond"` // user id UserID string `json:"-"` // whether user can edit it diff --git a/internal/schema/plugin_admin_schema.go b/internal/schema/plugin_admin_schema.go index 76d1e9d00..c588c6da3 100644 --- a/internal/schema/plugin_admin_schema.go +++ b/internal/schema/plugin_admin_schema.go @@ -78,9 +78,11 @@ func (g *GetPluginConfigResp) SetConfigFields(ctx *gin.Context, fields []plugin. Required: field.Required, Value: field.Value, UIOptions: ConfigFieldUIOptions{ - Rows: field.UIOptions.Rows, - InputType: string(field.UIOptions.InputType), - Variant: field.UIOptions.Variant, + Rows: field.UIOptions.Rows, + InputType: string(field.UIOptions.InputType), + Variant: field.UIOptions.Variant, + ClassName: field.UIOptions.ClassName, + FieldClassName: field.UIOptions.FieldClassName, }, } configField.UIOptions.Placeholder = field.UIOptions.Placeholder.Translate(ctx) @@ -128,13 +130,15 @@ type ConfigField struct { } type ConfigFieldUIOptions struct { - Placeholder string `json:"placeholder,omitempty"` - Rows string `json:"rows,omitempty"` - InputType string `json:"input_type,omitempty"` - Label string `json:"label,omitempty"` - Action *UIOptionAction `json:"action,omitempty"` - Variant string `json:"variant,omitempty"` - Text string `json:"text,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Rows string `json:"rows,omitempty"` + InputType string `json:"input_type,omitempty"` + Label string `json:"label,omitempty"` + Action *UIOptionAction `json:"action,omitempty"` + Variant string `json:"variant,omitempty"` + Text string `json:"text,omitempty"` + ClassName string `json:"class_name,omitempty"` + FieldClassName string `json:"field_class_name,omitempty"` } type ConfigFieldOption struct { diff --git a/internal/schema/plugin_user_schema.go b/internal/schema/plugin_user_schema.go new file mode 100644 index 000000000..b2734cb13 --- /dev/null +++ b/internal/schema/plugin_user_schema.go @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import ( + "github.com/apache/incubator-answer/plugin" + "github.com/gin-gonic/gin" +) + +type GetUserPluginListResp struct { + Name string `json:"name"` + SlugName string `json:"slug_name"` +} + +type UpdateUserPluginReq struct { + PluginSlugName string `validate:"required,gt=1,lte=100" json:"plugin_slug_name"` + UserID string `json:"-"` +} + +type GetUserPluginConfigReq struct { + PluginSlugName string `validate:"required,gt=1,lte=100" form:"plugin_slug_name"` + UserID string `json:"-"` +} + +type GetUserPluginConfigResp struct { + Name string `json:"name"` + SlugName string `json:"slug_name"` + ConfigFields []*ConfigField `json:"config_fields"` +} + +func (g *GetUserPluginConfigResp) SetConfigFields(ctx *gin.Context, fields []plugin.ConfigField) { + for _, field := range fields { + configField := &ConfigField{ + Name: field.Name, + Type: string(field.Type), + Title: field.Title.Translate(ctx), + Description: field.Description.Translate(ctx), + Required: field.Required, + Value: field.Value, + UIOptions: ConfigFieldUIOptions{ + Rows: field.UIOptions.Rows, + InputType: string(field.UIOptions.InputType), + Variant: field.UIOptions.Variant, + ClassName: field.UIOptions.ClassName, + FieldClassName: field.UIOptions.FieldClassName, + }, + } + configField.UIOptions.Placeholder = field.UIOptions.Placeholder.Translate(ctx) + configField.UIOptions.Label = field.UIOptions.Label.Translate(ctx) + configField.UIOptions.Text = field.UIOptions.Text.Translate(ctx) + if field.UIOptions.Action != nil { + uiOptionAction := &UIOptionAction{ + Url: field.UIOptions.Action.Url, + Method: field.UIOptions.Action.Method, + } + if field.UIOptions.Action.Loading != nil { + uiOptionAction.Loading = &LoadingAction{ + Text: field.UIOptions.Action.Loading.Text.Translate(ctx), + State: string(field.UIOptions.Action.Loading.State), + } + } + if field.UIOptions.Action.OnComplete != nil { + uiOptionAction.OnCompleteAction = &OnCompleteAction{ + ToastReturnMessage: field.UIOptions.Action.OnComplete.ToastReturnMessage, + RefreshFormConfig: field.UIOptions.Action.OnComplete.RefreshFormConfig, + } + } + configField.UIOptions.Action = uiOptionAction + } + + for _, option := range field.Options { + configField.Options = append(configField.Options, ConfigFieldOption{ + Label: option.Label.Translate(ctx), + Value: option.Value, + }) + } + g.ConfigFields = append(g.ConfigFields, configField) + } +} + +type UpdateUserPluginConfigReq struct { + PluginSlugName string `validate:"required,gt=1,lte=100" json:"plugin_slug_name"` + ConfigFields map[string]any `json:"config_fields"` + UserID string `json:"-"` +} diff --git a/internal/schema/search_schema.go b/internal/schema/search_schema.go index e15339533..90b08cebe 100644 --- a/internal/schema/search_schema.go +++ b/internal/schema/search_schema.go @@ -64,7 +64,7 @@ type SearchCondition struct { // only show this question's answer QuestionID string // search query tags - Tags []string + Tags [][]string // search query keywords Words []string } diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index fc56bf5ad..2953b944a 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -136,6 +136,7 @@ type SiteCustomCssHTMLReq struct { type SiteThemeReq struct { Theme string `validate:"required,gt=0,lte=255" json:"theme"` ThemeConfig map[string]interface{} `validate:"omitempty" json:"theme_config"` + ColorScheme string `validate:"omitempty,gt=0,lte=100" json:"color_scheme"` } type SiteSeoReq struct { @@ -171,6 +172,7 @@ type SiteThemeResp struct { ThemeOptions []*ThemeOption `json:"theme_options"` Theme string `json:"theme"` ThemeConfig map[string]interface{} `json:"theme_config"` + ColorScheme string `json:"color_scheme"` } func (s *SiteThemeResp) TrTheme(ctx context.Context) { @@ -284,6 +286,8 @@ const ( PrivilegeLevel2 PrivilegeLevel = 2 // PrivilegeLevel3 high PrivilegeLevel3 PrivilegeLevel = 3 + // PrivilegeLevelCustom custom + PrivilegeLevelCustom PrivilegeLevel = 99 ) type PrivilegeLevel int @@ -308,16 +312,18 @@ type GetPrivilegesConfigResp struct { type PrivilegeOption struct { Level PrivilegeLevel `json:"level"` LevelDesc string `json:"level_desc"` - Privileges []*constant.Privilege `json:"privileges"` + Privileges []*constant.Privilege `validate:"dive" json:"privileges"` } // UpdatePrivilegesConfigReq update privileges config request type UpdatePrivilegesConfigReq struct { - Level PrivilegeLevel `validate:"required,min=1,max=3" json:"level"` + Level PrivilegeLevel `validate:"required,min=1,max=3|eq=99" json:"level"` + CustomPrivileges []*constant.Privilege `validate:"dive" json:"custom_privileges"` } var ( DefaultPrivilegeOptions PrivilegeOptions + DefaultCustomPrivilegeOption *PrivilegeOption privilegeOptionsLevelMapping = map[string][]int{ constant.RankQuestionAddKey: {1, 1, 1}, constant.RankAnswerAddKey: {1, 1, 1}, @@ -368,4 +374,11 @@ func init() { }) } } + + // set up default custom privilege option + DefaultCustomPrivilegeOption = &PrivilegeOption{ + Level: PrivilegeLevelCustom, + LevelDesc: reason.PrivilegeLevelCustomDesc, + Privileges: DefaultPrivilegeOptions[0].Privileges, + } } diff --git a/internal/schema/user_notification_schema.go b/internal/schema/user_notification_schema.go index c25062866..4c2cd3f97 100644 --- a/internal/schema/user_notification_schema.go +++ b/internal/schema/user_notification_schema.go @@ -38,41 +38,13 @@ func NewNotificationChannelsFormJson(jsonStr string) NotificationChannels { return list } -func (n *NotificationChannels) Format(sequences []constant.NotificationChannelKey) { - if n == nil { - *n = make([]*NotificationChannelConfig, 0) - return - } - mapping := make(map[constant.NotificationChannelKey]*NotificationChannelConfig) - for _, item := range *n { - mapping[item.Key] = &NotificationChannelConfig{ - Key: item.Key, - Enable: item.Enable, - } - } - newList := make([]*NotificationChannelConfig, 0) - for _, ch := range sequences { - if c, ok := mapping[ch]; ok { - newList = append(newList, c) - } else { - newList = append(newList, &NotificationChannelConfig{ - Key: ch, - }) - } - } - *n = newList -} - -func (n *NotificationChannels) CheckEnable(ch constant.NotificationChannelKey) bool { - if n == nil { - return false - } - for _, item := range *n { - if item.Key == ch { - return item.Enable - } +func NewNotificationChannelConfigFormJson(jsonStr string) NotificationChannelConfig { + var list NotificationChannels + _ = json.Unmarshal([]byte(jsonStr), &list) + if len(list) > 0 { + return *list[0] } - return false + return NotificationChannelConfig{} } func (n *NotificationChannels) ToJsonString() string { @@ -81,62 +53,39 @@ func (n *NotificationChannels) ToJsonString() string { } type NotificationConfig struct { - Inbox NotificationChannels `json:"inbox"` - AllNewQuestion NotificationChannels `json:"all_new_question"` - AllNewQuestionForFollowingTags NotificationChannels `json:"all_new_question_for_following_tags"` -} - -func (n *NotificationConfig) ToJsonString() string { - data, _ := json.Marshal(n) - return string(data) + Inbox NotificationChannelConfig `json:"inbox"` + AllNewQuestion NotificationChannelConfig `json:"all_new_question"` + AllNewQuestionForFollowingTags NotificationChannelConfig `json:"all_new_question_for_following_tags"` } func NewNotificationConfig(configs []*entity.UserNotificationConfig) NotificationConfig { nc := NotificationConfig{} - nc.Inbox = make([]*NotificationChannelConfig, 0) - nc.AllNewQuestion = make([]*NotificationChannelConfig, 0) - nc.AllNewQuestionForFollowingTags = make([]*NotificationChannelConfig, 0) for _, item := range configs { switch item.Source { case string(constant.InboxSource): - nc.Inbox = NewNotificationChannelsFormJson(item.Channels) + nc.Inbox = NewNotificationChannelConfigFormJson(item.Channels) case string(constant.AllNewQuestionSource): - nc.AllNewQuestion = NewNotificationChannelsFormJson(item.Channels) + nc.AllNewQuestion = NewNotificationChannelConfigFormJson(item.Channels) case string(constant.AllNewQuestionForFollowingTagsSource): - nc.AllNewQuestionForFollowingTags = NewNotificationChannelsFormJson(item.Channels) + nc.AllNewQuestionForFollowingTags = NewNotificationChannelConfigFormJson(item.Channels) } } return nc } -func (n *NotificationConfig) FromJsonString(data string) { - if len(data) > 0 { - _ = json.Unmarshal([]byte(data), n) - return - } - n.Inbox = make([]*NotificationChannelConfig, 0) - n.AllNewQuestion = make([]*NotificationChannelConfig, 0) - n.AllNewQuestionForFollowingTags = make([]*NotificationChannelConfig, 0) - return -} - func (n *NotificationConfig) Format() { - n.Inbox.Format([]constant.NotificationChannelKey{constant.EmailChannel}) - n.AllNewQuestion.Format([]constant.NotificationChannelKey{constant.EmailChannel}) - n.AllNewQuestionForFollowingTags.Format([]constant.NotificationChannelKey{constant.EmailChannel}) -} - -func (n *NotificationConfig) CheckEnable( - source constant.NotificationSource, channel constant.NotificationChannelKey) bool { - switch source { - case constant.InboxSource: - return n.Inbox.CheckEnable(channel) - case constant.AllNewQuestionSource: - return n.AllNewQuestion.CheckEnable(channel) - case constant.AllNewQuestionForFollowingTagsSource: - return n.AllNewQuestionForFollowingTags.CheckEnable(channel) + if n.Inbox.Key == "" { + n.Inbox.Key = constant.EmailChannel + n.Inbox.Enable = false + } + if n.AllNewQuestion.Key == "" { + n.AllNewQuestion.Key = constant.EmailChannel + n.AllNewQuestion.Enable = false + } + if n.AllNewQuestionForFollowingTags.Key == "" { + n.AllNewQuestionForFollowingTags.Key = constant.EmailChannel + n.AllNewQuestionForFollowingTags.Enable = false } - return false } // UpdateUserNotificationConfigReq update user notification config request diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index 4906faffa..539090663 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -21,6 +21,9 @@ package schema import ( "encoding/json" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/base/translator" + "github.com/segmentfault/pacman/errors" "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/validator" @@ -80,6 +83,8 @@ type UserLoginResp struct { Location string `json:"location"` // language Language string `json:"language"` + // Color scheme + ColorScheme string `json:"color_scheme"` // access token AccessToken string `json:"access_token"` // role id @@ -110,6 +115,9 @@ func (r *GetCurrentLoginUserInfoResp) ConvertFromUserEntity(userInfo *entity.Use r.CreatedAt = userInfo.CreatedAt.Unix() r.LastLoginDate = userInfo.LastLoginDate.Unix() r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) + if len(r.ColorScheme) == 0 { + r.ColorScheme = constant.ColorSchemeDefault + } } // GetOtherUserInfoByUsernameResp get user response @@ -278,10 +286,25 @@ func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, er type UpdateUserInterfaceRequest struct { // language Language string `validate:"required,gt=1,lte=100" json:"language"` + // Color scheme + ColorScheme string `validate:"required,gt=1,lte=100" json:"color_scheme"` // user id UserId string `json:"-"` } +func (req *UpdateUserInterfaceRequest) Check() (errFields []*validator.FormErrorField, err error) { + if !translator.CheckLanguageIsValid(req.Language) { + return nil, errors.BadRequest(reason.LangNotFound) + } + if req.ColorScheme != constant.ColorSchemeDefault && + req.ColorScheme != constant.ColorSchemeLight && + req.ColorScheme != constant.ColorSchemeDark && + req.ColorScheme != constant.ColorSchemeSystem { + req.ColorScheme = constant.ColorSchemeDefault + } + return nil, nil +} + type UserRetrievePassWordRequest struct { Email string `validate:"required,email,gt=0,lte=500" json:"e_mail"` CaptchaID string `json:"captcha_id"` @@ -325,6 +348,7 @@ type UserBasicInfo struct { Avatar string `json:"avatar"` Website string `json:"website"` Location string `json:"location"` + Language string `json:"language"` Status string `json:"status"` } diff --git a/internal/service/action/captcha_strategy.go b/internal/service/action/captcha_strategy.go index b4830d1bb..72dfefedb 100644 --- a/internal/service/action/captcha_strategy.go +++ b/internal/service/action/captcha_strategy.go @@ -36,10 +36,6 @@ func (cs *CaptchaService) ValidationStrategy(ctx context.Context, unit, actionTy log.Error(err) return false } - // If no operation previously, it is considered to be the first operation - if info == nil { - return true - } switch actionType { case entity.CaptchaActionEmail: return cs.CaptchaActionEmail(ctx, unit, info) @@ -71,119 +67,152 @@ func (cs *CaptchaService) ValidationStrategy(ctx context.Context, unit, actionTy return false } -func (cs *CaptchaService) CaptchaActionEmail(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionEmail(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { // You need a verification code every time return false } -func (cs *CaptchaService) CaptchaActionPassword(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionPassword(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 3 setTime := int64(60 * 30) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime && actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime && actionInfo.Num >= setNum { return false } - if now-actioninfo.LastTime != 0 && now-actioninfo.LastTime > setTime { + if now-actionInfo.LastTime != 0 && now-actionInfo.LastTime > setTime { cs.captchaRepo.SetActionType(ctx, unit, entity.CaptchaActionPassword, "", 0) } return true } -func (cs *CaptchaService) CaptchaActionEditUserinfo(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionEditUserinfo(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 3 setTime := int64(60 * 30) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime && actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime && actionInfo.Num >= setNum { return false } - if now-actioninfo.LastTime != 0 && now-actioninfo.LastTime > setTime { + if now-actionInfo.LastTime != 0 && now-actionInfo.LastTime > setTime { cs.captchaRepo.SetActionType(ctx, unit, entity.CaptchaActionEditUserinfo, "", 0) } return true } -func (cs *CaptchaService) CaptchaActionQuestion(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionQuestion(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 10 setTime := int64(5) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime || actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime || actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionAnswer(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionAnswer(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 10 setTime := int64(5) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime || actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime || actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionComment(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionComment(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 30 setTime := int64(1) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime || actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime || actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionEdit(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionEdit(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 10 - if actioninfo.Num >= setNum { + if actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionInvitationAnswer(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionInvitationAnswer(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 30 - if actioninfo.Num >= setNum { + if actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionSearch(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionSearch(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } now := time.Now().Unix() setNum := 20 setTime := int64(60) //seconds - if now-int64(actioninfo.LastTime) <= setTime && actioninfo.Num >= setNum { + if now-int64(actionInfo.LastTime) <= setTime && actionInfo.Num >= setNum { return false } - if now-actioninfo.LastTime > setTime { + if now-actionInfo.LastTime > setTime { cs.captchaRepo.SetActionType(ctx, unit, entity.CaptchaActionSearch, "", 0) } return true } -func (cs *CaptchaService) CaptchaActionReport(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionReport(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 30 setTime := int64(1) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime || actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime || actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionDelete(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionDelete(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 5 setTime := int64(5) //seconds now := time.Now().Unix() - if now-actioninfo.LastTime <= setTime || actioninfo.Num >= setNum { + if now-actionInfo.LastTime <= setTime || actionInfo.Num >= setNum { return false } return true } -func (cs *CaptchaService) CaptchaActionVote(ctx context.Context, unit string, actioninfo *entity.ActionRecordInfo) bool { +func (cs *CaptchaService) CaptchaActionVote(ctx context.Context, unit string, actionInfo *entity.ActionRecordInfo) bool { + if actionInfo == nil { + return true + } setNum := 40 - if actioninfo.Num >= setNum { + if actionInfo.Num >= setNum { return false } return true diff --git a/internal/service/activity/activity.go b/internal/service/activity/activity.go index 8591ff2e0..cb634d2a8 100644 --- a/internal/service/activity/activity.go +++ b/internal/service/activity/activity.go @@ -206,7 +206,7 @@ func (as *ActivityService) getTimelineActivityComment(ctx context.Context, objec if err != nil { log.Error(err) } else { - return revision.Log + return converter.Markdown2HTML(revision.Log) } return } @@ -218,7 +218,7 @@ func (as *ActivityService) getTimelineActivityComment(ctx context.Context, objec } else { closeMsg := &schema.CloseQuestionMeta{} if err := json.Unmarshal([]byte(metaInfo.Value), closeMsg); err == nil { - return closeMsg.CloseMsg + return converter.Markdown2HTML(closeMsg.CloseMsg) } } } diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index df93b1a4e..0caa2a14c 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -21,9 +21,6 @@ package comment import ( "context" - "github.com/apache/incubator-answer/pkg/token" - "time" - "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/pager" "github.com/apache/incubator-answer/internal/base/reason" @@ -38,10 +35,12 @@ import ( "github.com/apache/incubator-answer/internal/service/permission" usercommon "github.com/apache/incubator-answer/internal/service/user_common" "github.com/apache/incubator-answer/pkg/htmltext" + "github.com/apache/incubator-answer/pkg/token" "github.com/apache/incubator-answer/pkg/uid" "github.com/jinzhu/copier" "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" + "time" ) // CommentRepo comment repository diff --git a/internal/service/notification/external_notification.go b/internal/service/notification/external_notification.go index bb025e55d..58228884c 100644 --- a/internal/service/notification/external_notification.go +++ b/internal/service/notification/external_notification.go @@ -26,7 +26,9 @@ import ( "github.com/apache/incubator-answer/internal/service/activity_common" "github.com/apache/incubator-answer/internal/service/export" "github.com/apache/incubator-answer/internal/service/notice_queue" + "github.com/apache/incubator-answer/internal/service/siteinfo_common" usercommon "github.com/apache/incubator-answer/internal/service/user_common" + "github.com/apache/incubator-answer/internal/service/user_external_login" "github.com/apache/incubator-answer/internal/service/user_notification_config" "github.com/segmentfault/pacman/log" ) @@ -38,6 +40,8 @@ type ExternalNotificationService struct { emailService *export.EmailService userRepo usercommon.UserRepo notificationQueueService notice_queue.ExternalNotificationQueueService + userExternalLoginRepo user_external_login.UserExternalLoginRepo + siteInfoService siteinfo_common.SiteInfoCommonService } func NewExternalNotificationService( @@ -47,6 +51,8 @@ func NewExternalNotificationService( emailService *export.EmailService, userRepo usercommon.UserRepo, notificationQueueService notice_queue.ExternalNotificationQueueService, + userExternalLoginRepo user_external_login.UserExternalLoginRepo, + siteInfoService siteinfo_common.SiteInfoCommonService, ) *ExternalNotificationService { n := &ExternalNotificationService{ data: data, @@ -55,6 +61,8 @@ func NewExternalNotificationService( emailService: emailService, userRepo: userRepo, notificationQueueService: notificationQueueService, + userExternalLoginRepo: userExternalLoginRepo, + siteInfoService: siteInfoService, } notificationQueueService.RegisterHandler(n.Handler) return n diff --git a/internal/service/notification/new_comment_notification.go b/internal/service/notification/new_comment_notification.go index de69038a0..048c66f1a 100644 --- a/internal/service/notification/new_comment_notification.go +++ b/internal/service/notification/new_comment_notification.go @@ -49,7 +49,6 @@ func (ns *ExternalNotificationService) handleNewCommentNotification(ctx context. ns.sendNewCommentNotificationEmail(ctx, msg.ReceiverUserID, msg.ReceiverEmail, msg.ReceiverLang, msg.NewCommentTemplateRawData) } } - return nil } diff --git a/internal/service/notification/new_question_notification.go b/internal/service/notification/new_question_notification.go index 29a5a9cea..c079ac282 100644 --- a/internal/service/notification/new_question_notification.go +++ b/internal/service/notification/new_question_notification.go @@ -21,17 +21,23 @@ package notification import ( "context" + "strings" + "time" + "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/pkg/display" "github.com/apache/incubator-answer/pkg/token" + "github.com/apache/incubator-answer/plugin" + "github.com/jinzhu/copier" "github.com/segmentfault/pacman/i18n" "github.com/segmentfault/pacman/log" - "time" ) type NewQuestionSubscriber struct { - UserID string `json:"user_id"` - Channels schema.NotificationChannels `json:"channels"` + UserID string `json:"user_id"` + Channels schema.NotificationChannels `json:"channels"` + NotificationSource constant.NotificationSource `json:"notification_source"` } func (ns *ExternalNotificationService) handleNewQuestionNotification(ctx context.Context, @@ -60,6 +66,8 @@ func (ns *ExternalNotificationService) handleNewQuestionNotification(ctx context } } } + + ns.syncNewQuestionNotificationToPlugin(ctx, msg) return nil } @@ -94,8 +102,9 @@ func (ns *ExternalNotificationService) getNewQuestionSubscribers(ctx context.Con continue } subscribersMapping[userNotificationConfig.UserID] = &NewQuestionSubscriber{ - UserID: userNotificationConfig.UserID, - Channels: schema.NewNotificationChannelsFormJson(userNotificationConfig.Channels), + UserID: userNotificationConfig.UserID, + Channels: schema.NewNotificationChannelsFormJson(userNotificationConfig.Channels), + NotificationSource: constant.AllNewQuestionForFollowingTagsSource, } } log.Debugf("get %d subscribers from tags", len(subscribersMapping)) @@ -113,8 +122,9 @@ func (ns *ExternalNotificationService) getNewQuestionSubscribers(ctx context.Con continue } subscribersMapping[notificationConfig.UserID] = &NewQuestionSubscriber{ - UserID: notificationConfig.UserID, - Channels: schema.NewNotificationChannelsFormJson(notificationConfig.Channels), + UserID: notificationConfig.UserID, + Channels: schema.NewNotificationChannelsFormJson(notificationConfig.Channels), + NotificationSource: constant.AllNewQuestionSource, } } @@ -182,3 +192,80 @@ func (ns *ExternalNotificationService) sendNewQuestionNotificationEmail(ctx cont ns.emailService.SendAndSaveCodeWithTime( ctx, userInfo.EMail, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 1*24*time.Hour) } + +func (ns *ExternalNotificationService) syncNewQuestionNotificationToPlugin(ctx context.Context, + msg *schema.ExternalNotificationMsg) { + _ = plugin.CallNotification(func(fn plugin.Notification) error { + // 1. get all this new question's tags followers + subscribersMapping := make(map[string]plugin.NotificationType) + for _, tagID := range msg.NewQuestionTemplateRawData.TagIDs { + userIDs, err := ns.followRepo.GetFollowUserIDs(ctx, tagID) + if err != nil { + log.Error(err) + continue + } + for _, userID := range userIDs { + subscribersMapping[userID] = plugin.NotificationNewQuestionFollowedTag + } + } + + // 2. get all new question's followers + questionSubscribers := fn.GetNewQuestionSubscribers() + for _, subscriber := range questionSubscribers { + subscribersMapping[subscriber] = plugin.NotificationNewQuestion + } + + // 3. remove question owner + delete(subscribersMapping, msg.NewQuestionTemplateRawData.QuestionAuthorUserID) + + pluginNotificationMsg := ns.newPluginQuestionNotification(ctx, msg) + + // 4. send notification + for subscriberUserID, notificationType := range subscribersMapping { + newMsg := plugin.NotificationMessage{} + _ = copier.Copy(&newMsg, pluginNotificationMsg) + newMsg.ReceiverUserID = subscriberUserID + newMsg.Type = notificationType + + if len(subscriberUserID) > 0 { + userInfo, _, _ := ns.userRepo.GetByUserID(ctx, subscriberUserID) + if userInfo != nil { + newMsg.ReceiverLang = userInfo.Language + } + } + + userInfo, exist, err := ns.userExternalLoginRepo.GetByUserID(ctx, fn.Info().SlugName, subscriberUserID) + if err != nil { + log.Errorf("get user external login info failed: %v", err) + return nil + } + if exist { + newMsg.ReceiverExternalID = userInfo.ExternalID + } + fn.Notify(newMsg) + } + return nil + }) +} + +func (ns *ExternalNotificationService) newPluginQuestionNotification( + ctx context.Context, msg *schema.ExternalNotificationMsg) (raw *plugin.NotificationMessage) { + raw = &plugin.NotificationMessage{ + ReceiverUserID: msg.ReceiverUserID, + ReceiverLang: msg.ReceiverLang, + QuestionTitle: msg.NewQuestionTemplateRawData.QuestionTitle, + QuestionTags: strings.Join(msg.NewQuestionTemplateRawData.Tags, ","), + } + siteInfo, err := ns.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + return raw + } + seoInfo, err := ns.siteInfoService.GetSiteSeo(ctx) + if err != nil { + return raw + } + raw.QuestionUrl = display.QuestionURL( + seoInfo.Permalink, siteInfo.SiteUrl, + msg.NewQuestionTemplateRawData.QuestionID, msg.NewQuestionTemplateRawData.QuestionTitle) + return raw +} diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index 84a2084f6..2338023ce 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -24,6 +24,10 @@ import ( "fmt" "time" + "github.com/apache/incubator-answer/internal/service/siteinfo_common" + "github.com/apache/incubator-answer/internal/service/user_external_login" + "github.com/apache/incubator-answer/pkg/display" + "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/data" "github.com/apache/incubator-answer/internal/base/reason" @@ -59,6 +63,8 @@ type NotificationCommon struct { userCommon *usercommon.UserCommon objectInfoService *object_info.ObjService notificationQueueService notice_queue.NotificationQueueService + userExternalLoginRepo user_external_login.UserExternalLoginRepo + siteInfoService siteinfo_common.SiteInfoCommonService } func NewNotificationCommon( @@ -69,6 +75,8 @@ func NewNotificationCommon( followRepo activity_common.FollowRepo, objectInfoService *object_info.ObjService, notificationQueueService notice_queue.NotificationQueueService, + userExternalLoginRepo user_external_login.UserExternalLoginRepo, + siteInfoService siteinfo_common.SiteInfoCommonService, ) *NotificationCommon { notification := &NotificationCommon{ data: data, @@ -78,6 +86,8 @@ func NewNotificationCommon( userCommon: userCommon, objectInfoService: objectInfoService, notificationQueueService: notificationQueueService, + userExternalLoginRepo: userExternalLoginRepo, + siteInfoService: siteInfoService, } notificationQueueService.RegisterHandler(notification.AddNotification) return notification @@ -183,6 +193,10 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N } go ns.SendNotificationToAllFollower(ctx, msg, questionID) + + if msg.Type == schema.NotificationTypeInbox { + ns.syncNotificationToPlugin(ctx, objInfo, msg) + } return nil } @@ -226,3 +240,72 @@ func (ns *NotificationCommon) SendNotificationToAllFollower(ctx context.Context, ns.notificationQueueService.Send(ctx, t) } } + +func (ns *NotificationCommon) syncNotificationToPlugin(ctx context.Context, objInfo *schema.SimpleObjectInfo, + msg *schema.NotificationMsg) { + siteInfo, err := ns.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return + } + seoInfo, err := ns.siteInfoService.GetSiteSeo(ctx) + if err != nil { + log.Errorf("get site seo info failed: %v", err) + return + } + + objInfo.QuestionID = uid.DeShortID(objInfo.QuestionID) + objInfo.AnswerID = uid.DeShortID(objInfo.AnswerID) + pluginNotificationMsg := plugin.NotificationMessage{ + Type: plugin.NotificationType(msg.NotificationAction), + ReceiverUserID: msg.ReceiverUserID, + TriggerUserID: msg.TriggerUserID, + QuestionTitle: objInfo.Title, + } + + if len(objInfo.QuestionID) > 0 { + pluginNotificationMsg.QuestionUrl = + display.QuestionURL(seoInfo.Permalink, siteInfo.SiteUrl, objInfo.QuestionID, objInfo.Title) + } + if len(objInfo.AnswerID) > 0 { + pluginNotificationMsg.AnswerUrl = + display.AnswerURL(seoInfo.Permalink, siteInfo.SiteUrl, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID) + } + if len(objInfo.CommentID) > 0 { + pluginNotificationMsg.CommentUrl = + display.CommentURL(seoInfo.Permalink, siteInfo.SiteUrl, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID, objInfo.CommentID) + } + + if len(msg.TriggerUserID) > 0 { + triggerUser, exist, err := ns.userCommon.GetUserBasicInfoByID(ctx, msg.TriggerUserID) + if err != nil { + log.Errorf("get trigger user basic info failed: %v", err) + return + } + if exist { + pluginNotificationMsg.TriggerUserID = triggerUser.ID + pluginNotificationMsg.TriggerUserDisplayName = triggerUser.DisplayName + pluginNotificationMsg.TriggerUserUrl = display.UserURL(siteInfo.SiteUrl, triggerUser.Username) + } + } + + if len(pluginNotificationMsg.ReceiverLang) == 0 && len(msg.ReceiverUserID) > 0 { + userInfo, _, _ := ns.userCommon.GetUserBasicInfoByID(ctx, msg.ReceiverUserID) + if userInfo != nil { + pluginNotificationMsg.ReceiverLang = userInfo.Language + } + } + + _ = plugin.CallNotification(func(fn plugin.Notification) error { + userInfo, exist, err := ns.userExternalLoginRepo.GetByUserID(ctx, fn.Info().SlugName, msg.ReceiverUserID) + if err != nil { + log.Errorf("get user external login info failed: %v", err) + return nil + } + if exist { + pluginNotificationMsg.ReceiverExternalID = userInfo.ExternalID + } + fn.Notify(pluginNotificationMsg) + return nil + }) +} diff --git a/internal/service/plugin_common/plugin_common_service.go b/internal/service/plugin_common/plugin_common_service.go index ef9ccfde6..d064fa99f 100644 --- a/internal/service/plugin_common/plugin_common_service.go +++ b/internal/service/plugin_common/plugin_common_service.go @@ -42,22 +42,92 @@ type PluginConfigRepo interface { GetPluginConfigAll(ctx context.Context) (pluginConfigs []*entity.PluginConfig, err error) } +type PluginUserConfigRepo interface { + SaveUserPluginConfig(ctx context.Context, userID string, pluginSlugName, configValue string) (err error) + GetPluginUserConfig(ctx context.Context, userID, pluginSlugName string) ( + pluginUserConfig *entity.PluginUserConfig, exist bool, err error) + GetPluginUserConfigPage(ctx context.Context, page, pageSize int) ( + pluginUserConfigs []*entity.PluginUserConfig, total int64, err error) +} + // PluginCommonService user service type PluginCommonService struct { - configService *config.ConfigService - pluginConfigRepo PluginConfigRepo - data *data.Data + configService *config.ConfigService + pluginConfigRepo PluginConfigRepo + pluginUserConfigRepo PluginUserConfigRepo + data *data.Data } // NewPluginCommonService new report service func NewPluginCommonService( pluginConfigRepo PluginConfigRepo, + pluginUserConfigRepo PluginUserConfigRepo, configService *config.ConfigService, data *data.Data, ) *PluginCommonService { + p := &PluginCommonService{ + configService: configService, + pluginConfigRepo: pluginConfigRepo, + pluginUserConfigRepo: pluginUserConfigRepo, + data: data, + } + p.initPluginData() + return p +} + +// UpdatePluginStatus update plugin status +func (ps *PluginCommonService) UpdatePluginStatus(ctx context.Context) (err error) { + content, err := plugin.StatusManager.MarshalJSON() + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err) + } + return ps.configService.UpdateConfig(ctx, constant.PluginStatus, string(content)) +} + +// UpdatePluginConfig update plugin config +func (ps *PluginCommonService) UpdatePluginConfig(ctx context.Context, req *schema.UpdatePluginConfigReq) (err error) { + configValue, _ := json.Marshal(req.ConfigFields) + err = ps.pluginConfigRepo.SavePluginConfig(ctx, req.PluginSlugName, string(configValue)) + if err != nil { + return err + } + + _ = plugin.CallSearch(func(search plugin.Search) error { + if search.Info().SlugName == req.PluginSlugName { + search.RegisterSyncer(ctx, search_sync.NewPluginSyncer(ps.data)) + } + return nil + }) + return nil +} + +// UpdatePluginUserConfig update plugin config +func (ps *PluginCommonService) UpdatePluginUserConfig(ctx context.Context, req *schema.UpdateUserPluginConfigReq) (err error) { + configValue, _ := json.Marshal(req.ConfigFields) + err = ps.pluginUserConfigRepo.SaveUserPluginConfig(ctx, req.UserID, req.PluginSlugName, string(configValue)) + if err != nil { + return err + } + return nil +} + +// GetUserPluginConfig get user plugin config +func (ps *PluginCommonService) GetUserPluginConfig(ctx context.Context, req *schema.GetUserPluginConfigReq) ( + configValue string, err error) { + pluginUserConfig, exist, err := ps.pluginUserConfigRepo.GetPluginUserConfig(ctx, req.UserID, req.PluginSlugName) + if err != nil { + return "", err + } + if !exist { + return "", nil + } + return pluginUserConfig.Value, nil +} + +func (ps *PluginCommonService) initPluginData() { // init plugin status - pluginStatus, err := configService.GetStringValue(context.TODO(), constant.PluginStatus) + pluginStatus, err := ps.configService.GetStringValue(context.TODO(), constant.PluginStatus) if err != nil { log.Error(err) } else { @@ -67,7 +137,7 @@ func NewPluginCommonService( } // init plugin config - pluginConfigs, err := pluginConfigRepo.GetPluginConfigAll(context.Background()) + pluginConfigs, err := ps.pluginConfigRepo.GetPluginConfigAll(context.Background()) if err != nil { log.Error(err) } else { @@ -83,44 +153,49 @@ func NewPluginCommonService( } } - err = plugin.CallCache(func(cache plugin.Cache) error { - data.Cache = cache + _ = plugin.CallCache(func(cache plugin.Cache) error { + ps.data.Cache = cache return nil }) - if err != nil { - log.Errorf("parse plugin cache failed: %v", err) - } } - return &PluginCommonService{ - configService: configService, - pluginConfigRepo: pluginConfigRepo, - data: data, - } -} - -// UpdatePluginStatus update plugin status -func (ps *PluginCommonService) UpdatePluginStatus(ctx context.Context) (err error) { - content, err := plugin.StatusManager.MarshalJSON() - if err != nil { - return errors.InternalServer(reason.UnknownError).WithError(err) - } - return ps.configService.UpdateConfig(ctx, constant.PluginStatus, string(content)) -} - -// UpdatePluginConfig update plugin config -func (ps *PluginCommonService) UpdatePluginConfig(ctx context.Context, req *schema.UpdatePluginConfigReq) (err error) { - configValue, _ := json.Marshal(req.ConfigFields) - err = ps.pluginConfigRepo.SavePluginConfig(ctx, req.PluginSlugName, string(configValue)) - if err != nil { - return err - } - - _ = plugin.CallSearch(func(search plugin.Search) error { - if search.Info().SlugName == req.PluginSlugName { - search.RegisterSyncer(ctx, search_sync.NewPluginSyncer(ps.data)) + // init plugin user config + plugin.RegisterGetPluginUserConfigFunc(func(userID, pluginSlugName string) []byte { + pluginUserConfig, exist, err := ps.pluginUserConfigRepo.GetPluginUserConfig(context.Background(), userID, pluginSlugName) + if err != nil { + log.Error(err) + return nil } - return nil + if !exist { + return nil + } + return []byte(pluginUserConfig.Value) }) - return nil + + // init plugin user config data + go func() { + page, pageSize := 1, 1000 + for { + userConfigs, _, err := ps.pluginUserConfigRepo.GetPluginUserConfigPage(context.Background(), page, pageSize) + if err != nil { + log.Error(err) + return + } + if len(userConfigs) == 0 { + return + } + for _, userConfig := range userConfigs { + err := plugin.CallUserConfig(func(fn plugin.UserConfig) error { + if fn.Info().SlugName == userConfig.PluginSlugName { + return fn.UserConfigReceiver(userConfig.UserID, []byte(userConfig.Value)) + } + return nil + }) + if err != nil { + log.Errorf("parse plugin user config failed: %s %v", userConfig.PluginSlugName, err) + } + } + page++ + } + }() } diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 72835731f..9fbda693e 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -54,7 +54,7 @@ type QuestionRepo interface { UpdateQuestion(ctx context.Context, question *entity.Question, Cols []string) (err error) GetQuestion(ctx context.Context, id string) (question *entity.Question, exist bool, err error) GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error) - GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string, inDays int) ( + GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, userID, orderCond string, inDays int, showHidden bool) ( questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, questionID string, status int) (err error) UpdateQuestionStatusWithOutUpdateTime(ctx context.Context, question *entity.Question) (err error) @@ -72,6 +72,7 @@ type QuestionRepo interface { GetUserQuestionCount(ctx context.Context, userID string) (count int64, err error) SitemapQuestions(ctx context.Context, page, pageSize int) (questionIDList []*schema.SiteMapQuestionInfo, err error) RemoveAllUserQuestion(ctx context.Context, userID string) (err error) + UpdateSearch(ctx context.Context, questionID string) (err error) } // QuestionCommon user service diff --git a/internal/service/question_service.go b/internal/service/question_service.go index f91593040..a4d5ffe68 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -22,9 +22,6 @@ package service import ( "encoding/json" "fmt" - "github.com/apache/incubator-answer/internal/service/notification" - "github.com/apache/incubator-answer/internal/service/siteinfo_common" - "github.com/apache/incubator-answer/pkg/token" "strings" "time" @@ -42,13 +39,17 @@ import ( "github.com/apache/incubator-answer/internal/service/export" "github.com/apache/incubator-answer/internal/service/meta" "github.com/apache/incubator-answer/internal/service/notice_queue" + "github.com/apache/incubator-answer/internal/service/notification" "github.com/apache/incubator-answer/internal/service/permission" questioncommon "github.com/apache/incubator-answer/internal/service/question_common" "github.com/apache/incubator-answer/internal/service/revision_common" + "github.com/apache/incubator-answer/internal/service/role" + "github.com/apache/incubator-answer/internal/service/siteinfo_common" tagcommon "github.com/apache/incubator-answer/internal/service/tag_common" usercommon "github.com/apache/incubator-answer/internal/service/user_common" "github.com/apache/incubator-answer/pkg/converter" "github.com/apache/incubator-answer/pkg/htmltext" + "github.com/apache/incubator-answer/pkg/token" "github.com/apache/incubator-answer/pkg/uid" "github.com/jinzhu/copier" "github.com/segmentfault/pacman/errors" @@ -65,6 +66,7 @@ type QuestionService struct { questioncommon *questioncommon.QuestionCommon userCommon *usercommon.UserCommon userRepo usercommon.UserRepo + userRoleRelService *role.UserRoleRelService revisionService *revision_common.RevisionService metaService *meta.MetaService collectionCommon *collectioncommon.CollectionCommon @@ -83,6 +85,7 @@ func NewQuestionService( questioncommon *questioncommon.QuestionCommon, userCommon *usercommon.UserCommon, userRepo usercommon.UserRepo, + userRoleRelService *role.UserRoleRelService, revisionService *revision_common.RevisionService, metaService *meta.MetaService, collectionCommon *collectioncommon.CollectionCommon, @@ -100,6 +103,7 @@ func NewQuestionService( questioncommon: questioncommon, userCommon: userCommon, userRepo: userRepo, + userRoleRelService: userRoleRelService, revisionService: revisionService, metaService: metaService, collectionCommon: collectionCommon, @@ -314,6 +318,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question if err != nil { return } + _ = qs.questionRepo.UpdateSearch(ctx, question.ID) revisionDTO := &schema.AddRevisionDTO{ UserID: question.UserID, @@ -1237,15 +1242,31 @@ func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID strin func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.QuestionPageReq) ( questions []*schema.QuestionPageResp, total int64, err error) { questions = make([]*schema.QuestionPageResp, 0) - + // query by user role + showHidden := false + if req.LoginUserID != "" && req.UserIDBeSearched != "" { + showHidden = req.LoginUserID == req.UserIDBeSearched + if !showHidden { + userRole, err := qs.userRoleRelService.GetUserRole(ctx, req.LoginUserID) + if err != nil { + return nil, 0, err + } + showHidden = userRole == role.RoleAdminID || userRole == role.RoleModeratorID + } + } // query by tag condition + var tagIDs = make([]string, 0) if len(req.Tag) > 0 { tagInfo, exist, err := qs.tagCommon.GetTagBySlugName(ctx, strings.ToLower(req.Tag)) if err != nil { return nil, 0, err } if exist { - req.TagID = tagInfo.ID + synTagIds, err := qs.tagCommon.GetTagIDsByMainTagID(ctx, tagInfo.ID) + if err != nil { + return nil, 0, err + } + tagIDs = append(synTagIds, tagInfo.ID) } } @@ -1262,7 +1283,7 @@ func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.Ques } questionList, total, err := qs.questionRepo.GetQuestionPage(ctx, req.Page, req.PageSize, - req.UserIDBeSearched, req.TagID, req.OrderCond, req.InDays) + tagIDs, req.UserIDBeSearched, req.OrderCond, req.InDays, showHidden) if err != nil { return nil, 0, err } diff --git a/internal/service/search_common/search.go b/internal/service/search_common/search.go index d1aea5479..c19fdc494 100644 --- a/internal/service/search_common/search.go +++ b/internal/service/search_common/search.go @@ -26,8 +26,8 @@ import ( ) type SearchRepo interface { - SearchContents(ctx context.Context, words []string, tagIDs []string, userID string, votes, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) - SearchQuestions(ctx context.Context, words []string, tagIDs []string, notAccepted bool, views, answers int, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) - SearchAnswers(ctx context.Context, words []string, tagIDs []string, accepted bool, questionID string, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) + SearchContents(ctx context.Context, words []string, tagIDs [][]string, userID string, votes, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) + SearchQuestions(ctx context.Context, words []string, tagIDs [][]string, notAccepted bool, views, answers int, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) + SearchAnswers(ctx context.Context, words []string, tagIDs [][]string, accepted bool, questionID string, page, size int, order string) (resp []*schema.SearchResult, total int64, err error) ParseSearchPluginResult(ctx context.Context, sres []plugin.SearchResult) (resp []*schema.SearchResult, err error) } diff --git a/internal/service/search_parser/search_parser.go b/internal/service/search_parser/search_parser.go index a5dfd6700..6485e1629 100644 --- a/internal/service/search_parser/search_parser.go +++ b/internal/service/search_parser/search_parser.go @@ -105,7 +105,7 @@ func (sp *SearchParser) ParseStructure(ctx context.Context, dto *schema.SearchDT } // parseTags parse search tags, return tag ids array -func (sp *SearchParser) parseTags(ctx context.Context, query *string) (tags []string) { +func (sp *SearchParser) parseTags(ctx context.Context, query *string) (tags [][]string) { var ( // expire tag pattern exprTag = `\[(.*?)\]` @@ -119,17 +119,25 @@ func (sp *SearchParser) parseTags(ctx context.Context, query *string) (tags []st return } - tags = []string{} + tags = make([][]string, 0) for _, item := range res { + tagGroup := make([]string, 0) tag, exists, err := sp.tagCommonService.GetTagBySlugName(ctx, item[1]) if err != nil || !exists { continue } + tagGroup = append(tagGroup, tag.ID) if tag.MainTagID > 0 { - tags = append(tags, fmt.Sprintf("%d", tag.MainTagID)) - } else { - tags = append(tags, tag.ID) + tagGroup = append(tagGroup, fmt.Sprintf("%d", tag.MainTagID)) } + synIDs, err := sp.tagCommonService.GetTagIDsByMainTagID(ctx, tag.ID) + if err != nil || !exists { + continue + } + tagGroup = append(tagGroup, tag.ID) + tagGroup = append(tagGroup, synIDs...) + tagGroup = converter.UniqueArray(tagGroup) + tags = append(tags, tagGroup) } // limit maximum 5 tags diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go index 7f609e6c9..f82a7c7fb 100644 --- a/internal/service/siteinfo/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -311,8 +311,18 @@ func (s *SiteInfoService) GetPrivilegesConfig(ctx context.Context) (resp *schema if err = s.siteInfoCommonService.GetSiteInfoByType(ctx, constant.SiteTypePrivileges, privilege); err != nil { return nil, err } + privilegeOptions := schema.DefaultPrivilegeOptions + if privilege.CustomPrivileges != nil && len(privilege.CustomPrivileges) > 0 { + privilegeOptions = append(privilegeOptions, &schema.PrivilegeOption{ + Level: schema.PrivilegeLevelCustom, + LevelDesc: reason.PrivilegeLevelCustomDesc, + Privileges: privilege.CustomPrivileges, + }) + } else { + privilegeOptions = append(privilegeOptions, schema.DefaultCustomPrivilegeOption) + } resp = &schema.GetPrivilegesConfigResp{ - Options: s.translatePrivilegeOptions(ctx), + Options: s.translatePrivilegeOptions(ctx, privilegeOptions), SelectedLevel: schema.PrivilegeLevel3, } if privilege != nil && privilege.Level > 0 { @@ -321,9 +331,9 @@ func (s *SiteInfoService) GetPrivilegesConfig(ctx context.Context) (resp *schema return resp, nil } -func (s *SiteInfoService) translatePrivilegeOptions(ctx context.Context) (options []*schema.PrivilegeOption) { +func (s *SiteInfoService) translatePrivilegeOptions(ctx context.Context, privilegeOptions []*schema.PrivilegeOption) (options []*schema.PrivilegeOption) { la := handler.GetLangByCtx(ctx) - for _, option := range schema.DefaultPrivilegeOptions { + for _, option := range privilegeOptions { op := &schema.PrivilegeOption{ Level: option.Level, LevelDesc: translator.Tr(la, option.LevelDesc), @@ -341,12 +351,43 @@ func (s *SiteInfoService) translatePrivilegeOptions(ctx context.Context) (option } func (s *SiteInfoService) UpdatePrivilegesConfig(ctx context.Context, req *schema.UpdatePrivilegesConfigReq) (err error) { - chooseOption := schema.DefaultPrivilegeOptions.Choose(req.Level) - if chooseOption == nil { + var choosePrivileges []*constant.Privilege + if req.Level == schema.PrivilegeLevelCustom { + choosePrivileges = req.CustomPrivileges + } else { + chooseOption := schema.DefaultPrivilegeOptions.Choose(req.Level) + if chooseOption == nil { + return nil + } + choosePrivileges = chooseOption.Privileges + } + if choosePrivileges == nil { return nil } // update site info that user choose which privilege level + if req.Level == schema.PrivilegeLevelCustom { + privilegeMap := make(map[string]int) + for _, privilege := range req.CustomPrivileges { + privilegeMap[privilege.Key] = privilege.Value + } + var privileges []*constant.Privilege + for _, privilege := range constant.RankAllPrivileges { + privileges = append(privileges, &constant.Privilege{ + Key: privilege.Key, + Label: privilege.Label, + Value: privilegeMap[privilege.Key], + }) + } + req.CustomPrivileges = privileges + } else { + privilege := &schema.UpdatePrivilegesConfigReq{} + if err = s.siteInfoCommonService.GetSiteInfoByType(ctx, constant.SiteTypePrivileges, privilege); err != nil { + return err + } + req.CustomPrivileges = privilege.CustomPrivileges + } + content, _ := json.Marshal(req) data := &entity.SiteInfo{ Type: constant.SiteTypePrivileges, @@ -359,7 +400,7 @@ func (s *SiteInfoService) UpdatePrivilegesConfig(ctx context.Context, req *schem } // update privilege in config - for _, privilege := range chooseOption.Privileges { + for _, privilege := range choosePrivileges { err = s.configService.UpdateConfig(ctx, privilege.Key, fmt.Sprintf("%d", privilege.Value)) if err != nil { return err diff --git a/internal/service/tag_common/tag_common.go b/internal/service/tag_common/tag_common.go index 74a0f0ab9..fe8b3b532 100644 --- a/internal/service/tag_common/tag_common.go +++ b/internal/service/tag_common/tag_common.go @@ -60,6 +60,7 @@ type TagRepo interface { MustGetTagByNameOrID(ctx context.Context, tagID, slugName string) (tag *entity.Tag, exist bool, err error) UpdateTagSynonym(ctx context.Context, tagSlugNameList []string, mainTagID int64, mainTagSlugName string) (err error) GetTagSynonymCount(ctx context.Context, tagID string) (count int64, err error) + GetIDsByMainTagId(ctx context.Context, mainTagID string) (tagIDs []string, err error) GetTagList(ctx context.Context, tag *entity.Tag) (tagList []*entity.Tag, err error) } @@ -364,6 +365,12 @@ func (ts *TagCommonService) GetTagByID(ctx context.Context, tagID string) (tag * return } +// GetTagIDsByMainTagID get object tag +func (ts *TagCommonService) GetTagIDsByMainTagID(ctx context.Context, tagID string) (tagIDs []string, err error) { + tagIDs, err = ts.tagRepo.GetIDsByMainTagId(ctx, tagID) + return +} + // GetTagBySlugName get object tag func (ts *TagCommonService) GetTagBySlugName(ctx context.Context, slugName string) (tag *entity.Tag, exist bool, err error) { tag, exist, err = ts.tagCommonRepo.GetTagBySlugName(ctx, slugName) diff --git a/internal/service/user_common/user.go b/internal/service/user_common/user.go index 753588840..334bcc4ee 100644 --- a/internal/service/user_common/user.go +++ b/internal/service/user_common/user.go @@ -48,7 +48,7 @@ type UserRepo interface { UpdateEmailStatus(ctx context.Context, userID string, emailStatus int) error UpdateNoticeStatus(ctx context.Context, userID string, noticeStatus int) error UpdateEmail(ctx context.Context, userID, email string) error - UpdateLanguage(ctx context.Context, userID, language string) error + UpdateUserInterface(ctx context.Context, userID, language, colorSchema string) (err error) UpdatePass(ctx context.Context, userID, pass string) error UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) GetByUserID(ctx context.Context, userID string) (userInfo *entity.User, exist bool, err error) @@ -153,6 +153,7 @@ func (us *UserCommon) FormatUserBasicInfo(ctx context.Context, userInfo *entity. userBasicInfo.DisplayName = userInfo.DisplayName userBasicInfo.Website = userInfo.Website userBasicInfo.Location = userInfo.Location + userBasicInfo.Language = userInfo.Language userBasicInfo.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) if userBasicInfo.Status == constant.UserDeleted { userBasicInfo.Avatar = "" diff --git a/internal/service/user_external_login/user_external_login_service.go b/internal/service/user_external_login/user_external_login_service.go index ccd3b52d7..6cd69aec6 100644 --- a/internal/service/user_external_login/user_external_login_service.go +++ b/internal/service/user_external_login/user_external_login_service.go @@ -49,6 +49,7 @@ type UserExternalLoginRepo interface { AddUserExternalLogin(ctx context.Context, user *entity.UserExternalLogin) (err error) UpdateInfo(ctx context.Context, userInfo *entity.UserExternalLogin) (err error) GetByExternalID(ctx context.Context, provider, externalID string) (userInfo *entity.UserExternalLogin, exist bool, err error) + GetByUserID(ctx context.Context, provider, userID string) (userInfo *entity.UserExternalLogin, exist bool, err error) GetUserExternalLoginList(ctx context.Context, userID string) (resp []*entity.UserExternalLogin, err error) DeleteUserExternalLogin(ctx context.Context, userID, externalID string) (err error) SetCacheUserExternalLoginInfo(ctx context.Context, key string, info *schema.ExternalLoginUserInfoCache) (err error) diff --git a/internal/service/user_notification_config/user_notification_config_service.go b/internal/service/user_notification_config/user_notification_config_service.go index dee84939b..cc6bc1b1f 100644 --- a/internal/service/user_notification_config/user_notification_config_service.go +++ b/internal/service/user_notification_config/user_notification_config_service.go @@ -96,7 +96,9 @@ func (us *UserNotificationConfigService) SetDefaultUserNotificationConfig(ctx co } func (us *UserNotificationConfigService) convertToEntity(ctx context.Context, userID string, - source constant.NotificationSource, channels schema.NotificationChannels) (c *entity.UserNotificationConfig) { + source constant.NotificationSource, channel schema.NotificationChannelConfig) (c *entity.UserNotificationConfig) { + var channels schema.NotificationChannels + channels = append(channels, &channel) c = &entity.UserNotificationConfig{ UserID: userID, Source: string(source), @@ -110,17 +112,3 @@ func (us *UserNotificationConfigService) convertToEntity(ctx context.Context, us } return c } - -func (us *UserNotificationConfigService) CheckEnable( - ctx context.Context, userID string, source constant.NotificationSource, - channel constant.NotificationChannelKey) (enable bool, err error) { - conf, exist, err := us.userNotificationConfigRepo.GetByUserIDAndSource(ctx, userID, source) - if err != nil { - return false, err - } - if !exist { - return false, nil - } - notificationChannels := schema.NewNotificationChannelsFormJson(conf.Channels) - return notificationChannels.CheckEnable(channel), nil -} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index a0e0dbb75..c6d98fc2e 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -381,14 +381,7 @@ func (us *UserService) formatUserInfoForUpdateInfo( // UserUpdateInterface update user interface func (us *UserService) UserUpdateInterface(ctx context.Context, req *schema.UpdateUserInterfaceRequest) (err error) { - if !translator.CheckLanguageIsValid(req.Language) { - return errors.BadRequest(reason.LangNotFound) - } - err = us.userRepo.UpdateLanguage(ctx, req.UserId, req.Language) - if err != nil { - return - } - return nil + return us.userRepo.UpdateUserInterface(ctx, req.UserId, req.Language, req.ColorScheme) } // UserRegisterByEmail user register diff --git a/pkg/converter/array.go b/pkg/converter/array.go index 7718ee69b..2b122203e 100644 --- a/pkg/converter/array.go +++ b/pkg/converter/array.go @@ -32,3 +32,15 @@ func ArrayNotInArray(original []string, search []string) []string { } return result } + +func UniqueArray[T comparable](input []T) []T { + result := make([]T, 0, len(input)) + seen := make(map[T]bool, len(input)) + for _, element := range input { + if !seen[element] { + result = append(result, element) + seen[element] = true + } + } + return result +} diff --git a/pkg/display/url.go b/pkg/display/url.go index 1a9c51961..8f42f2f55 100644 --- a/pkg/display/url.go +++ b/pkg/display/url.go @@ -58,3 +58,8 @@ func CommentURL(permalink int, siteUrl, questionID, title, answerID, commentID s } return QuestionURL(permalink, siteUrl, questionID, title) + "?commentId=" + commentID } + +// UserURL get user url +func UserURL(siteUrl, username string) string { + return siteUrl + "/users/" + username +} diff --git a/plugin/config.go b/plugin/config.go index 5e6ed5dd8..b03e794ee 100644 --- a/plugin/config.go +++ b/plugin/config.go @@ -32,6 +32,7 @@ const ( ConfigTypeTimezone ConfigType = "timezone" ConfigTypeSwitch ConfigType = "switch" ConfigTypeButton ConfigType = "button" + ConfigTypeLegend ConfigType = "legend" ) const ( @@ -63,13 +64,15 @@ type ConfigField struct { } type ConfigFieldUIOptions struct { - Placeholder Translator `json:"placeholder,omitempty"` - Rows string `json:"rows,omitempty"` - InputType InputType `json:"input_type,omitempty"` - Label Translator `json:"label,omitempty"` - Action *UIOptionAction `json:"action,omitempty"` - Variant string `json:"variant,omitempty"` - Text Translator `json:"text,omitempty"` + Placeholder Translator `json:"placeholder,omitempty"` + Rows string `json:"rows,omitempty"` + InputType InputType `json:"input_type,omitempty"` + Label Translator `json:"label,omitempty"` + Action *UIOptionAction `json:"action,omitempty"` + Variant string `json:"variant,omitempty"` + Text Translator `json:"text,omitempty"` + ClassName string `json:"class_name,omitempty"` + FieldClassName string `json:"field_class_name,omitempty"` } type ConfigFieldOption struct { diff --git a/plugin/notification.go b/plugin/notification.go new file mode 100644 index 000000000..4c483bcd4 --- /dev/null +++ b/plugin/notification.go @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package plugin + +// NotificationType is the type of the notification +type NotificationType string + +const ( + NotificationUpdateQuestion NotificationType = "notification.action.update_question" + NotificationAnswerTheQuestion NotificationType = "notification.action.answer_the_question" + NotificationUpVotedTheQuestion NotificationType = "notification.action.up_voted_question" + NotificationDownVotedTheQuestion NotificationType = "notification.action.down_voted_question" + NotificationUpdateAnswer NotificationType = "notification.action.update_answer" + NotificationAcceptAnswer NotificationType = "notification.action.accept_answer" + NotificationUpVotedTheAnswer NotificationType = "notification.action.up_voted_answer" + NotificationDownVotedTheAnswer NotificationType = "notification.action.down_voted_answer" + NotificationCommentQuestion NotificationType = "notification.action.comment_question" + NotificationCommentAnswer NotificationType = "notification.action.comment_answer" + NotificationUpVotedTheComment NotificationType = "notification.action.up_voted_comment" + NotificationReplyToYou NotificationType = "notification.action.reply_to_you" + NotificationMentionYou NotificationType = "notification.action.mention_you" + NotificationYourQuestionIsClosed NotificationType = "notification.action.your_question_is_closed" + NotificationYourQuestionWasDeleted NotificationType = "notification.action.your_question_was_deleted" + NotificationYourAnswerWasDeleted NotificationType = "notification.action.your_answer_was_deleted" + NotificationYourCommentWasDeleted NotificationType = "notification.action.your_comment_was_deleted" + NotificationInvitedYouToAnswer NotificationType = "notification.action.invited_you_to_answer" + NotificationNewQuestion NotificationType = "notification.action.new_question" + NotificationNewQuestionFollowedTag NotificationType = "notification.action.new_question_followed_tag" +) + +type Notification interface { + Base + + // GetNewQuestionSubscribers returns the subscribers of the new question notification + GetNewQuestionSubscribers() (userIDs []string) + + // Notify sends a notification to the user + Notify(msg NotificationMessage) +} + +type NotificationMessage struct { + // the type of the notification + Type NotificationType `json:"notification_type"` + // the receiver user id + ReceiverUserID string `json:"receiver_user_id"` + // the receiver user using language + ReceiverLang string `json:"receiver_lang"` + // the receiver user external id (optional) + ReceiverExternalID string `json:"receiver_external_id"` + + // Who triggered the notification (optional, admin or system operation will not have this field) + TriggerUserID string `json:"trigger_user_id"` + // The trigger user's display name (optional, admin or system operation will not have this field) + TriggerUserDisplayName string `json:"trigger_user_display_name"` + // The trigger user's url (optional, admin or system operation will not have this field) + TriggerUserUrl string `json:"trigger_user_url"` + + // the question title + QuestionTitle string `json:"question_title"` + // the question url + QuestionUrl string `json:"question_url"` + // the question tags (comma separated, optional, only for new question notification) + QuestionTags string `json:"tags"` + + // the answer url (optional, only for new answer notification) + AnswerUrl string `json:"answer_url"` + // the comment url (optional, only for new comment notification) + CommentUrl string `json:"comment_url"` +} + +var ( + // CallNotification is a function that calls all registered notification plugins + CallNotification, + registerNotification = MakePlugin[Notification](false) +) diff --git a/plugin/plugin.go b/plugin/plugin.go index 1fec78428..79e43f687 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -21,6 +21,7 @@ package plugin import ( "encoding/json" + "github.com/segmentfault/pacman/i18n" "github.com/apache/incubator-answer/internal/base/handler" "github.com/apache/incubator-answer/internal/base/translator" @@ -48,6 +49,10 @@ func Register(p Base) { registerConfig(p.(Config)) } + if _, ok := p.(UserConfig); ok { + registerUserConfig(p.(UserConfig)) + } + if _, ok := p.(Connector); ok { registerConnector(p.(Connector)) } @@ -79,6 +84,10 @@ func Register(p Base) { if _, ok := p.(Search); ok { registerSearch(p.(Search)) } + + if _, ok := p.(Notification); ok { + registerNotification(p.(Notification)) + } } type Stack[T Base] struct { @@ -153,6 +162,11 @@ func Translate(ctx *GinContext, key string) string { return translator.Tr(handler.GetLang(ctx), key) } +// TranslateWithData translates the key to the language with data +func TranslateWithData(lang i18n.Language, key string, data any) string { + return translator.TrWithData(lang, key, data) +} + // TranslateFn presents a generator of translated string. // We use it to delegate the translation work outside the plugin. type TranslateFn func(ctx *GinContext) string diff --git a/plugin/search.go b/plugin/search.go index 20ff0f0ac..719d4294c 100644 --- a/plugin/search.go +++ b/plugin/search.go @@ -56,7 +56,7 @@ type SearchBasicCond struct { // The keywords for search. Words []string // TagIDs is a list of tag IDs. - TagIDs []string + TagIDs [][]string // The object's owner user ID. UserID string // The order of the search result. diff --git a/plugin/user_config.go b/plugin/user_config.go new file mode 100644 index 000000000..07070d84e --- /dev/null +++ b/plugin/user_config.go @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package plugin + +type UserConfig interface { + Base + + // UserConfigFields returns the list of config fields + UserConfigFields() []ConfigField + // UserConfigReceiver receives the config data, it calls when the config is saved or initialized. + // We recommend to unmarshal the data to a struct, and then use the struct to do something. + // The config is encoded in JSON format. + // It depends on the definition of ConfigFields. + UserConfigReceiver(userID string, config []byte) error +} + +var ( + // CallUserConfig is a function that calls all registered config plugins + CallUserConfig, + registerUserConfig = MakePlugin[UserConfig](false) + getPluginUserConfigFn func(userID, pluginSlugName string) []byte +) + +// GetPluginUserConfig returns the user config of the given user id +func GetPluginUserConfig(userID, pluginSlugName string) []byte { + if getPluginUserConfigFn != nil { + return getPluginUserConfigFn(userID, pluginSlugName) + } + return nil +} + +// RegisterGetPluginUserConfigFunc registers a function to get the user config of the given user id +func RegisterGetPluginUserConfigFunc(fn func(userID, pluginSlugName string) []byte) { + getPluginUserConfigFn = fn +} diff --git a/ui/.env.production b/ui/.env.production index e04b4d355..33d84710b 100644 --- a/ui/.env.production +++ b/ui/.env.production @@ -1,2 +1,4 @@ +TSC_COMPILE_ON_ERROR=true +ESLINT_NO_DEV_ERRORS=true PUBLIC_URL = / REACT_APP_API_URL = / diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 46c006879..1d9052600 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -109,5 +109,7 @@ module.exports = { 'newlines-between': 'always', }, ], + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/no-noninteractive-tabindex': 'off', }, }; diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json index bf0ed214b..c707926a1 100644 --- a/ui/.prettierrc.json +++ b/ui/.prettierrc.json @@ -5,4 +5,4 @@ "jsxBracketSameLine": true, "printWidth": 80, "endOfLine": "auto" -} \ No newline at end of file +} diff --git a/ui/package.json b/ui/package.json index 1ad295947..ac7e4411f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "axios": "^0.27.2", - "bootstrap": "^5.3.0", + "bootstrap": "^5.3.2", "bootstrap-icons": "^1.10.5", "classnames": "^2.3.1", "codemirror": "5.65.0", @@ -32,7 +32,7 @@ "qrcode": "^1.5.1", "qs": "^6.11.0", "react": "^18.2.0", - "react-bootstrap": "^2.7.4", + "react-bootstrap": "^2.10.0", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", "react-i18next": "^11.18.3", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index b1555ee75..77f3c9b1f 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: ^0.27.2 version: 0.27.2 bootstrap: - specifier: ^5.3.0 - version: 5.3.0(@popperjs/core@2.11.8) + specifier: ^5.3.2 + version: 5.3.2(@popperjs/core@2.11.8) bootstrap-icons: specifier: ^1.10.5 version: 1.10.5 @@ -77,8 +77,8 @@ importers: specifier: ^18.2.0 version: 18.2.0 react-bootstrap: - specifier: ^2.7.4 - version: 2.7.4(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.10.0 + version: 2.10.0(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -211,13 +211,13 @@ importers: version: 3.1.0 purgecss-webpack-plugin: specifier: ^4.1.3 - version: 4.1.3(webpack@5.80.0) + version: 4.1.3(webpack@5.89.0) react-app-rewired: specifier: ^2.2.1 version: 2.2.1(react-scripts@5.0.1) react-scripts: specifier: 5.0.1 - version: 5.0.1(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5) + version: 5.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5) sass: specifier: ^1.54.4 version: 1.54.9 @@ -342,6 +342,12 @@ packages: dependencies: '@babel/types': 7.19.0 + /@babel/helper-annotate-as-pure@7.22.5: + resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.6 + /@babel/helper-builder-binary-assignment-operator-visitor@7.18.9: resolution: {integrity: sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==} engines: {node: '>=6.9.0'} @@ -438,11 +444,11 @@ packages: dependencies: '@babel/types': 7.19.0 - /@babel/helper-module-imports@7.21.4: - resolution: {integrity: sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==} + /@babel/helper-module-imports@7.22.15: + resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.21.4 + '@babel/types': 7.23.6 /@babel/helper-module-transforms@7.19.0: resolution: {integrity: sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==} @@ -469,8 +475,8 @@ packages: resolution: {integrity: sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==} engines: {node: '>=6.9.0'} - /@babel/helper-plugin-utils@7.20.2: - resolution: {integrity: sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==} + /@babel/helper-plugin-utils@7.22.5: + resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} engines: {node: '>=6.9.0'} /@babel/helper-remap-async-to-generator@7.18.9(@babel/core@7.19.1): @@ -521,14 +527,18 @@ packages: resolution: {integrity: sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==} engines: {node: '>=6.9.0'} - /@babel/helper-string-parser@7.19.4: - resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==} + /@babel/helper-string-parser@7.23.4: + resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} engines: {node: '>=6.9.0'} /@babel/helper-validator-identifier@7.19.1: resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} engines: {node: '>=6.9.0'} + /@babel/helper-validator-identifier@7.22.20: + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + /@babel/helper-validator-option@7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} engines: {node: '>=6.9.0'} @@ -840,14 +850,14 @@ packages: '@babel/core': 7.19.1 '@babel/helper-plugin-utils': 7.19.0 - /@babel/plugin-syntax-flow@7.21.4(@babel/core@7.19.1): - resolution: {integrity: sha512-l9xd3N+XG4fZRxEP3vXdK6RW7vN1Uf5dxzRC/09wV86wqZ/YYQooBIGNsiRdfNR3/q2/5pPzV4B54J/9ctX5jw==} + /@babel/plugin-syntax-flow@7.23.3(@babel/core@7.19.1): + resolution: {integrity: sha512-YZiAIpkJAwQXBJLIQbRFayR5c+gJ35Vcz3bg954k7cd73zqjvhacJuL9RbrzPz8qPmZdgqP6EUKwy0PCNhaaPA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.19.1 - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-plugin-utils': 7.22.5 /@babel/plugin-syntax-import-assertions@7.18.6(@babel/core@7.19.1): resolution: {integrity: sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==} @@ -883,14 +893,14 @@ packages: '@babel/core': 7.19.1 '@babel/helper-plugin-utils': 7.19.0 - /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.19.1): - resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==} + /@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.19.1): + resolution: {integrity: sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.19.1 - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-plugin-utils': 7.22.5 /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.19.1): resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} @@ -1264,18 +1274,18 @@ packages: '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.19.1) '@babel/types': 7.19.0 - /@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.19.1): - resolution: {integrity: sha512-6OAWljMvQrZjR2DaNhVfRz6dkCAVV+ymcLUmaf8bccGOHn2v5rHJK3tTpij0BuhdYWP4LLaqj5lwcdlpAAPuvg==} + /@babel/plugin-transform-react-jsx@7.23.4(@babel/core@7.19.1): + resolution: {integrity: sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.19.1 - '@babel/helper-annotate-as-pure': 7.18.6 - '@babel/helper-module-imports': 7.21.4 - '@babel/helper-plugin-utils': 7.20.2 - '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.19.1) - '@babel/types': 7.21.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-module-imports': 7.22.15 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-jsx': 7.23.3(@babel/core@7.19.1) + '@babel/types': 7.23.6 /@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.19.1): resolution: {integrity: sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==} @@ -1530,17 +1540,17 @@ packages: dependencies: regenerator-runtime: 0.13.9 - /@babel/runtime@7.22.5: - resolution: {integrity: sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==} + /@babel/runtime@7.23.2: + resolution: {integrity: sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==} engines: {node: '>=6.9.0'} dependencies: - regenerator-runtime: 0.13.11 + regenerator-runtime: 0.14.1 - /@babel/runtime@7.23.2: - resolution: {integrity: sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==} + /@babel/runtime@7.23.8: + resolution: {integrity: sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==} engines: {node: '>=6.9.0'} dependencies: - regenerator-runtime: 0.14.0 + regenerator-runtime: 0.14.1 /@babel/template@7.18.10: resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==} @@ -1575,12 +1585,12 @@ packages: '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 - /@babel/types@7.21.4: - resolution: {integrity: sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==} + /@babel/types@7.23.6: + resolution: {integrity: sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-string-parser': 7.19.4 - '@babel/helper-validator-identifier': 7.19.1 + '@babel/helper-string-parser': 7.23.4 + '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 /@bcoe/v8-coverage@0.2.3: @@ -2285,7 +2295,7 @@ packages: collect-v8-coverage: 1.0.1 exit: 0.1.2 glob: 7.2.3 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 istanbul-lib-coverage: 3.2.0 istanbul-lib-instrument: 5.2.0 istanbul-lib-report: 3.0.0 @@ -2314,7 +2324,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: callsites: 3.1.0 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 source-map: 0.6.1 /@jest/test-result@27.5.1: @@ -2340,7 +2350,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/test-result': 27.5.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-haste-map: 27.5.1 jest-runtime: 27.5.1 transitivePeerDependencies: @@ -2419,13 +2429,18 @@ packages: dependencies: '@jridgewell/set-array': 1.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.22 dev: true /@jridgewell/resolve-uri@3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + dev: true + /@jridgewell/set-array@1.1.2: resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} engines: {node: '>=6.0.0'} @@ -2436,11 +2451,11 @@ packages: '@jridgewell/gen-mapping': 0.3.2 '@jridgewell/trace-mapping': 0.3.17 - /@jridgewell/source-map@0.3.3: - resolution: {integrity: sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==} + /@jridgewell/source-map@0.3.5: + resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} dependencies: '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.22 dev: true /@jridgewell/sourcemap-codec@1.4.14: @@ -2462,11 +2477,11 @@ packages: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 - /@jridgewell/trace-mapping@0.3.18: - resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} + /@jridgewell/trace-mapping@0.3.22: + resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} dependencies: - '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 dev: true /@jridgewell/trace-mapping@0.3.9: @@ -2556,12 +2571,13 @@ packages: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} dev: false - /@react-aria/ssr@3.6.0(react@18.2.0): - resolution: {integrity: sha512-OFiYQdv+Yk7AO7IsQu/fAEPijbeTwrrEYvdNoJ3sblBBedD5j5fBTNWrUPNVlwC4XWWnWTCMaRIVsJujsFiWXg==} + /@react-aria/ssr@3.9.1(react@18.2.0): + resolution: {integrity: sha512-NqzkLFP8ZVI4GSorS0AYljC13QW2sc8bDqJOkBvkAt3M8gbcAXJWVRGtZBCRscki9RZF+rNlnPdg0G0jYkhJcg==} + engines: {node: '>= 12'} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 dependencies: - '@swc/helpers': 0.4.14 + '@swc/helpers': 0.5.3 react: 18.2.0 dev: false @@ -2570,8 +2586,8 @@ packages: engines: {node: '>=14'} dev: false - /@restart/hooks@0.4.9(react@18.2.0): - resolution: {integrity: sha512-3BekqcwB6Umeya+16XPooARn4qEPW6vNvwYnlofIYe6h9qG1/VeD7UvShCWx11eFz5ELYmwIEshz+MkPX3wjcQ==} + /@restart/hooks@0.4.15(react@18.2.0): + resolution: {integrity: sha512-cZFXYTxbpzYcieq/mBwSyXgqnGMHoBVh3J7MU0CCoIB4NRZxV9/TuwTBAaLMqpNhC3zTPMCgkQ5Ey07L02Xmcw==} peerDependencies: react: '>=16.8.0' dependencies: @@ -2585,16 +2601,16 @@ packages: react: '>=16.14.0' react-dom: '>=16.14.0' dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.8 '@popperjs/core': 2.11.8 - '@react-aria/ssr': 3.6.0(react@18.2.0) - '@restart/hooks': 0.4.9(react@18.2.0) - '@types/warning': 3.0.0 + '@react-aria/ssr': 3.9.1(react@18.2.0) + '@restart/hooks': 0.4.15(react@18.2.0) + '@types/warning': 3.0.3 dequal: 2.0.3 dom-helpers: 5.2.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - uncontrollable: 8.0.2(react@18.2.0) + uncontrollable: 8.0.4(react@18.2.0) warning: 4.0.3 dev: false @@ -2880,10 +2896,10 @@ packages: resolution: {integrity: sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==} dev: true - /@swc/helpers@0.4.14: - resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} + /@swc/helpers@0.5.3: + resolution: {integrity: sha512-FaruWX6KdudYloq1AHD/4nU+UsMTdNE8CKyrseXWEcgjDAbvkwJg2QGPAnfIJLIWsjZOSPLOAykK6fuYp4vp4A==} dependencies: - tslib: 2.4.0 + tslib: 2.6.2 dev: false /@swc/types@0.1.5: @@ -3042,12 +3058,26 @@ packages: '@types/eslint': 8.4.6 '@types/estree': 0.0.51 + /@types/eslint-scope@3.7.7: + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + dependencies: + '@types/eslint': 8.56.2 + '@types/estree': 1.0.5 + dev: true + /@types/eslint@8.4.6: resolution: {integrity: sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==} dependencies: '@types/estree': 1.0.0 '@types/json-schema': 7.0.13 + /@types/eslint@8.56.2: + resolution: {integrity: sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==} + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + dev: true + /@types/estree@0.0.39: resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -3057,8 +3087,8 @@ packages: /@types/estree@1.0.0: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} - /@types/estree@1.0.1: - resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true /@types/express-serve-static-core@4.17.31: @@ -3119,6 +3149,10 @@ packages: /@types/json-schema@7.0.13: resolution: {integrity: sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==} + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + /@types/json5@0.0.29: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -3172,10 +3206,10 @@ packages: '@types/react': 18.0.20 dev: true - /@types/react-transition-group@4.4.6: - resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==} + /@types/react-transition-group@4.4.10: + resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==} dependencies: - '@types/react': 18.2.22 + '@types/react': 18.0.20 dev: false /@types/react@18.0.20: @@ -3185,14 +3219,6 @@ packages: '@types/scheduler': 0.16.2 csstype: 3.1.1 - /@types/react@18.2.22: - resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==} - dependencies: - '@types/prop-types': 15.7.5 - '@types/scheduler': 0.16.2 - csstype: 3.1.1 - dev: false - /@types/resolve@1.17.1: resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} dependencies: @@ -3230,8 +3256,8 @@ packages: /@types/trusted-types@2.0.2: resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==} - /@types/warning@3.0.0: - resolution: {integrity: sha512-t/Tvs5qR47OLOr+4E9ckN8AmP2Tf16gWq+/qA4iUGS/OOyHVO8wv2vjJuX8SNOUTJyWb+2t7wJm6cXILFnOROA==} + /@types/warning@3.0.3: + resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==} dev: false /@types/ws@8.5.3: @@ -3649,32 +3675,32 @@ packages: '@webassemblyjs/helper-numbers': 1.11.1 '@webassemblyjs/helper-wasm-bytecode': 1.11.1 - /@webassemblyjs/ast@1.11.5: - resolution: {integrity: sha512-LHY/GSAZZRpsNQH+/oHqhRQ5FT7eoULcBqgfyTB5nQHogFnK3/7QoN7dLnwSE/JkUAF0SrRuclT7ODqMFtWxxQ==} + /@webassemblyjs/ast@1.11.6: + resolution: {integrity: sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==} dependencies: - '@webassemblyjs/helper-numbers': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + '@webassemblyjs/helper-numbers': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 dev: true /@webassemblyjs/floating-point-hex-parser@1.11.1: resolution: {integrity: sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==} - /@webassemblyjs/floating-point-hex-parser@1.11.5: - resolution: {integrity: sha512-1j1zTIC5EZOtCplMBG/IEwLtUojtwFVwdyVMbL/hwWqbzlQoJsWCOavrdnLkemwNoC/EOwtUFch3fuo+cbcXYQ==} + /@webassemblyjs/floating-point-hex-parser@1.11.6: + resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} dev: true /@webassemblyjs/helper-api-error@1.11.1: resolution: {integrity: sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==} - /@webassemblyjs/helper-api-error@1.11.5: - resolution: {integrity: sha512-L65bDPmfpY0+yFrsgz8b6LhXmbbs38OnwDCf6NpnMUYqa+ENfE5Dq9E42ny0qz/PdR0LJyq/T5YijPnU8AXEpA==} + /@webassemblyjs/helper-api-error@1.11.6: + resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} dev: true /@webassemblyjs/helper-buffer@1.11.1: resolution: {integrity: sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==} - /@webassemblyjs/helper-buffer@1.11.5: - resolution: {integrity: sha512-fDKo1gstwFFSfacIeH5KfwzjykIE6ldh1iH9Y/8YkAZrhmu4TctqYjSh7t0K2VyDSXOZJ1MLhht/k9IvYGcIxg==} + /@webassemblyjs/helper-buffer@1.11.6: + resolution: {integrity: sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==} dev: true /@webassemblyjs/helper-numbers@1.11.1: @@ -3684,19 +3710,19 @@ packages: '@webassemblyjs/helper-api-error': 1.11.1 '@xtuc/long': 4.2.2 - /@webassemblyjs/helper-numbers@1.11.5: - resolution: {integrity: sha512-DhykHXM0ZABqfIGYNv93A5KKDw/+ywBFnuWybZZWcuzWHfbp21wUfRkbtz7dMGwGgT4iXjWuhRMA2Mzod6W4WA==} + /@webassemblyjs/helper-numbers@1.11.6: + resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.11.5 - '@webassemblyjs/helper-api-error': 1.11.5 + '@webassemblyjs/floating-point-hex-parser': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 '@xtuc/long': 4.2.2 dev: true /@webassemblyjs/helper-wasm-bytecode@1.11.1: resolution: {integrity: sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==} - /@webassemblyjs/helper-wasm-bytecode@1.11.5: - resolution: {integrity: sha512-oC4Qa0bNcqnjAowFn7MPCETQgDYytpsfvz4ujZz63Zu/a/v71HeCAAmZsgZ3YVKec3zSPYytG3/PrRCqbtcAvA==} + /@webassemblyjs/helper-wasm-bytecode@1.11.6: + resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} dev: true /@webassemblyjs/helper-wasm-section@1.11.1: @@ -3707,13 +3733,13 @@ packages: '@webassemblyjs/helper-wasm-bytecode': 1.11.1 '@webassemblyjs/wasm-gen': 1.11.1 - /@webassemblyjs/helper-wasm-section@1.11.5: - resolution: {integrity: sha512-uEoThA1LN2NA+K3B9wDo3yKlBfVtC6rh0i4/6hvbz071E8gTNZD/pT0MsBf7MeD6KbApMSkaAK0XeKyOZC7CIA==} + /@webassemblyjs/helper-wasm-section@1.11.6: + resolution: {integrity: sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==} dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-buffer': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/wasm-gen': 1.11.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/helper-buffer': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/wasm-gen': 1.11.6 dev: true /@webassemblyjs/ieee754@1.11.1: @@ -3721,8 +3747,8 @@ packages: dependencies: '@xtuc/ieee754': 1.2.0 - /@webassemblyjs/ieee754@1.11.5: - resolution: {integrity: sha512-37aGq6qVL8A8oPbPrSGMBcp38YZFXcHfiROflJn9jxSdSMMM5dS5P/9e2/TpaJuhE+wFrbukN2WI6Hw9MH5acg==} + /@webassemblyjs/ieee754@1.11.6: + resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} dependencies: '@xtuc/ieee754': 1.2.0 dev: true @@ -3732,8 +3758,8 @@ packages: dependencies: '@xtuc/long': 4.2.2 - /@webassemblyjs/leb128@1.11.5: - resolution: {integrity: sha512-ajqrRSXaTJoPW+xmkfYN6l8VIeNnR4vBOTQO9HzR7IygoCcKWkICbKFbVTNMjMgMREqXEr0+2M6zukzM47ZUfQ==} + /@webassemblyjs/leb128@1.11.6: + resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} dependencies: '@xtuc/long': 4.2.2 dev: true @@ -3741,8 +3767,8 @@ packages: /@webassemblyjs/utf8@1.11.1: resolution: {integrity: sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==} - /@webassemblyjs/utf8@1.11.5: - resolution: {integrity: sha512-WiOhulHKTZU5UPlRl53gHR8OxdGsSOxqfpqWeA2FmcwBMaoEdz6b2x2si3IwC9/fSPLfe8pBMRTHVMk5nlwnFQ==} + /@webassemblyjs/utf8@1.11.6: + resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} dev: true /@webassemblyjs/wasm-edit@1.11.1: @@ -3757,17 +3783,17 @@ packages: '@webassemblyjs/wasm-parser': 1.11.1 '@webassemblyjs/wast-printer': 1.11.1 - /@webassemblyjs/wasm-edit@1.11.5: - resolution: {integrity: sha512-C0p9D2fAu3Twwqvygvf42iGCQ4av8MFBLiTb+08SZ4cEdwzWx9QeAHDo1E2k+9s/0w1DM40oflJOpkZ8jW4HCQ==} + /@webassemblyjs/wasm-edit@1.11.6: + resolution: {integrity: sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==} dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-buffer': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/helper-wasm-section': 1.11.5 - '@webassemblyjs/wasm-gen': 1.11.5 - '@webassemblyjs/wasm-opt': 1.11.5 - '@webassemblyjs/wasm-parser': 1.11.5 - '@webassemblyjs/wast-printer': 1.11.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/helper-buffer': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-wasm-section': 1.11.6 + '@webassemblyjs/wasm-gen': 1.11.6 + '@webassemblyjs/wasm-opt': 1.11.6 + '@webassemblyjs/wasm-parser': 1.11.6 + '@webassemblyjs/wast-printer': 1.11.6 dev: true /@webassemblyjs/wasm-gen@1.11.1: @@ -3779,14 +3805,14 @@ packages: '@webassemblyjs/leb128': 1.11.1 '@webassemblyjs/utf8': 1.11.1 - /@webassemblyjs/wasm-gen@1.11.5: - resolution: {integrity: sha512-14vteRlRjxLK9eSyYFvw1K8Vv+iPdZU0Aebk3j6oB8TQiQYuO6hj9s4d7qf6f2HJr2khzvNldAFG13CgdkAIfA==} + /@webassemblyjs/wasm-gen@1.11.6: + resolution: {integrity: sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==} dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/ieee754': 1.11.5 - '@webassemblyjs/leb128': 1.11.5 - '@webassemblyjs/utf8': 1.11.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 dev: true /@webassemblyjs/wasm-opt@1.11.1: @@ -3797,13 +3823,13 @@ packages: '@webassemblyjs/wasm-gen': 1.11.1 '@webassemblyjs/wasm-parser': 1.11.1 - /@webassemblyjs/wasm-opt@1.11.5: - resolution: {integrity: sha512-tcKwlIXstBQgbKy1MlbDMlXaxpucn42eb17H29rawYLxm5+MsEmgPzeCP8B1Cl69hCice8LeKgZpRUAPtqYPgw==} + /@webassemblyjs/wasm-opt@1.11.6: + resolution: {integrity: sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==} dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-buffer': 1.11.5 - '@webassemblyjs/wasm-gen': 1.11.5 - '@webassemblyjs/wasm-parser': 1.11.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/helper-buffer': 1.11.6 + '@webassemblyjs/wasm-gen': 1.11.6 + '@webassemblyjs/wasm-parser': 1.11.6 dev: true /@webassemblyjs/wasm-parser@1.11.1: @@ -3816,15 +3842,15 @@ packages: '@webassemblyjs/leb128': 1.11.1 '@webassemblyjs/utf8': 1.11.1 - /@webassemblyjs/wasm-parser@1.11.5: - resolution: {integrity: sha512-SVXUIwsLQlc8srSD7jejsfTU83g7pIGr2YYNb9oHdtldSxaOhvA5xwvIiWIfcX8PlSakgqMXsLpLfbbJ4cBYew==} + /@webassemblyjs/wasm-parser@1.11.6: + resolution: {integrity: sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==} dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-api-error': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/ieee754': 1.11.5 - '@webassemblyjs/leb128': 1.11.5 - '@webassemblyjs/utf8': 1.11.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 dev: true /@webassemblyjs/wast-printer@1.11.1: @@ -3833,10 +3859,10 @@ packages: '@webassemblyjs/ast': 1.11.1 '@xtuc/long': 4.2.2 - /@webassemblyjs/wast-printer@1.11.5: - resolution: {integrity: sha512-f7Pq3wvg3GSPUPzR0F6bmI89Hdb+u9WXrSKc4v+N0aV0q6r42WoF92Jp2jEorBEBRoRNXgjp53nBniDXcqZYPA==} + /@webassemblyjs/wast-printer@1.11.6: + resolution: {integrity: sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==} dependencies: - '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/ast': 1.11.6 '@xtuc/long': 4.2.2 dev: true @@ -3870,20 +3896,20 @@ packages: acorn: 7.4.1 acorn-walk: 7.2.0 - /acorn-import-assertions@1.8.0(acorn@8.10.0): + /acorn-import-assertions@1.8.0(acorn@8.8.0): resolution: {integrity: sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==} peerDependencies: acorn: ^8 dependencies: - acorn: 8.10.0 - dev: true + acorn: 8.8.0 - /acorn-import-assertions@1.8.0(acorn@8.8.0): - resolution: {integrity: sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==} + /acorn-import-assertions@1.9.0(acorn@8.11.3): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} peerDependencies: acorn: ^8 dependencies: - acorn: 8.8.0 + acorn: 8.11.3 + dev: true /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -3917,6 +3943,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /acorn@8.8.0: resolution: {integrity: sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==} engines: {node: '>=0.4.0'} @@ -4294,7 +4326,7 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.8 cosmiconfig: 7.0.1 resolve: 1.22.1 @@ -4387,7 +4419,7 @@ packages: '@babel/preset-env': 7.19.1(@babel/core@7.19.1) '@babel/preset-react': 7.18.6(@babel/core@7.19.1) '@babel/preset-typescript': 7.18.6(@babel/core@7.19.1) - '@babel/runtime': 7.19.0 + '@babel/runtime': 7.23.8 babel-plugin-macros: 3.1.0 babel-plugin-transform-react-remove-prop-types: 0.4.24 transitivePeerDependencies: @@ -4457,10 +4489,10 @@ packages: resolution: {integrity: sha512-oSX26F37V7QV7NCE53PPEL45d7EGXmBgHG3pDpZvcRaKVzWMqIRL9wcqJUyEha1esFtM3NJzvmxFXDxjJYD0jQ==} dev: false - /bootstrap@5.3.0(@popperjs/core@2.11.8): - resolution: {integrity: sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==} + /bootstrap@5.3.2(@popperjs/core@2.11.8): + resolution: {integrity: sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==} peerDependencies: - '@popperjs/core': ^2.11.7 + '@popperjs/core': ^2.11.8 dependencies: '@popperjs/core': 2.11.8 dev: false @@ -4502,15 +4534,15 @@ packages: node-releases: 2.0.6 update-browserslist-db: 1.0.9(browserslist@4.21.4) - /browserslist@4.21.5: - resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} + /browserslist@4.22.2: + resolution: {integrity: sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001481 - electron-to-chromium: 1.4.369 - node-releases: 2.0.10 - update-browserslist-db: 1.0.11(browserslist@4.21.5) + caniuse-lite: 1.0.30001579 + electron-to-chromium: 1.4.640 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.22.2) dev: true /bser@2.1.1: @@ -4567,7 +4599,7 @@ packages: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} dependencies: pascal-case: 3.1.2 - tslib: 2.4.0 + tslib: 2.6.2 /camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} @@ -4601,8 +4633,8 @@ packages: /caniuse-lite@1.0.30001408: resolution: {integrity: sha512-DdUCktgMSM+1ndk9EFMZcavsGszV7zxV9O7MtOHniTa/iyAIwJCF0dFVBdU9SijJbfh29hC9bCs07wu8pjnGJQ==} - /caniuse-lite@1.0.30001481: - resolution: {integrity: sha512-KCqHwRnaa1InZBtqXzP98LPg0ajCVujMKjqKDhZEthIpAsJl/YEIa3YvXjGXPVqzZVguccuu7ga9KOE1J9rKPQ==} + /caniuse-lite@1.0.30001579: + resolution: {integrity: sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==} dev: true /case-sensitive-paths-webpack-plugin@2.4.0: @@ -4656,7 +4688,7 @@ packages: normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 /chrome-trace-event@1.0.3: resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} @@ -5198,6 +5230,10 @@ packages: /csstype@3.1.1: resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + dev: false + /customize-cra@1.0.0: resolution: {integrity: sha512-DbtaLuy59224U+xCiukkxSq8clq++MOtJ1Et7LED1fLszWe88EoblEYFBJ895sB1mC6B4uu3xPT/IjClELhMbA==} dependencies: @@ -5748,8 +5784,8 @@ packages: /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: - '@babel/runtime': 7.22.5 - csstype: 3.1.1 + '@babel/runtime': 7.23.8 + csstype: 3.1.3 dev: false /dom-serializer@0.2.2: @@ -5804,7 +5840,7 @@ packages: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.4.0 + tslib: 2.6.2 /dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} @@ -5840,8 +5876,8 @@ packages: /electron-to-chromium@1.4.256: resolution: {integrity: sha512-x+JnqyluoJv8I0U9gVe+Sk2st8vF0CzMt78SXxuoWCooLLY2k5VerIBdpvG7ql6GKI4dzNnPjmqgDJ76EdaAKw==} - /electron-to-chromium@1.4.369: - resolution: {integrity: sha512-LfxbHXdA/S+qyoTEA4EbhxGjrxx7WK2h6yb5K2v0UCOufUKX+VZaHbl3svlzZfv9sGseym/g3Ne4DpsgRULmqg==} + /electron-to-chromium@1.4.640: + resolution: {integrity: sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==} dev: true /elkjs@0.8.2: @@ -5878,11 +5914,11 @@ packages: resolution: {integrity: sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==} engines: {node: '>=10.13.0'} dependencies: - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 tapable: 2.2.1 - /enhanced-resolve@5.13.0: - resolution: {integrity: sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg==} + /enhanced-resolve@5.15.0: + resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} engines: {node: '>=10.13.0'} dependencies: graceful-fs: 4.2.11 @@ -5998,8 +6034,8 @@ packages: /es-module-lexer@0.9.3: resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} - /es-module-lexer@1.2.1: - resolution: {integrity: sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==} + /es-module-lexer@1.4.1: + resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} dev: true /es-set-tostringtag@2.0.2: @@ -6143,7 +6179,7 @@ packages: eslint: 8.53.0 dev: true - /eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0)(jest@27.5.1)(typescript@4.9.5): + /eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0)(jest@27.5.1)(typescript@4.9.5): resolution: {integrity: sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -6161,7 +6197,7 @@ packages: babel-preset-react-app: 10.0.1 confusing-browser-globals: 1.0.11 eslint: 8.53.0 - eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0) + eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0) eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.38.0)(eslint@8.53.0) eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.38.0)(eslint@8.53.0)(jest@27.5.1)(typescript@4.9.5) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.53.0) @@ -6290,7 +6326,7 @@ packages: regexpp: 3.2.0 dev: true - /eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0): + /eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0): resolution: {integrity: sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==} engines: {node: '>=12.0.0'} peerDependencies: @@ -6298,8 +6334,8 @@ packages: '@babel/plugin-transform-react-jsx': ^7.14.9 eslint: ^8.1.0 dependencies: - '@babel/plugin-syntax-flow': 7.21.4(@babel/core@7.19.1) - '@babel/plugin-transform-react-jsx': 7.21.0(@babel/core@7.19.1) + '@babel/plugin-syntax-flow': 7.23.3(@babel/core@7.19.1) + '@babel/plugin-transform-react-jsx': 7.23.4(@babel/core@7.19.1) eslint: 8.53.0 lodash: 4.17.21 string-natural-compare: 3.0.1 @@ -6976,8 +7012,8 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -7894,7 +7930,7 @@ packages: ci-info: 3.4.0 deepmerge: 4.2.2 glob: 7.2.3 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-circus: 27.5.1 jest-environment-jsdom: 27.5.1 jest-environment-node: 27.5.1 @@ -8006,7 +8042,7 @@ packages: micromatch: 4.0.5 walker: 1.0.8 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 /jest-jasmine2@27.5.1: resolution: {integrity: sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==} @@ -8066,7 +8102,7 @@ packages: '@jest/types': 27.5.1 '@types/stack-utils': 2.0.1 chalk: 4.1.2 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 micromatch: 4.0.5 pretty-format: 27.5.1 slash: 3.0.0 @@ -8149,7 +8185,7 @@ packages: '@types/node': 16.11.59 chalk: 4.1.2 emittery: 0.8.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-docblock: 27.5.1 jest-environment-jsdom: 27.5.1 jest-environment-node: 27.5.1 @@ -8184,7 +8220,7 @@ packages: collect-v8-coverage: 1.0.1 execa: 5.1.1 glob: 7.2.3 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-haste-map: 27.5.1 jest-message-util: 27.5.1 jest-mock: 27.5.1 @@ -8202,7 +8238,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@types/node': 16.11.59 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 /jest-snapshot@27.5.1: resolution: {integrity: sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==} @@ -8220,7 +8256,7 @@ packages: babel-preset-current-node-syntax: 1.0.1(@babel/core@7.19.1) chalk: 4.1.2 expect: 27.5.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 jest-diff: 27.5.1 jest-get-type: 27.5.1 jest-haste-map: 27.5.1 @@ -8447,7 +8483,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 /jsonp@0.2.1: resolution: {integrity: sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==} @@ -8663,7 +8699,7 @@ packages: /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.4.0 + tslib: 2.6.2 /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} @@ -8917,7 +8953,7 @@ packages: jsonp: 0.2.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5) + react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5) transitivePeerDependencies: - supports-color dev: false @@ -8926,7 +8962,7 @@ packages: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.4.0 + tslib: 2.6.2 /node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} @@ -8935,8 +8971,8 @@ packages: /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - /node-releases@2.0.10: - resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==} + /node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} dev: true /node-releases@2.0.6: @@ -9202,7 +9238,7 @@ packages: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: dot-case: 3.0.4 - tslib: 2.4.0 + tslib: 2.6.2 /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -9230,7 +9266,7 @@ packages: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: no-case: 3.0.4 - tslib: 2.4.0 + tslib: 2.6.2 /path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} @@ -10122,13 +10158,13 @@ packages: resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} engines: {node: '>=6'} - /purgecss-webpack-plugin@4.1.3(webpack@5.80.0): + /purgecss-webpack-plugin@4.1.3(webpack@5.89.0): resolution: {integrity: sha512-1OHS0WE935w66FjaFSlV06ycmn3/A8a6Q+iVUmmCYAujQ1HPdX+psMXUhASEW0uF1PYEpOlhMc5ApigVqYK08g==} peerDependencies: webpack: '*' dependencies: purgecss: 4.1.3 - webpack: 5.80.0 + webpack: 5.89.0 webpack-sources: 3.2.3 dev: true @@ -10216,7 +10252,7 @@ packages: object-assign: 4.1.1 promise: 8.2.0 raf: 3.4.1 - regenerator-runtime: 0.13.9 + regenerator-runtime: 0.13.11 whatwg-fetch: 3.6.2 /react-app-rewired@2.2.1(react-scripts@5.0.1): @@ -10225,12 +10261,12 @@ packages: peerDependencies: react-scripts: '>=2.1.3' dependencies: - react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5) + react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5) semver: 5.7.1 dev: true - /react-bootstrap@2.7.4(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-EPKPwhfbxsKsNBhJBitJwqul9fvmlYWSft6jWE2EpqhEyjhqIqNihvQo2onE5XtS+QHOavUSNmA+8Lnv5YeAyg==} + /react-bootstrap@2.10.0(@types/react@18.0.20)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-87gRP69VAfeU2yKgp8RI3HvzhPNrnYIV2QNranYXataz3ef+k7OhvKGGdxQLQfUsQ2RTmlY66tn4pdFrZ94hNg==} peerDependencies: '@types/react': '>=16.14.8' react: '>=16.14.0' @@ -10239,11 +10275,11 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.22.5 - '@restart/hooks': 0.4.9(react@18.2.0) + '@babel/runtime': 7.23.8 + '@restart/hooks': 0.4.15(react@18.2.0) '@restart/ui': 1.6.6(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.0.20 - '@types/react-transition-group': 4.4.6 + '@types/react-transition-group': 4.4.10 classnames: 2.3.2 dom-helpers: 5.2.1 invariant: 2.2.4 @@ -10388,7 +10424,7 @@ packages: react: 18.2.0 dev: false - /react-scripts@5.0.1(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5): + /react-scripts@5.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0)(react@18.2.0)(sass@1.54.9)(ts-node@10.9.1)(typescript@4.9.5): resolution: {integrity: sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==} engines: {node: '>=14.0.0'} hasBin: true @@ -10416,7 +10452,7 @@ packages: dotenv: 10.0.0 dotenv-expand: 5.1.0 eslint: 8.53.0 - eslint-config-react-app: 7.0.1(@babel/plugin-syntax-flow@7.21.4)(@babel/plugin-transform-react-jsx@7.21.0)(eslint@8.53.0)(jest@27.5.1)(typescript@4.9.5) + eslint-config-react-app: 7.0.1(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint@8.53.0)(jest@27.5.1)(typescript@4.9.5) eslint-webpack-plugin: 3.2.0(eslint@8.53.0)(webpack@5.74.0) file-loader: 6.2.0(webpack@5.74.0) fs-extra: 10.1.0 @@ -10450,7 +10486,7 @@ packages: webpack-manifest-plugin: 4.1.1(webpack@5.74.0) workbox-webpack-plugin: 6.5.4(webpack@5.74.0) optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 transitivePeerDependencies: - '@babel/plugin-syntax-flow' - '@babel/plugin-transform-react-jsx' @@ -10490,7 +10526,7 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.8 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -10593,13 +10629,13 @@ packages: /regenerator-runtime@0.13.9: resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==} - /regenerator-runtime@0.14.0: - resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} /regenerator-transform@0.15.0: resolution: {integrity: sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==} dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.8 /regex-parser@2.2.11: resolution: {integrity: sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==} @@ -10782,14 +10818,14 @@ packages: engines: {node: '>=10.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 /rollup@3.29.4: resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /run-applescript@5.0.0: @@ -10907,11 +10943,11 @@ packages: ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) - /schema-utils@3.1.2: - resolution: {integrity: sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg==} + /schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/json-schema': 7.0.13 + '@types/json-schema': 7.0.15 ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) dev: true @@ -10995,8 +11031,8 @@ packages: dependencies: randombytes: 2.1.0 - /serialize-javascript@6.0.1: - resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} + /serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} dependencies: randombytes: 2.1.0 dev: true @@ -11573,8 +11609,8 @@ packages: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - /terser-webpack-plugin@5.3.6(webpack@5.74.0): - resolution: {integrity: sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==} + /terser-webpack-plugin@5.3.10(webpack@5.89.0): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -11589,15 +11625,16 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.15 + '@jridgewell/trace-mapping': 0.3.22 jest-worker: 27.5.1 - schema-utils: 3.1.1 - serialize-javascript: 6.0.0 - terser: 5.15.0 - webpack: 5.74.0 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.27.0 + webpack: 5.89.0 + dev: true - /terser-webpack-plugin@5.3.7(webpack@5.80.0): - resolution: {integrity: sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==} + /terser-webpack-plugin@5.3.6(webpack@5.74.0): + resolution: {integrity: sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -11612,13 +11649,12 @@ packages: uglify-js: optional: true dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.15 jest-worker: 27.5.1 - schema-utils: 3.1.2 - serialize-javascript: 6.0.1 - terser: 5.17.1 - webpack: 5.80.0 - dev: true + schema-utils: 3.1.1 + serialize-javascript: 6.0.0 + terser: 5.15.0 + webpack: 5.74.0 /terser@5.15.0: resolution: {integrity: sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==} @@ -11630,13 +11666,13 @@ packages: commander: 2.20.3 source-map-support: 0.5.21 - /terser@5.17.1: - resolution: {integrity: sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==} + /terser@5.27.0: + resolution: {integrity: sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==} engines: {node: '>=10'} hasBin: true dependencies: - '@jridgewell/source-map': 0.3.3 - acorn: 8.10.0 + '@jridgewell/source-map': 0.3.5 + acorn: 8.11.3 commander: 2.20.3 source-map-support: 0.5.21 dev: true @@ -11791,12 +11827,8 @@ packages: /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - /tslib@2.4.0: - resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} - /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: true /tsutils@3.21.0(typescript@4.9.5): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} @@ -11925,15 +11957,15 @@ packages: peerDependencies: react: '>=15.0.0' dependencies: - '@babel/runtime': 7.22.5 - '@types/react': 18.2.22 + '@babel/runtime': 7.23.8 + '@types/react': 18.0.20 invariant: 2.2.4 react: 18.2.0 react-lifecycles-compat: 3.0.4 dev: false - /uncontrollable@8.0.2(react@18.2.0): - resolution: {integrity: sha512-/GDx+K1STGtpgTsj5Dj3J51YaKxZDblbCQHTH1zHLuoBEWodj6MjtRVv3TUijj1JYLRLSFsFzN8NV4M3QV4d9w==} + /uncontrollable@8.0.4(react@18.2.0): + resolution: {integrity: sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==} peerDependencies: react: '>=16.14.0' dependencies: @@ -11989,13 +12021,13 @@ packages: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} - /update-browserslist-db@1.0.11(browserslist@4.21.5): - resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} + /update-browserslist-db@1.0.13(browserslist@4.22.2): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.21.5 + browserslist: 4.22.2 escalade: 3.1.1 picocolors: 1.0.0 dev: true @@ -12117,7 +12149,7 @@ packages: rollup: 3.29.4 sass: 1.54.9 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /void-elements@3.1.0: @@ -12152,7 +12184,7 @@ packages: engines: {node: '>=10.13.0'} dependencies: glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 + graceful-fs: 4.2.10 /wbuf@1.7.3: resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} @@ -12300,8 +12332,8 @@ packages: - esbuild - uglify-js - /webpack@5.80.0: - resolution: {integrity: sha512-OIMiq37XK1rWO8mH9ssfFKZsXg4n6klTEDL7S8/HqbAOBBaiy8ABvXvz0dDCXeEF9gqwxSvVk611zFPjS8hJxA==} + /webpack@5.89.0: + resolution: {integrity: sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -12310,17 +12342,17 @@ packages: webpack-cli: optional: true dependencies: - '@types/eslint-scope': 3.7.4 - '@types/estree': 1.0.1 - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/wasm-edit': 1.11.5 - '@webassemblyjs/wasm-parser': 1.11.5 - acorn: 8.10.0 - acorn-import-assertions: 1.8.0(acorn@8.10.0) - browserslist: 4.21.5 + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/wasm-edit': 1.11.6 + '@webassemblyjs/wasm-parser': 1.11.6 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.22.2 chrome-trace-event: 1.0.3 - enhanced-resolve: 5.13.0 - es-module-lexer: 1.2.1 + enhanced-resolve: 5.15.0 + es-module-lexer: 1.4.1 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -12329,9 +12361,9 @@ packages: loader-runner: 4.3.0 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 3.1.2 + schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.7(webpack@5.80.0) + terser-webpack-plugin: 5.3.10(webpack@5.89.0) watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -12461,7 +12493,7 @@ packages: '@apideck/better-ajv-errors': 0.3.6(ajv@8.11.0) '@babel/core': 7.19.1 '@babel/preset-env': 7.19.1(@babel/core@7.19.1) - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.8 '@rollup/plugin-babel': 5.3.1(@babel/core@7.19.1)(rollup@2.79.0) '@rollup/plugin-node-resolve': 11.2.1(rollup@2.79.0) '@rollup/plugin-replace': 2.4.2(rollup@2.79.0) diff --git a/ui/public/index.html b/ui/public/index.html index eeb5fb11b..f5a9c1417 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -19,7 +19,7 @@ --> - + diff --git a/ui/src/assets/images/default-avatar.svg b/ui/src/assets/images/default-avatar.svg index 08e0a60bc..7ecd67fc9 100644 --- a/ui/src/assets/images/default-avatar.svg +++ b/ui/src/assets/images/default-avatar.svg @@ -1,3 +1,21 @@ + diff --git a/ui/src/common/color.scss b/ui/src/common/color.scss new file mode 100644 index 000000000..664368731 --- /dev/null +++ b/ui/src/common/color.scss @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + + +:root { + --an-side-nav-link: rgba(0, 0, 0, 0.65); + --an-toolbar-divider: rgba(0, 0, 0, 0.1); + --an-ced4da: #ced4da; + --an-e9ecef: #e9ecef; + --an-pre: #161b22; + --an-6c757d: #6c757d; + --an-212529: #212529; + --an-gray-300: var(--bs-gray-300); + --an-white: #fff; + --an-inbox-warning:#fff3cd80; + --an-f5: #f5f5f5; + --an-answer-item-border-top: rgba(0, 0, 0, .125); + --an-answer-inbox-nav-border-top: var(--bs-border-color); + --an-comment-item-border-bottom: var(--bs-colors-gray-200, #E9ECEF); + --an-editor-toolbar-hover: #f8f9fa; + --ans-editor-toolbar-focus: #dae0e5; + --an-editor-placeholder-color: #6c757d; + --an-side-nav-link-hover-color: black; + --an-invite-answer-item-active: #e9ecef; + --an-alert-exist-color: #055160; +} + + +[data-bs-theme="dark"] { + --an-side-nav-link: rgba(255, 255, 255, 0.65); + --an-toolbar-divider: rgba(255, 255, 255, 0.3); + --an-ced4da: var(--bs-border-color); + --an-e9ecef: #161b22; + --an-pre: #161b22; + --an-6c757d: var(--bs-body-color); + --an-212529: var(--bs-body-color); + --an-gray-300: #161b22; + --an-white: #000; + --an-inbox-warning:#38363180; + --an-f5: var(--bs-body-bg); + --an-answer-item-border-top: var(--bs-border-color); + --an-answer-inbox-nav-border-top: var(--bs-border-color); + --an-comment-item-border-bottom: var(--bs-border-color); + --an-editor-toolbar-hover: var(--bs-tertiary-bg); + --ans-editor-toolbar-focus: var(--bs-tertiary-bg); + --an-editor-placeholder-color: var(--bs-body-color); + --an-side-nav-link-hover-color: var(--bs-body-color); + --an-invite-answer-item-active: var(--bs-tertiary-bg); + --an-alert-exist-color: #60cee4; + } + +[data-bs-theme="dark"] { + .link-dark { + color: rgba(var(--bs-emphasis-color-rgb),0.8)!important; + } + /** CodeMirror **/ + + .CodeMirror { + background: var(--bs-body-bg); + color: var(--bs-body-color); + } + .CodeMirror-cursor { + border-left: 1px solid var(--bs-body-color); + } + .cm-header, .cm-link, .cm-url { + color: var(--bs-body-color); + } + div.CodeMirror-selected { background: rgba(127, 190, 244, 0.4); } + .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: rgba(84,174,255,0.4); } + .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: rgba(84,174,255,0.4); } + + .bg-light { + background-color: rgba(0, 0, 0, 0.5) !important; + } + .text-bg-dark { + color: #000 !important; + background-color: RGBA(255, 255, 255, var(--bs-bg-opacity, 1)) !important; + } + /** side nav **/ + #sideNav { + .nav-link:hover, .nav-link.active { + background-color: #2b3035 !important; + } + } + + /** tag **/ + .badge-tag { + background: rgba($blue-900, 0.5); + color: $blue-300; + &:hover { + color: $blue-300; + background: $blue-900; + } + } + .badge-tag-required { + background: $gray-800; + color:$gray-300; + border: 1px solid $gray-600; + &:hover { + color: $gray-300; + background: $gray-700; + } + } + .badge-tag-reserved { + color: $orange-300; + background: rgba($orange-900, 0.5); + border: 1px solid $orange-900; + &:hover { + color: $orange-300; + background: rgba($orange-900, 1); + } + } + + +} diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 87b2377b4..373e41d21 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -28,6 +28,8 @@ 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 QUESTIONS_ORDER_STORAGE_KEY = '_a_qok_'; +export const DEFAULT_THEME = 'system'; +export const ADMIN_PRIVILEGE_CUSTOM_LEVEL = 99; export const USER_AGENT_NAMES = { SegmentFault: 'SegmentFault', @@ -634,3 +636,5 @@ export const SYSTEM_AVATAR_OPTIONS = [ value: 'gravatar', }, ]; + +export const TAG_SLUG_NAME_MAX_LENGTH = 35; diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 68f3be952..201a5ae0e 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -440,6 +440,7 @@ export type themeConfig = { }; export interface AdminSettingsTheme { theme: string; + color_scheme: string; theme_options?: { label: string; value: string }[]; theme_config: Record; } @@ -622,12 +623,17 @@ export interface NotificationConfigItem { key: string; } export interface NotificationConfig { - all_new_question: NotificationConfigItem[]; - all_new_question_for_following_tags: NotificationConfigItem[]; - inbox: NotificationConfigItem[]; + all_new_question: NotificationConfigItem; + all_new_question_for_following_tags: NotificationConfigItem; + inbox: NotificationConfigItem; } export interface ActivatedPlugin { name: string; slug_name: string; } + +export interface UserPluginsConfigRes { + name: string; + slug_name: string; +} diff --git a/ui/src/components/Comment/components/ActionBar/index.tsx b/ui/src/components/Comment/components/ActionBar/index.tsx index 82d6619e8..c55bf40e2 100644 --- a/ui/src/components/Comment/components/ActionBar/index.tsx +++ b/ui/src/components/Comment/components/ActionBar/index.tsx @@ -41,20 +41,27 @@ const ActionBar = ({ const { t } = useTranslation('translation', { keyPrefix: 'comment' }); return ( -
-
+
+
{userStatus !== 'deleted' ? ( - {nickName} + + {nickName} + ) : ( {nickName} )} - + )}
diff --git a/ui/src/components/Counts/index.tsx b/ui/src/components/Counts/index.tsx index e3b7dbef9..3b5fbe3a9 100644 --- a/ui/src/components/Counts/index.tsx +++ b/ui/src/components/Counts/index.tsx @@ -51,7 +51,7 @@ const Index: FC = ({ return (
{showVotes && ( -
+
{data.votes} {t('votes')} @@ -60,7 +60,7 @@ const Index: FC = ({ )} {showAccepted && ( -
+
{t('accepted')}
@@ -68,7 +68,7 @@ const Index: FC = ({ {showAnswers && (
{isAccepted ? ( @@ -82,7 +82,7 @@ const Index: FC = ({
)} {showViews && ( - + {data.views} {t('views')} diff --git a/ui/src/components/CustomizeTheme/index.tsx b/ui/src/components/CustomizeTheme/index.tsx index 46c0c6ec4..ff49e5507 100644 --- a/ui/src/components/CustomizeTheme/index.tsx +++ b/ui/src/components/CustomizeTheme/index.tsx @@ -58,6 +58,22 @@ const Index: FC = () => { .round() .array()} } + :root[data-bs-theme='dark'] { + --bs-link-color: ${tintColor(primaryColor, 0.6).hex()}; + --bs-link-color-rgb: ${tintColor(primaryColor, 0.6) + .round() + .array()}; + --bs-link-hover-color: ${shiftColor( + tintColor(primaryColor, 0.6), + -0.8, + ).hex()}; + --bs-link-hover-color-rgb: ${shiftColor( + tintColor(primaryColor, 0.6), + -0.8, + ) + .round() + .array()}; + } .nav-pills { --bs-nav-pills-link-active-bg: ${primaryColor.hex()}; } @@ -109,6 +125,12 @@ const Index: FC = () => { 0.5, )}%27/%3e%3c/svg%3e"); } + .tag-selector-wrap--focus { + box-shadow: 0 0 0 0.25rem ${primaryColor + .fade(0.75) + .string()} !important; + border-color: ${tintColor(primaryColor, 0.5)} !important; + } .dropdown-menu { --bs-dropdown-link-active-bg: rgb(var(--bs-primary-rgb)); } @@ -128,6 +150,21 @@ const Index: FC = () => { .badge-tag:not(.badge-tag-reserved, .badge-tag-required):hover { background-color: ${tintColor(primaryColor, 0.2).hex()}; } + + [data-bs-theme="dark"] .badge-tag:not(.badge-tag-reserved):not(.badge-tag-required) { + background-color: rgba(${shadeColor(primaryColor, 0.2) + .rgb() + .array() + .join(',')}, .5) !important; + color: ${tintColor(primaryColor, 0.4).hex()} !important; + } + [data-bs-theme="dark"] .badge-tag:not(.badge-tag-reserved, .badge-tag-required):hover { + background-color: rgba(${tintColor( + primaryColor, + 0.4, + ).hex()}, 0.8) !important; + color: ${tintColor(primaryColor, 0.6).hex()} !important; + } `} )} diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx index 48f1b0398..95737db6d 100644 --- a/ui/src/components/Editor/ToolBars/image.tsx +++ b/ui/src/components/Editor/ToolBars/image.tsx @@ -117,7 +117,7 @@ const Image = ({ editorInstance }) => { editor.replaceSelection(loadingText); const urls = await upload(fileList).catch((ex) => { - console.log('ex: ', ex); + console.error('upload file error: ', ex); }); const text: string[] = []; diff --git a/ui/src/components/Editor/index.scss b/ui/src/components/Editor/index.scss index e71d27fb2..67b5f4549 100644 --- a/ui/src/components/Editor/index.scss +++ b/ui/src/components/Editor/index.scss @@ -20,16 +20,16 @@ .md-editor-wrap { display: flex; flex-direction: column; - background-color: #fff; - border: 1px solid #ced4da; + background-color: var(-bs-body-bg); + border: 1px solid var(--an-ced4da); overflow: hidden; .toolbar-wrap { - border-bottom: 1px solid #ced4da; + border-bottom: 1px solid var(--an-ced4da); .toolbar-divider { float: left; width: 1px; height: 15px; - background-color: rgba(0, 0, 0, 0.1); + background-color: var(--an-toolbar-divider); margin: 10px 8px; } .toolbar-item-wrap { @@ -57,7 +57,7 @@ box-sizing: border-box; outline: none; cursor: pointer; - background-color: #fff; + background-color: var(--bs-body-bg); height: 100%; width: 100%; border-radius: 0; @@ -66,10 +66,10 @@ border-radius: 3px; &:hover { - background-color: #f8f9fa; + background-color: var(--an-editor-toolbar-hover); } &:focus { - background-color: #dae0e5; + background-color: var(--ans-editor-toolbar-focus); } &.icon-heading { background-position: 0px -144px; @@ -204,7 +204,7 @@ padding: 16px 0; } .CodeMirror-placeholder { - color: #6c757d; + color: var(--an-editor-placeholder-color); } } } diff --git a/ui/src/components/FollowingTags/index.tsx b/ui/src/components/FollowingTags/index.tsx index 571acf3d7..223af5e1a 100644 --- a/ui/src/components/FollowingTags/index.tsx +++ b/ui/src/components/FollowingTags/index.tsx @@ -70,7 +70,7 @@ const Index: FC = () => { onChange={handleTagsChange} hiddenDescription hiddenCreateBtn - alwaysShowAddBtn + autoFocus /> diff --git a/ui/src/components/Header/index.scss b/ui/src/components/Header/index.scss index caf674816..1a02c3328 100644 --- a/ui/src/components/Header/index.scss +++ b/ui/src/components/Header/index.scss @@ -22,7 +22,7 @@ #header { transform: translate3d(0,0,0); --bs-navbar-padding-y: 0.75rem; - background: linear-gradient(180deg, rgb(var(--bs-primary-rgb)) 0%, rgba(var(--bs-primary-rgb), 0.95) 100%); + background: var(--bs-primary); box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15), 0 0.125rem 0.25rem rgb(0 0 0 / 8%); .logo { max-height: 2rem; @@ -70,7 +70,22 @@ // style for colored navbar &.theme-light { - background: linear-gradient(180deg, rgb(255, 255, 255) 0%, rgba(255, 255, 255, 0.95) 100%); + background: rgb(255, 255, 255); + .nav-link { + color: rgba(0, 0, 0, 0.65); + } + .placeholder-search { + box-shadow: none; + color: var(--bs-body-color); + background-color: rgba(255, 255, 255, .2); + border: $border-width $border-style #dee2e6; + &:focus { + border: $border-width $border-style $border-color; + } + &::placeholder { + color: rgba(0, 0, 0, 0.65); + } + } } .maxw-400 { diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index ee09dc1f5..4f4109906 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -103,8 +103,8 @@ const QuestionList: FC = ({ {li.status === 2 ? ` [${t('closed')}]` : ''} -
-
+
+
= ({ •
@@ -124,7 +124,7 @@ const QuestionList: FC = ({ views: li.view_count, }} isAccepted={li.accepted_answer_id >= 1} - className="ms-0 ms-md-3 mt-2 mt-md-0" + className="mt-2 mt-md-0" />
diff --git a/ui/src/components/SchemaForm/README.md b/ui/src/components/SchemaForm/README.md index c5e42e04b..13149ae41 100644 --- a/ui/src/components/SchemaForm/README.md +++ b/ui/src/components/SchemaForm/README.md @@ -183,8 +183,9 @@ export interface BaseUIOptions { empty?: string; // Will be appended to the className of the form component itself className?: classnames.Argument; + class_name?: classnames.Argument; // The className that will be attached to a form field container - fieldClassName?: classnames.Argument; + field_class_name?: classnames.Argument; // Make a form component render into simplified mode readOnly?: boolean; simplify?: boolean; diff --git a/ui/src/components/SchemaForm/components/Button.tsx b/ui/src/components/SchemaForm/components/Button.tsx index 38bad913d..0371992f2 100644 --- a/ui/src/components/SchemaForm/components/Button.tsx +++ b/ui/src/components/SchemaForm/components/Button.tsx @@ -17,31 +17,43 @@ * under the License. */ -import React, { FC, useLayoutEffect, useState } from 'react'; +import { FC, useEffect, useState } from 'react'; import { Button, ButtonProps, Spinner } from 'react-bootstrap'; import { request } from '@/utils'; import type { UIAction, FormKit } from '../types'; import { useToast } from '@/hooks'; +import { Icon } from '@/components'; interface Props { fieldName: string; text: string; action: UIAction | undefined; + actionType?: 'submit' | 'click'; + clickCallback?: () => void; formKit: FormKit; readOnly: boolean; variant?: ButtonProps['variant']; size?: ButtonProps['size']; + iconName?: string; + nowrap?: boolean; + title?: string; } const Index: FC = ({ fieldName, action, + actionType = 'submit', formKit, text = '', readOnly = false, variant = 'primary', size, + iconName = '', + nowrap = false, + clickCallback, + title, }) => { + console.log('Button.tsx: action:', title); const Toast = useToast(); const [isLoading, setLoading] = useState(false); const handleToast = (msg, type: 'success' | 'danger' = 'success') => { @@ -62,6 +74,12 @@ const Index: FC = ({ } }; const handleAction = () => { + if (actionType === 'click') { + if (typeof clickCallback === 'function') { + clickCallback(); + } + return; + } if (!action) { return; } @@ -87,13 +105,39 @@ const Index: FC = ({ setLoading(false); }); }; - useLayoutEffect(() => { + useEffect(() => { if (action?.loading?.state === 'pending') { setLoading(true); } }, []); const loadingText = action?.loading?.text || text; const disabled = isLoading || readOnly; + if (nowrap) { + return ( + + ); + } return (
@@ -102,6 +146,7 @@ const Index: FC = ({ onClick={handleAction} disabled={disabled} size={size} + title={title} variant={variant}> {isLoading ? ( <> @@ -116,6 +161,7 @@ const Index: FC = ({ ) : ( text )} + {iconName && }
); diff --git a/ui/src/components/SchemaForm/components/Input.tsx b/ui/src/components/SchemaForm/components/Input.tsx index 97229d130..1a3fe319b 100644 --- a/ui/src/components/SchemaForm/components/Input.tsx +++ b/ui/src/components/SchemaForm/components/Input.tsx @@ -63,7 +63,7 @@ const Index: FC = ({ onChange={handleChange} disabled={readOnly} isInvalid={fieldObject?.isInvalid} - style={type === 'color' ? { width: '6rem' } : {}} + style={type === 'color' ? { width: '100px', flex: 'none' } : {}} /> ); }; diff --git a/ui/src/components/SchemaForm/components/InputGroup.tsx b/ui/src/components/SchemaForm/components/InputGroup.tsx new file mode 100644 index 000000000..88cb0388c --- /dev/null +++ b/ui/src/components/SchemaForm/components/InputGroup.tsx @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FC } from 'react'; +import { InputGroup } from 'react-bootstrap'; + +import type { FormKit, InputGroupOptions } from '../types'; + +import Button from './Button'; + +interface Props { + formKitWithContext: FormKit; + uiOpt: InputGroupOptions; + prefixText?: string; + suffixText?: string; + children: React.ReactNode; +} + +const InputGroupBtn = ({ + formKitWithContext, + uiOpt, +}: { + formKitWithContext: FormKit; + uiOpt: + | InputGroupOptions['prefixBtnOptions'] + | InputGroupOptions['suffixBtnOptions']; +}) => { + return ( + - ); - })} - {initialValue?.length < 5 || alwaysShowAddBtn ? ( - - - + - {t('add_btn')} - - - {visibleMenu && ( - -
{ - e.preventDefault(); - }}> - - -
- )} - {!searchValue && - showRequiredTagText && - tags && - tags.filter((v) => v.recommend)?.length > 0 && ( -
{t('tag_required_text')}
- )} - - {tags?.map((item, index) => { - return ( - handleClick(item)}> - {item.display_name} - - ); - })} - {searchValue && tags && tags.length === 0 && ( - - {t('no_result')} - - )} - {!hiddenCreateBtn && searchValue && ( - - )} -
-
- ) : null} + )} +
{!hiddenDescription && {t('hint')}}
diff --git a/ui/src/hooks/useTagModal/index.tsx b/ui/src/hooks/useTagModal/index.tsx index fd04e79cd..50cbbe271 100644 --- a/ui/src/hooks/useTagModal/index.tsx +++ b/ui/src/hooks/useTagModal/index.tsx @@ -23,10 +23,11 @@ import { useTranslation } from 'react-i18next'; import ReactDOM from 'react-dom/client'; +import { TAG_SLUG_NAME_MAX_LENGTH } from '@/common/constants'; + const div = document.createElement('div'); const root = ReactDOM.createRoot(div); -const MAX_LENGTH = 35; interface IProps { title?: string; onConfirm?: (formData: any) => void; @@ -85,7 +86,7 @@ const useTagModal = (props: IProps = {}) => { isInvalid: true, errorMsg: t('form.fields.display_name.msg.empty'), }; - } else if (displayName.value.length > MAX_LENGTH) { + } else if (displayName.value.length > TAG_SLUG_NAME_MAX_LENGTH) { bol = false; formData.displayName = { value: displayName.value, @@ -107,7 +108,7 @@ const useTagModal = (props: IProps = {}) => { isInvalid: true, errorMsg: t('form.fields.slug_name.msg.empty'), }; - } else if (slugName.value.length > MAX_LENGTH) { + } else if (slugName.value.length > TAG_SLUG_NAME_MAX_LENGTH) { bol = false; formData.slugName = { value: slugName.value, diff --git a/ui/src/index.scss b/ui/src/index.scss index e95357484..8dd770876 100644 --- a/ui/src/index.scss +++ b/ui/src/index.scss @@ -20,9 +20,10 @@ @import 'common/variable'; @import '~bootstrap/scss/bootstrap'; @import '~bootstrap-icons'; +@import 'common/color'; .bg-gray-300 { - background-color: $gray-300; + background-color: var(--an-gray-300); } .focus { @@ -132,7 +133,7 @@ img[src=""] { } .bg-f5 { - background-color: #f5f5f5; + background-color: var(--an-f5); } .btn-no-border, @@ -198,7 +199,7 @@ img[src=""] { } .warning { - background-color: #fff3cd80; + background-color: var(--an-inbox-warning); } .fit-content { @@ -246,14 +247,14 @@ img[src=""] { } p { > code { - background-color: #e9ecef; - color: #212529; + background-color: var(--an-e9ecef); + color: var(--an-212529); padding: 2px 4px; border-radius: 0.25rem; } } pre { - background-color: #e9ecef; + background-color: var(--an-e9ecef); border-radius: 0.25rem; padding: 1rem; } @@ -261,9 +262,9 @@ img[src=""] { border-left: 0.25rem solid #ced4da; padding: 1rem; color: #6c757d; - background-color: #e9ecef; + background-color: var(--an-e9ecef); p { - color: $body-color; + color: var(--bs-body-color); } > p:last-child { margin-bottom: 0; @@ -275,6 +276,9 @@ img[src=""] { word-break: initial; } } + ol ol,ol ul,ul ol,ul ul { + margin-bottom: 1rem; + } } .upload-img-wrap { diff --git a/ui/src/pages/Admin/Login/index.tsx b/ui/src/pages/Admin/Login/index.tsx index 7355a23e8..c58a293b6 100644 --- a/ui/src/pages/Admin/Login/index.tsx +++ b/ui/src/pages/Admin/Login/index.tsx @@ -39,7 +39,7 @@ const Index: FC = () => { type: 'boolean', title: t('membership.title'), description: t('membership.text'), - default: false, + default: true, }, allow_email_registrations: { type: 'boolean', diff --git a/ui/src/pages/Admin/Privileges/index.tsx b/ui/src/pages/Admin/Privileges/index.tsx index 4bbf573d9..dbe58f0c9 100644 --- a/ui/src/pages/Admin/Privileges/index.tsx +++ b/ui/src/pages/Admin/Privileges/index.tsx @@ -27,8 +27,10 @@ import { getPrivilegeSetting, putPrivilegeSetting, AdminSettingsPrivilege, + AdminSettingsPrivilegeReq, } from '@/services'; import { handleFormError } from '@/utils'; +import { ADMIN_PRIVILEGE_CUSTOM_LEVEL } from '@/common/constants'; const Index: FC = () => { const { t } = useTranslation('translation', { @@ -47,8 +49,8 @@ const Index: FC = () => { }); const [formData, setFormData] = useState(initFormData(schema)); - const setFormConfig = (selectedLevel: number = 1) => { - selectedLevel = Number(selectedLevel); + const setFormConfig = (state: FormDataType) => { + const selectedLevel = Number(state.level.value); const levelOptions = privilege?.options; const curLevel = levelOptions?.find((li) => { return li.level === selectedLevel; @@ -77,7 +79,17 @@ const Index: FC = () => { }; uiState[li.key] = { 'ui:options': { - readOnly: true, + readOnly: curLevel.level !== ADMIN_PRIVILEGE_CUSTOM_LEVEL, + validator: (value: string) => { + const val = Number(value); + if (Number.isNaN(val)) { + return 'the input should be number'; + } + if (val < 1) { + return 'number should be equal or larger than 1'; + } + return true; + }, }, }; }); @@ -101,13 +113,36 @@ const Index: FC = () => { const onSubmit = (evt: FormEvent) => { evt.preventDefault(); evt.stopPropagation(); - const lv = Number(formData.level.value); - putPrivilegeSetting(lv) + + const reqParams: AdminSettingsPrivilegeReq = { + level: Number(formData.level.value), + custom_privileges: [], + }; + + if (reqParams.level === ADMIN_PRIVILEGE_CUSTOM_LEVEL) { + // construct custom level request data + Object.entries(formData).forEach(([key, value]) => { + if (key === 'level') { + return; + } + reqParams.custom_privileges?.push({ + key, + value: Number(value.value), + }); + }); + } + + putPrivilegeSetting(reqParams) .then(() => { Toast.onShow({ msg: t('update', { keyPrefix: 'toast' }), variant: 'success', }); + if (reqParams.level === ADMIN_PRIVILEGE_CUSTOM_LEVEL) { + getPrivilegeSetting().then((resp) => { + setPrivilege(resp); + }); + } }) .catch((err) => { if (err.isError) { @@ -121,7 +156,13 @@ const Index: FC = () => { if (!privilege) { return; } - setFormConfig(privilege.selected_level); + setFormConfig({ + level: { + value: privilege.selected_level, + isInvalid: false, + errorMsg: '', + }, + }); }, [privilege]); useEffect(() => { getPrivilegeSetting().then((resp) => { @@ -129,7 +170,15 @@ const Index: FC = () => { }); }, []); const handleOnChange = (state) => { - setFormConfig(state.level.value); + // if updated values in Custom form + if ( + state.level.value === ADMIN_PRIVILEGE_CUSTOM_LEVEL && + formData?.level?.value === state.level.value + ) { + setFormData(state); + } else { + setFormConfig(state); + } }; return ( diff --git a/ui/src/pages/Admin/SettingsUsers/index.tsx b/ui/src/pages/Admin/SettingsUsers/index.tsx index 106c59232..a83c2e3bd 100644 --- a/ui/src/pages/Admin/SettingsUsers/index.tsx +++ b/ui/src/pages/Admin/SettingsUsers/index.tsx @@ -137,7 +137,7 @@ const Index: FC = () => { 'ui:widget': 'switch', 'ui:options': { label: t('allow_update_location.label'), - fieldClassName: 'mb-3', + field_class_name: 'mb-3', simplify: true, }, }, diff --git a/ui/src/pages/Admin/Themes/index.tsx b/ui/src/pages/Admin/Themes/index.tsx index 34cb42d95..346e2e05c 100644 --- a/ui/src/pages/Admin/Themes/index.tsx +++ b/ui/src/pages/Admin/Themes/index.tsx @@ -26,6 +26,7 @@ import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components'; import { useToast } from '@/hooks'; import { handleFormError } from '@/utils'; import { themeSettingStore } from '@/stores'; +import { setupAppTheme } from '@/utils/localize'; const Index: FC = () => { const { t } = useTranslation('translation', { @@ -44,10 +45,20 @@ const Index: FC = () => { enumNames: themeSetting?.theme_options?.map((_) => _.label), default: themeSetting?.theme_options?.[0]?.value, }, + color_scheme: { + type: 'string', + title: t('color_scheme.label'), + enum: ['system', 'light', 'dark'], + enumNames: [ + t('system_setting', { keyPrefix: 'btns' }), + t('light', { keyPrefix: 'btns' }), + t('dark', { keyPrefix: 'btns' }), + ], + default: themeSetting?.color_scheme, + }, navbar_style: { type: 'string', title: t('navbar_style.label'), - description: t('navbar_style.text'), enum: ['colored', 'light'], enumNames: ['Colored', 'Light'], default: 'colored', @@ -64,23 +75,45 @@ const Index: FC = () => { themes: { 'ui:widget': 'select', }, + color_scheme: { + 'ui:widget': 'select', + }, navbar_style: { 'ui:widget': 'select', }, primary_color: { + 'ui:widget': 'input_group', 'ui:options': { inputType: 'color', + suffixBtnOptions: { + text: '', + variant: 'outline-secondary', + iconName: 'arrow-counterclockwise', + actionType: 'click', + title: t('reset', { keyPrefix: 'btns' }), + // eslint-disable-next-line @typescript-eslint/no-use-before-define + clickCallback: () => resetPrimaryScheme(), + }, }, }, }; + const [formData, setFormData] = useState(initFormData(schema)); const { update: updateThemeSetting } = themeSettingStore((_) => _); + + const resetPrimaryScheme = () => { + const formMeta = { ...formData }; + formMeta.primary_color.value = '#0033FF'; + setFormData({ ...formMeta }); + }; + const onSubmit = (evt) => { evt.preventDefault(); evt.stopPropagation(); const themeName = formData.themes.value; const reqParams: Type.AdminSettingsTheme = { theme: themeName, + color_scheme: formData.color_scheme.value, theme_config: { [themeName]: { navbar_style: formData.navbar_style.value, @@ -96,6 +129,7 @@ const Index: FC = () => { variant: 'success', }); updateThemeSetting(reqParams); + setupAppTheme(); }) .catch((err) => { if (err.isError) { @@ -115,6 +149,7 @@ const Index: FC = () => { formMeta.themes.value = themeName; formMeta.navbar_style.value = themeConfig?.navbar_style; formMeta.primary_color.value = themeConfig?.primary_color; + formData.color_scheme.value = setting?.color_scheme || 'system'; setFormData({ ...formMeta }); } }); diff --git a/ui/src/pages/Install/components/FourthStep/index.tsx b/ui/src/pages/Install/components/FourthStep/index.tsx index 7a4a3fca9..65ffd9d5a 100644 --- a/ui/src/pages/Install/components/FourthStep/index.tsx +++ b/ui/src/pages/Install/components/FourthStep/index.tsx @@ -259,7 +259,6 @@ const Index: FC = ({ visible, data, changeCallback, nextCallback }) => { label={t('login_required.switch')} checked={data.login_required.value} onChange={(e) => { - console.log(e.target.checked); changeCallback({ login_required: { value: e.target.checked, diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 1be44d9d2..f0a061bd0 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -34,6 +34,7 @@ import { HttpErrorContent, } from '@/components'; import { LoginToContinueModal } from '@/components/Modal'; +import { changeTheme } from '@/utils'; const Layout: FC = () => { const location = useLocation(); @@ -47,6 +48,23 @@ const Layout: FC = () => { useEffect(() => { httpStatusReset(); }, [location]); + + useEffect(() => { + const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + function handleSystemThemeChange(event) { + if (event.matches) { + changeTheme('dark'); + } else { + changeTheme('light'); + } + } + + systemThemeQuery.addListener(handleSystemThemeChange); + + return () => { + systemThemeQuery.removeListener(handleSystemThemeChange); + }; + }, []); return ( diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index 1a852f111..165e061ad 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -417,6 +417,7 @@ const Ask = () => { onChange={handleTitleChange} placeholder={t('form.fields.title.placeholder')} autoFocus + contentEditable /> @@ -460,7 +461,8 @@ const Ask = () => { {formData.tags.errorMsg} @@ -475,6 +477,7 @@ const Ask = () => { isInvalid={formData.edit_summary.isInvalid} placeholder={t('form.fields.edit_summary.placeholder')} onChange={handleSummaryChange} + contentEditable /> {formData.edit_summary.errorMsg} diff --git a/ui/src/pages/Questions/Detail/components/Alert/index.tsx b/ui/src/pages/Questions/Detail/components/Alert/index.tsx index c854bf35a..149733733 100644 --- a/ui/src/pages/Questions/Detail/components/Alert/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Alert/index.tsx @@ -35,7 +35,7 @@ const Index: FC = ({ data }) => { {data.msg.indexOf('http') > -1 ? (

{data.description}{' '} - + {t('question_detail.show_exist')}

diff --git a/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.scss b/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.scss index 1ea373dca..ad72a4d18 100644 --- a/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.scss +++ b/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.scss @@ -18,12 +18,22 @@ */ .people-dropdown { - .dropdown-menu { - width: 15rem; - } + position: absolute; + top: 34px; + left: 0; + width: 100%; .dropdown-item.active { - color: #212529; - background-color: #e9ecef; + color: var(--bs-body-color); + background-color: var(--an-invite-answer-item-active); + } + .check-cover { + width: 18px; + height: 18px; + position: absolute; + top: 0; + bottom: 0; + left: 0; + margin: auto 0; } } diff --git a/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.tsx b/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.tsx index 2ff288f70..130dd5dc2 100644 --- a/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.tsx +++ b/ui/src/pages/Questions/Detail/components/InviteToAnswer/PeopleDropdown.tsx @@ -30,42 +30,48 @@ import './PeopleDropdown.scss'; interface Props { selectedPeople: Type.UserInfoBase[] | undefined; onSelect: (people: Type.UserInfoBase) => void; + saveInviteUsers: () => void; visible?: boolean; } +interface UserInfoCheck extends Type.UserInfoBase { + checked?: boolean; +} + const Index: FC = ({ selectedPeople = [], visible = false, onSelect, + saveInviteUsers, }) => { const { user: currentUser } = loggedUserInfoStore(); const { t } = useTranslation('translation', { keyPrefix: 'invite_to_answer', }); - const [toggleState, setToggleState] = useState(false); - const [peopleList, setPeopleList] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); + const [peopleList, setPeopleList] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); const [searchValue, setSearchValue] = useState(''); const filterAndSetPeople = (source) => { - if (!toggleState) { - return; - } const filteredPeople: Type.UserInfoBase[] = []; source.forEach((p) => { - if (currentUser && currentUser.username === p.username) { + if ( + currentUser && + currentUser.role_id === 1 && + currentUser.username === p.username + ) { return; } - if (selectedPeople.find((_) => _.username === p.username)) { + if (selectedPeople?.find((_) => _.username === p.username)) { return; } filteredPeople.push(p); }); - setPeopleList(filteredPeople); + setPeopleList([...selectedPeople, ...filteredPeople]); }; const searchPeople = (s) => { if (!s) { - setPeopleList([]); + setPeopleList([...selectedPeople]); return; } userSearchByName(s).then((resp) => { @@ -79,7 +85,7 @@ const Index: FC = ({ }; const resetSearch = () => { - setCurrentIndex(0); + setCurrentIndex(-1); setSearchValue(''); setPeopleList([]); }; @@ -92,8 +98,6 @@ const Index: FC = ({ if (people) { onSelect(people); } - - resetSearch(); }; const handleKeyDown = (evt) => { @@ -117,69 +121,83 @@ const Index: FC = ({ }; useEffect(() => { - filterAndSetPeople(peopleList); - }, [selectedPeople]); - - useEffect(() => { - searchPeople(searchValue); - }, [toggleState]); - - useEffect(() => { - if (!visible && toggleState) { - setToggleState(false); + if (visible && selectedPeople.length > 0) { + setPeopleList(selectedPeople); + } + if (!visible) { + resetSearch(); } }, [visible]); return visible ? ( - - + - {t('add')} - - - - - {toggleState ? ( - - ) : null} - - {peopleList.map((p, idx) => { - return ( - -
- -
- {p.display_name} - - @{p.username} - -
-
-
- ); - })} + onToggle={(state) => { + if (!state) { + saveInviteUsers(); + } + }}> + +
+ +
+
0 ? 'py-2 border-top' : ''}`}> + {peopleList.map((p, idx) => { + return ( + { + setCurrentIndex(idx); + }} + active={idx === currentIndex}> + + v.id === p.id), + )} + onChange={() => {}} + /> +
+ + +
+ + {p.display_name} + + + @{p.username} + +
+
+ + + ); + })} +
) : null; diff --git a/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.scss b/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.scss new file mode 100644 index 000000000..0f3739e04 --- /dev/null +++ b/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.scss @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.invite-answer-card { + .card-header { + border: var(--bs-border-width) solid var(--bs-border-color-translucent); + } + + .card-body { + border: var(--bs-border-width) solid var(--bs-border-color-translucent); + border-top: 0; + border-bottom-left-radius: var(--bs-border-radius); + border-bottom-right-radius: var(--bs-border-radius); + + } +} diff --git a/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx b/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx index b959ff0e7..82f9fa275 100644 --- a/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/InviteToAnswer/index.tsx @@ -31,6 +31,8 @@ import { useCaptchaModal } from '@/hooks'; import PeopleDropdown from './PeopleDropdown'; +import './index.scss'; + interface Props { questionId: string; readOnly?: boolean; @@ -39,9 +41,9 @@ const Index: FC = ({ questionId, readOnly = false }) => { const { t } = useTranslation('translation', { keyPrefix: 'invite_to_answer', }); - const MAX_ASK_NUMBER = 5; + const [editing, setEditing] = useState(false); - const [users, setUsers] = useState(); + const [users, setUsers] = useState([]); const iaCaptcha = useCaptchaModal('invitation_answer'); const initInviteUsers = () => { @@ -60,20 +62,18 @@ const Index: FC = ({ questionId, readOnly = false }) => { }; const updateInviteUsers = (user: Type.UserInfoBase) => { - let userList = [user]; - if (users?.length) { - userList = [...users, user]; + const userID = users?.find((_) => _.id === user.id); + let userList: any = [...(users || [])]; + if (userID) { + userList = userList?.filter((_) => { + return _.id !== user.id; + }); + } else { + userList.push(user); } setUsers(userList); }; - const removeInviteUser = (user: Type.UserInfoBase) => { - const inviteUsers = users!.filter((_) => { - return _.username !== user.username; - }); - setUsers(inviteUsers); - }; - const saveInviteUsers = () => { if (!users) { return; @@ -93,7 +93,7 @@ const Index: FC = ({ questionId, readOnly = false }) => { if (ex.isError) { iaCaptcha.handleCaptchaError(ex.list); } - console.log('ex: ', ex); + console.error('putInviteUser error: ', ex); }); }); }; @@ -102,11 +102,6 @@ const Index: FC = ({ questionId, readOnly = false }) => { initInviteUsers(); }, [questionId]); - const showAddButton = editing && (!users || users.length < MAX_ASK_NUMBER); - const showInviteFeat = !editing && users?.length === 0; - const showInviteButton = showInviteFeat && !readOnly; - const showEditButton = !readOnly && !editing && users?.length; - const showSaveButton = !readOnly && editing; const showEmpty = readOnly && users?.length === 0; if (showEmpty) { @@ -114,53 +109,21 @@ const Index: FC = ({ questionId, readOnly = false }) => { } return ( - + {t('title')} - {showSaveButton ? ( - - ) : null} - {showEditButton ? ( + {!readOnly && ( - ) : null} + )} - -
+ +
{users?.map((user) => { - if (editing) { - return ( - - ); - } return ( = ({ questionId, readOnly = false }) => { ); })} - -
- {showInviteFeat ? ( - <> + {users?.length === 0 ? (
{t('desc')}
- {showInviteButton ? ( - - ) : null} - - ) : null} + ) : null} +
+ {editing && ( + + )}
); }; diff --git a/ui/src/pages/Questions/Detail/index.scss b/ui/src/pages/Questions/Detail/index.scss index 925ac9f3c..5db9050b7 100644 --- a/ui/src/pages/Questions/Detail/index.scss +++ b/ui/src/pages/Questions/Detail/index.scss @@ -18,7 +18,10 @@ */ .answer-item { - border-top: 1px solid rgba(33, 37, 41, 0.25); + border-top: 1px solid var(--an-answer-item-border-top); +} +.alert-exist { + color: var(--an-alert-exist-color); } @media screen and (max-width: 768px) { diff --git a/ui/src/pages/Questions/Detail/index.tsx b/ui/src/pages/Questions/Detail/index.tsx index 3729fc8fa..aac8e1845 100644 --- a/ui/src/pages/Questions/Detail/index.tsx +++ b/ui/src/pages/Questions/Detail/index.tsx @@ -294,13 +294,13 @@ const Index = () => { - {showInviteToAnswer ? ( ) : null} + ); diff --git a/ui/src/pages/Questions/EditAnswer/index.scss b/ui/src/pages/Questions/EditAnswer/index.scss index 9bae8ebe1..0d7ce9c65 100644 --- a/ui/src/pages/Questions/EditAnswer/index.scss +++ b/ui/src/pages/Questions/EditAnswer/index.scss @@ -27,6 +27,10 @@ height: calc(100% - 12px); overflow-y: scroll; z-index: 1; + background: var(--an-white); + } + img { + max-width: 100%; } .scroll-bar { height: 12px; @@ -46,6 +50,9 @@ transform: scale(110, 1); height: 100%; } + .resize-bottom::-webkit-resizer { + border: none; + } .resize-bottom + .line { left: 0; width: 100%; diff --git a/ui/src/pages/Questions/EditAnswer/index.tsx b/ui/src/pages/Questions/EditAnswer/index.tsx index d693794de..fbb3e3279 100644 --- a/ui/src/pages/Questions/EditAnswer/index.tsx +++ b/ui/src/pages/Questions/EditAnswer/index.tsx @@ -73,7 +73,7 @@ const Index = () => { const [contentChanged, setContentChanged] = useState(false); const editCaptcha = useCaptchaModal('edit'); - useLayoutEffect(() => { + useEffect(() => { if (data?.info?.content) { setFormData({ ...formData, @@ -227,7 +227,7 @@ const Index = () => {
{ defaultValue={formData.description.value} isInvalid={formData.description.isInvalid} placeholder={t('form.fields.edit_summary.placeholder')} + contentEditable /> {formData.description.errorMsg} diff --git a/ui/src/pages/Review/index.tsx b/ui/src/pages/Review/index.tsx index 087ecad76..927e95a13 100644 --- a/ui/src/pages/Review/index.tsx +++ b/ui/src/pages/Review/index.tsx @@ -68,7 +68,7 @@ const Index: FC = () => { resolveNextOne(resp, pageNumber); }) .catch((ex) => { - console.log('ex: ', ex); + console.error('review next error: ', ex); }); }; const reviewInfo = unreviewed_info?.content; @@ -85,7 +85,7 @@ const Index: FC = () => { queryNextOne(page); }) .catch((ex) => { - console.log('ex: ', ex); + console.error('revisionAudit approve error: ', ex); }) .finally(() => { setIsLoading(false); @@ -101,7 +101,7 @@ const Index: FC = () => { queryNextOne(page); }) .catch((ex) => { - console.log('ex: ', ex); + console.error('revisionAudit reject error: ', ex); }) .finally(() => { setIsLoading(false); diff --git a/ui/src/pages/Tags/Create/index.tsx b/ui/src/pages/Tags/Create/index.tsx index 485c7cd6c..8b3b12bfa 100644 --- a/ui/src/pages/Tags/Create/index.tsx +++ b/ui/src/pages/Tags/Create/index.tsx @@ -30,6 +30,7 @@ import { loggedUserInfoStore } from '@/stores'; import type * as Type from '@/common/interface'; import { createTag } from '@/services'; import { handleFormError } from '@/utils'; +import { TAG_SLUG_NAME_MAX_LENGTH } from '@/common/constants'; interface FormDataItem { displayName: Type.FormValue; @@ -104,10 +105,69 @@ const Index = () => { description: { ...formData.description, value, isInvalid: false }, }); + const checkValidated = (): boolean => { + let bol = true; + const { displayName, slugName } = formData; + + if (!displayName.value) { + bol = false; + formData.displayName = { + value: '', + isInvalid: true, + errorMsg: t('form.fields.display_name.msg.empty'), + }; + } else if (displayName.value.length > TAG_SLUG_NAME_MAX_LENGTH) { + bol = false; + formData.displayName = { + value: displayName.value, + isInvalid: true, + errorMsg: t('form.fields.display_name.msg.range'), + }; + } else { + formData.displayName = { + value: displayName.value, + isInvalid: false, + errorMsg: '', + }; + } + + if (!slugName.value) { + bol = false; + formData.slugName = { + value: '', + isInvalid: true, + errorMsg: t('form.fields.slug_name.msg.empty'), + }; + } else if (slugName.value.length > TAG_SLUG_NAME_MAX_LENGTH) { + bol = false; + formData.slugName = { + value: slugName.value, + isInvalid: true, + errorMsg: t('form.fields.slug_name.msg.range'), + }; + } else { + formData.slugName = { + value: slugName.value, + isInvalid: false, + errorMsg: '', + }; + } + + setFormData({ + ...formData, + }); + return bol; + }; + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); event.stopPropagation(); setContentChanged(false); + + if (!checkValidated()) { + return; + } + const params = { display_name: formData.displayName.value, slug_name: formData.slugName.value, diff --git a/ui/src/pages/Tags/Edit/index.tsx b/ui/src/pages/Tags/Edit/index.tsx index e5ddc4b28..0670b9467 100644 --- a/ui/src/pages/Tags/Edit/index.tsx +++ b/ui/src/pages/Tags/Edit/index.tsx @@ -29,6 +29,7 @@ import { usePageTags, usePromptWithUnload } from '@/hooks'; import { Editor, EditorRef } from '@/components'; import { loggedUserInfoStore } from '@/stores'; import type * as Type from '@/common/interface'; +import { TAG_SLUG_NAME_MAX_LENGTH } from '@/common/constants'; import { useTagInfo, modifyTag, useQueryRevisions } from '@/services'; interface FormDataItem { @@ -123,14 +124,51 @@ const Index = () => { const checkValidated = (): boolean => { let bol = true; - const { slugName } = formData; + const { displayName, slugName } = formData; + + if (!displayName.value) { + bol = false; + formData.displayName = { + value: '', + isInvalid: true, + errorMsg: t('form.fields.display_name.msg.empty', { + keyPrefix: 'tag_modal', + }), + }; + } else if (displayName.value.length > TAG_SLUG_NAME_MAX_LENGTH) { + bol = false; + formData.displayName = { + value: displayName.value, + isInvalid: true, + errorMsg: t('form.fields.display_name.msg.range', { + keyPrefix: 'tag_modal', + }), + }; + } else { + formData.displayName = { + value: displayName.value, + isInvalid: false, + errorMsg: '', + }; + } if (!slugName.value) { bol = false; formData.slugName = { value: '', isInvalid: true, - errorMsg: '标题不能为空', + errorMsg: t('form.fields.slug_name.msg.empty', { + keyPrefix: 'tag_modal', + }), + }; + } else if (slugName.value.length > TAG_SLUG_NAME_MAX_LENGTH) { + bol = false; + formData.slugName = { + value: slugName.value, + isInvalid: true, + errorMsg: t('form.fields.slug_name.msg.range', { + keyPrefix: 'tag_modal', + }), }; } else { formData.slugName = { @@ -215,7 +253,9 @@ const Index = () => {
- {t('form.fields.revision.label')} + + {t('form.fields.revision.label', { keyPrefix: 'tag_modal' })} + {revisions.map(({ create_at, reason, user_info }, index) => { const date = dayjs(create_at * 1000) @@ -235,7 +275,11 @@ const Index = () => { - {t('form.fields.display_name.label')} + + {t('form.fields.display_name.label', { + keyPrefix: 'tag_modal', + })} + { - {t('form.fields.slug_name.label')} + + {t('form.fields.slug_name.label', { keyPrefix: 'tag_modal' })} + - {t('form.fields.slug_name.info')} + + {t('form.fields.slug_name.desc', { keyPrefix: 'tag_modal' })} + {formData.slugName.errorMsg} - {t('form.fields.desc.label')} + + {t('form.fields.desc.label', { keyPrefix: 'tag_modal' })} + { - {t('form.fields.edit_summary.label')} + + {t('form.fields.edit_summary.label', { + keyPrefix: 'tag_modal', + })} + {formData.editSummary.errorMsg} diff --git a/ui/src/pages/Timeline/index.tsx b/ui/src/pages/Timeline/index.tsx index e20d50ef0..0a7816faf 100644 --- a/ui/src/pages/Timeline/index.tsx +++ b/ui/src/pages/Timeline/index.tsx @@ -98,8 +98,6 @@ const Index: FC = () => { usePageTags({ title: pageTitle, }); - - console.log('timelineData', linkUrl); return (
diff --git a/ui/src/pages/Users/Login/index.tsx b/ui/src/pages/Users/Login/index.tsx index 549808bd8..bbb1f67fa 100644 --- a/ui/src/pages/Users/Login/index.tsx +++ b/ui/src/pages/Users/Login/index.tsx @@ -32,6 +32,7 @@ import { } from '@/stores'; import { floppyNavigation, guard, handleFormError, userCenter } from '@/utils'; import { login, UcAgent } from '@/services'; +import { setupAppTheme } from '@/utils/localize'; const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'login' }); @@ -116,6 +117,7 @@ const Index: React.FC = () => { .then(async (res) => { await passwordCaptcha.close(); updateUser(res); + setupAppTheme(); const userStat = guard.deriveLoginState(); if (userStat.isNotActivated) { // inactive diff --git a/ui/src/pages/Users/Notifications/index.scss b/ui/src/pages/Users/Notifications/index.scss index 6b2d2c361..f4c930dcb 100644 --- a/ui/src/pages/Users/Notifications/index.scss +++ b/ui/src/pages/Users/Notifications/index.scss @@ -18,6 +18,6 @@ */ .inbox-nav { - border-top: 1px solid rgba(0, 0, 0, .125); + border-top: 1px solid var(--an-answer-item-border-top); padding: .5rem 0; } diff --git a/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx b/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx index 12e186b37..353115f18 100644 --- a/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx +++ b/ui/src/pages/Users/Settings/Account/components/MyLogins/index.tsx @@ -79,7 +79,7 @@ const Index = () => { onClick={(e) => deleteLogins(e, item)}> diff --git a/ui/src/pages/Users/Settings/Interface/index.tsx b/ui/src/pages/Users/Settings/Interface/index.tsx index 4164394f8..575995edf 100644 --- a/ui/src/pages/Users/Settings/Interface/index.tsx +++ b/ui/src/pages/Users/Settings/Interface/index.tsx @@ -25,7 +25,7 @@ import { useToast } from '@/hooks'; import { updateUserInterface } from '@/services'; import { localize } from '@/utils'; import { loggedUserInfoStore } from '@/stores'; -import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components'; +import { SchemaForm, JSONSchema, UISchema } from '@/components'; const Index = () => { const { t } = useTranslation('translation', { @@ -34,10 +34,22 @@ const Index = () => { const loggedUserInfo = loggedUserInfoStore.getState().user; const toast = useToast(); const [langs, setLangs] = useState(); + const [formData, setFormData] = useState({ + language: { + value: loggedUserInfo.language, + isInvalid: false, + errorMsg: '', + }, + color_scheme: { + value: loggedUserInfo.color_scheme || 'default', + isInvalid: false, + errorMsg: '', + }, + }); const schema: JSONSchema = { title: t('heading'), properties: { - lang: { + language: { type: 'string', title: t('lang.label'), description: t('lang.text'), @@ -45,17 +57,38 @@ const Index = () => { enumNames: langs?.map((_) => _.label), default: loggedUserInfo.language, }, + color_scheme: { + type: 'string', + title: t('color_scheme.label', { keyPrefix: 'admin.themes' }), + enum: ['default', 'system', 'light', 'dark'], + enumNames: [ + t('default', { keyPrefix: 'btns' }), + t('system_setting', { keyPrefix: 'btns' }), + t('light', { keyPrefix: 'btns' }), + t('dark', { keyPrefix: 'btns' }), + ], + default: loggedUserInfo.color_scheme, + }, }, }; const uiSchema: UISchema = { - lang: { + language: { + 'ui:widget': 'select', + }, + color_scheme: { 'ui:widget': 'select', }, }; - const [formData, setFormData] = useState(initFormData(schema)); const getLangs = async () => { const res: LangsType[] = await localize.loadLanguageOptions(); + setFormData({ + ...formData, + language: { + ...formData.language, + value: res[0].value, + }, + }); setLangs(res); }; @@ -64,13 +97,17 @@ const Index = () => { }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - const lang = formData.lang.value; - updateUserInterface(lang).then(() => { + const params = { + language: formData.language.value, + color_scheme: formData.color_scheme.value, + }; + updateUserInterface(params).then(() => { loggedUserInfoStore.getState().update({ ...loggedUserInfo, - language: lang, + ...params, }); localize.setupAppLanguage(); + localize.setupAppTheme(); toast.onShow({ msg: t('update', { keyPrefix: 'toast' }), variant: 'success', diff --git a/ui/src/pages/Users/Settings/Notification/index.tsx b/ui/src/pages/Users/Settings/Notification/index.tsx index f368564b5..c0aa4061b 100644 --- a/ui/src/pages/Users/Settings/Notification/index.tsx +++ b/ui/src/pages/Users/Settings/Notification/index.tsx @@ -39,51 +39,39 @@ const Index = () => { type: 'boolean', title: t('inbox.label'), description: t('inbox.description'), - enum: configData?.inbox?.map((v) => v.enable), - default: configData?.inbox?.map((v) => v.enable), - enumNames: configData?.inbox?.map((v) => t(v.key)), + default: configData?.inbox.enable, }, all_new_question: { type: 'boolean', title: t('all_new_question.label'), description: t('all_new_question.description'), - enum: configData?.all_new_question?.map((v) => v.enable), - default: configData?.all_new_question?.map((v) => v.enable), - enumNames: configData?.all_new_question?.map((v) => t(v.key)), + default: configData?.all_new_question.enable, }, all_new_question_for_following_tags: { type: 'boolean', title: t('all_new_question_for_following_tags.label'), description: t('all_new_question_for_following_tags.description'), - enum: configData?.all_new_question_for_following_tags?.map( - (v) => v.enable, - ), - default: configData?.all_new_question_for_following_tags?.map( - (v) => v.enable, - ), - enumNames: configData?.all_new_question_for_following_tags?.map((v) => - t(v.key), - ), + default: configData?.all_new_question_for_following_tags.enable, }, }, }; const uiSchema: UISchema = { inbox: { - 'ui:widget': 'checkbox', + 'ui:widget': 'switch', 'ui:options': { - label: t('email'), + label: t('turn_on'), }, }, all_new_question: { - 'ui:widget': 'checkbox', + 'ui:widget': 'switch', 'ui:options': { - label: t('email'), + label: t('turn_on'), }, }, all_new_question_for_following_tags: { - 'ui:widget': 'checkbox', + 'ui:widget': 'switch', 'ui:options': { - label: t('email'), + label: t('turn_on'), text: t('all_new_question_for_following_tags.description'), }, }, @@ -98,19 +86,18 @@ const Index = () => { event.preventDefault(); event.stopPropagation(); const params = { - inbox: configData?.inbox.map((v, index) => { - return { enable: formData.inbox.value[index], key: v.key }; - }), - all_new_question: configData?.all_new_question.map((v, index) => { - return { enable: formData.all_new_question.value[index], key: v.key }; - }), - all_new_question_for_following_tags: - configData?.all_new_question_for_following_tags.map((v, index) => { - return { - enable: formData.all_new_question_for_following_tags.value[index], - key: v.key, - }; - }), + inbox: { + enable: formData.inbox.value, + key: configData?.inbox.key, + }, + all_new_question: { + enable: formData.all_new_question.value, + key: configData?.all_new_question.key, + }, + all_new_question_for_following_tags: { + enable: formData.all_new_question_for_following_tags.value, + key: configData?.all_new_question_for_following_tags.key, + }, } as NotificationConfig; putNotificationConfig(params).then(() => { diff --git a/ui/src/pages/Users/Settings/Plugins/index.tsx b/ui/src/pages/Users/Settings/Plugins/index.tsx new file mode 100644 index 000000000..4fbe3ee9a --- /dev/null +++ b/ui/src/pages/Users/Settings/Plugins/index.tsx @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { useToast } from '@/hooks'; +import type * as Types from '@/common/interface'; +import { SchemaForm, JSONSchema, UISchema } from '@/components'; +import { useGetUserPluginConfig, updateUserPluginConfig } from '@/services'; +import { + InputOptions, + FormKit, + initFormData, + mergeFormData, +} from '@/components/SchemaForm'; + +const Config = () => { + const { t } = useTranslation('translation'); + const { slug_name } = useParams<{ slug_name: string }>(); + const { data, mutate: refreshPluginConfig } = useGetUserPluginConfig({ + plugin_slug_name: slug_name, + }); + const Toast = useToast(); + const [schema, setSchema] = useState(null); + const [uiSchema, setUISchema] = useState(); + const required: string[] = []; + + const [formData, setFormData] = useState(null); + + useEffect(() => { + if (!data) { + return; + } + const properties: JSONSchema['properties'] = {}; + const uiConf: UISchema = {}; + data.config_fields?.forEach((item) => { + properties[item.name] = { + type: 'string', + title: item.title, + description: item.description, + default: item.value, + }; + + if (item.options instanceof Array) { + properties[item.name].enum = item.options.map((option) => option.value); + properties[item.name].enumNames = item.options.map( + (option) => option.label, + ); + } + uiConf[item.name] = {}; + uiConf[item.name]['ui:widget'] = item.type; + if (item.ui_options) { + if ((item.ui_options as InputOptions & { input_type })?.input_type) { + (item.ui_options as InputOptions).inputType = ( + item.ui_options as InputOptions & { input_type } + ).input_type; + } + uiConf[item.name]['ui:options'] = item.ui_options; + } + if (item.required) { + required.push(item.name); + } + }); + const result = { + title: data?.name || '', + required, + properties, + }; + setSchema(result); + setUISchema(uiConf); + setFormData(mergeFormData(formData, initFormData(result))); + }, [data?.config_fields]); + + const onSubmit = (evt) => { + if (!formData) { + return; + } + evt.preventDefault(); + evt.stopPropagation(); + const config_fields = {}; + Object.keys(formData).forEach((key) => { + config_fields[key] = formData[key].value; + }); + const params = { + plugin_slug_name: slug_name, + config_fields, + }; + updateUserPluginConfig(params).then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + }); + }; + const refreshConfig: FormKit['refreshConfig'] = async () => { + refreshPluginConfig(); + }; + const handleOnChange = (form) => { + setFormData(form); + }; + return ( + <> +

{data?.name}

+ + + ); +}; + +export default Config; diff --git a/ui/src/pages/Users/Settings/components/Nav/index.tsx b/ui/src/pages/Users/Settings/components/Nav/index.tsx index d7c84ba02..6e2b0c076 100644 --- a/ui/src/pages/Users/Settings/components/Nav/index.tsx +++ b/ui/src/pages/Users/Settings/components/Nav/index.tsx @@ -22,9 +22,13 @@ import { Nav } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { NavLink, useMatch } from 'react-router-dom'; +import { useGetUserPluginList } from '@/services'; + const Index: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'settings.nav' }); const settingMatch = useMatch('/users/settings/:setting'); + const { data } = useGetUserPluginList(); + return ( ); }; diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index be558746b..bab8b34ba 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -181,6 +181,10 @@ const routes: RouteNode[] = [ path: 'interface', page: 'pages/Users/Settings/Interface', }, + { + path: ':slug_name', + page: 'pages/Users/Settings/Plugins', + }, ], }, { diff --git a/ui/src/services/admin/settings.ts b/ui/src/services/admin/settings.ts index 8fcced97c..7ce57ade1 100644 --- a/ui/src/services/admin/settings.ts +++ b/ui/src/services/admin/settings.ts @@ -47,6 +47,15 @@ export interface AdminSettingsPrivilege { options: PrivilegeLevel[]; } +export interface AdminSettingsPrivilegeReq { + level: number; + custom_privileges?: { + label?: string; + value: number; + key: string; + }[]; +} + export const useGeneralSetting = () => { const apiUrl = `/answer/admin/api/siteinfo/general`; const { data, error } = useSWR( @@ -185,8 +194,6 @@ export const getPrivilegeSetting = () => { ); }; -export const putPrivilegeSetting = (level: number) => { - return request.put('/answer/admin/api/setting/privileges', { - level, - }); +export const putPrivilegeSetting = (params: AdminSettingsPrivilegeReq) => { + return request.put('/answer/admin/api/setting/privileges', params); }; diff --git a/ui/src/services/client/settings.ts b/ui/src/services/client/settings.ts index 51b3a6a1e..0111c2da5 100644 --- a/ui/src/services/client/settings.ts +++ b/ui/src/services/client/settings.ts @@ -17,10 +17,12 @@ * under the License. */ +import qs from 'qs'; import useSWR from 'swr'; import request from '@/utils/request'; import type * as Type from '@/common/interface'; +import type { PluginConfig } from '@/services/admin/plugins'; export const getLanguageConfig = () => { return request.get('/answer/api/v1/language/config'); @@ -30,10 +32,12 @@ export const getLanguageOptions = () => { return request.get('/answer/api/v1/language/options'); }; -export const updateUserInterface = (lang: string) => { - return request.put('/answer/api/v1/user/interface', { - language: lang, - }); +interface userSettingInterface { + language: ''; + color_scheme: ''; +} +export const updateUserInterface = (data: userSettingInterface) => { + return request.put('/answer/api/v1/user/interface', data); }; export const useGetNotificationConfig = () => { @@ -46,3 +50,28 @@ export const useGetNotificationConfig = () => { export const putNotificationConfig = (data: Type.NotificationConfig) => { return request.put('/answer/api/v1/user/notification/config', data); }; + +export const useGetUserPluginList = () => { + return useSWR( + '/answer/api/v1/user/plugin/configs', + request.instance.get, + ); +}; + +export const useGetUserPluginConfig = (params) => { + const apiUrl = `/answer/api/v1/user/plugin/config?${qs.stringify(params)}`; + const { data, error, mutate } = useSWR( + apiUrl, + request.instance.get, + ); + return { + data, + isLoading: !data && !error, + error, + mutate, + }; +}; + +export const updateUserPluginConfig = (params) => { + return request.put('/answer/api/v1/user/plugin/config', params); +}; diff --git a/ui/src/services/common.ts b/ui/src/services/common.ts index 0130d69bc..0676cc716 100644 --- a/ui/src/services/common.ts +++ b/ui/src/services/common.ts @@ -62,6 +62,7 @@ export const useQueryComments = (params) => { params.page = 1; } else { // only first page need commentId + params.query_cond = ''; delete params.comment_id; } return useSWR( diff --git a/ui/src/stores/loggedUserInfo.ts b/ui/src/stores/loggedUserInfo.ts index e1d5e4af8..806c09930 100644 --- a/ui/src/stores/loggedUserInfo.ts +++ b/ui/src/stores/loggedUserInfo.ts @@ -42,6 +42,7 @@ const initUser: UserInfoRes = { status: 'normal', mail_status: 1, language: 'Default', + color_scheme: 'default', is_admin: false, have_password: true, role_id: 1, @@ -56,6 +57,9 @@ const loggedUserInfo = create((set) => ({ if (!params?.language) { params.language = 'Default'; } + if (!params?.color_scheme) { + params.color_scheme = 'default'; + } set(() => { Storage.set(LOGGED_TOKEN_STORAGE_KEY, params.access_token); return { user: params }; diff --git a/ui/src/stores/themeSetting.ts b/ui/src/stores/themeSetting.ts index 1100a027c..d0788cc81 100644 --- a/ui/src/stores/themeSetting.ts +++ b/ui/src/stores/themeSetting.ts @@ -25,11 +25,13 @@ interface IType { theme: AdminSettingsTheme['theme']; theme_config: AdminSettingsTheme['theme_config']; theme_options: AdminSettingsTheme['theme_options']; + color_scheme: AdminSettingsTheme['color_scheme']; update: (params: AdminSettingsTheme) => void; } const store = create((set) => ({ theme: 'default', + color_scheme: 'system', theme_options: [{ label: 'Default', value: 'default' }], theme_config: { default: { diff --git a/ui/src/utils/color.ts b/ui/src/utils/color.ts index f799fb0fa..52cdfc1d1 100644 --- a/ui/src/utils/color.ts +++ b/ui/src/utils/color.ts @@ -58,5 +58,5 @@ export const shiftColor = (color, weight) => { if (weight > 0) { return shadeColor(color, weight); } - return tintColor(color, weight); + return tintColor(color, -weight); }; diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts index b6382a4e5..07dc34002 100644 --- a/ui/src/utils/common.ts +++ b/ui/src/utils/common.ts @@ -208,26 +208,30 @@ function diffText(newText: string, oldText?: string): string { } function base64ToSvg(base64: string, svgClassName?: string) { - // base64 to svg xml - const svgxml = atob(base64); + try { + // base64 to svg xml + const svgxml = atob(base64); - // svg add class - const parser = new DOMParser(); - const doc = parser.parseFromString(svgxml, 'image/svg+xml'); - const parseError = doc.querySelector('parsererror'); - const svg = doc.querySelector('svg'); - let str = ''; - if (svg && !parseError) { - if (svgClassName) { - svg.setAttribute('class', svgClassName); - } - // svg.classList.add('me-2'); + // svg add class + const parser = new DOMParser(); + const doc = parser.parseFromString(svgxml, 'image/svg+xml'); + const parseError = doc.querySelector('parsererror'); + const svg = doc.querySelector('svg'); + let str = ''; + if (svg && !parseError) { + if (svgClassName) { + svg.setAttribute('class', svgClassName); + } + // svg.classList.add('me-2'); - // transform svg to string - const serializer = new XMLSerializer(); - str = serializer.serializeToString(doc); + // transform svg to string + const serializer = new XMLSerializer(); + str = serializer.serializeToString(doc); + } + return str; + } catch (error) { + return ''; } - return str; } // Determine whether the user is in WeChat or Enterprise WeChat or DingTalk, and return the corresponding type @@ -246,6 +250,21 @@ function getUaType() { return null; } +function changeTheme(mode: 'default' | 'light' | 'dark' | 'system') { + const htmlTag = document.querySelector('html') as HTMLHtmlElement; + if (mode === 'system') { + const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + if (systemThemeQuery.matches) { + htmlTag.setAttribute('data-bs-theme', 'dark'); + } else { + htmlTag.setAttribute('data-bs-theme', 'light'); + } + } else { + htmlTag.setAttribute('data-bs-theme', mode); + } +} + export { thousandthDivision, formatCount, @@ -260,4 +279,5 @@ export { diffText, base64ToSvg, getUaType, + changeTheme, }; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index b424b87ff..5f2e7b525 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -38,7 +38,7 @@ import { } from '@/common/constants'; import Storage from '@/utils/storage'; -import { setupAppLanguage, setupAppTimeZone } from './localize'; +import { setupAppLanguage, setupAppTimeZone, setupAppTheme } from './localize'; import { floppyNavigation, NavigateConfig } from './floppyNavigation'; import { pullUcAgent, getSignUpUrl } from './userCenter'; @@ -144,10 +144,9 @@ export const pullLoggedUser = async (isInitPull = false) => { pluTimestamp = Date.now(); const loggedUserInfo = await getLoggedUserInfo({ passingError: true, - }).catch((ex) => { + }).catch(() => { pluTimestamp = 0; loggedUserInfoStore.getState().clear(false); - console.error(ex); }); if (loggedUserInfo) { loggedUserInfoStore.getState().update(loggedUserInfo); @@ -424,7 +423,6 @@ export const googleSnapshotRedirect = () => { if (searchStr.indexOf('cache:') === 0 && searchStr.includes(':http')) { const redirectUrl = `http${searchStr.split(':http')[1]}`; const pathname = redirectUrl.replace(new URL(redirectUrl).origin, ''); - console.log('googleSnapshotUrl', window.location.href); gr.ok = false; gr.redirect = pathname || '/'; @@ -453,6 +451,7 @@ export const setupApp = async () => { await Promise.allSettled([pullUcAgent()]); setupAppLanguage(); setupAppTimeZone(); + setupAppTheme(); /** * WARN: * Initialization must be completed after all initialization actions, diff --git a/ui/src/utils/localize.ts b/ui/src/utils/localize.ts index 760b79679..cfebb6847 100644 --- a/ui/src/utils/localize.ts +++ b/ui/src/utils/localize.ts @@ -22,17 +22,23 @@ import i18next from 'i18next'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; -import { interfaceStore, loggedUserInfoStore } from '@/stores'; +import { + interfaceStore, + loggedUserInfoStore, + themeSettingStore, +} from '@/stores'; import { CURRENT_LANG_STORAGE_KEY, DEFAULT_LANG, LANG_RESOURCE_STORAGE_KEY, + DEFAULT_THEME, } from '@/common/constants'; import { getAdminLanguageOptions, getLanguageConfig, getLanguageOptions, } from '@/services'; +import { changeTheme } from '@/utils/common'; import Storage from './storage'; @@ -70,7 +76,7 @@ const addI18nResource = async (langName) => { const { default: resConf } = await import(`@i18n/${langName}.yaml`); res.resources = resConf.ui; } catch (ex) { - console.log('ex: ', ex); + console.error('addI18nResource error: ', ex); } } else if (storageResource && storageResource.lng === res.lng) { res.resources = storageResource.resources; @@ -103,6 +109,18 @@ export const getCurrentLang = () => { return currentLang; }; +export const getCurrentTheme = () => { + const loggedUser = loggedUserInfoStore.getState().user; + const adminTheme = themeSettingStore.getState().color_scheme; + const fallbackTheme = DEFAULT_THEME; + let currentTheme = loggedUser.color_scheme; + if (/default/i.test(currentTheme)) { + currentTheme = adminTheme; + } + currentTheme ||= fallbackTheme; + return currentTheme; +}; + /** * localize for Day.js */ @@ -128,3 +146,8 @@ export const setupAppTimeZone = () => { dayjs.tz.setDefault(adminInterface.time_zone); } }; + +export const setupAppTheme = () => { + const theme = getCurrentTheme(); + changeTheme(theme); +}; diff --git a/ui/template/header.html b/ui/template/header.html index 5471391db..854256744 100644 --- a/ui/template/header.html +++ b/ui/template/header.html @@ -29,6 +29,7 @@ {{if .keywords }}{{end}} + {{if .noindex }}{{end}}