From b78d21a524f7a11ad29b4bd230f23825f80c3ed7 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 26 Jul 2020 15:02:55 +0800 Subject: Merge front end repo --- .github/workflows/back-ci.yaml | 45 ++ .github/workflows/cd-version.yaml | 2 - .github/workflows/cd.yaml | 2 - .github/workflows/ci.yaml | 41 -- .github/workflows/front-ci.yaml | 43 ++ Timeline/ClientApp/.babelrc | 28 + Timeline/ClientApp/.editorconfig | 13 + Timeline/ClientApp/.eslintignore | 5 + Timeline/ClientApp/.eslintrc.js | 48 ++ Timeline/ClientApp/.gitattributes | 1 + Timeline/ClientApp/.gitignore | 32 ++ Timeline/ClientApp/.prettierrc | 3 + Timeline/ClientApp/.yarnrc.yml | 5 + Timeline/ClientApp/Jenkinsfile | 45 ++ Timeline/ClientApp/LICENSE | 21 + Timeline/ClientApp/README.md | 13 + Timeline/ClientApp/package.json | 111 ++++ .../ClientApp/public/android-chrome-192x192.png | Bin 0 -> 8519 bytes .../ClientApp/public/android-chrome-512x512.png | Bin 0 -> 23619 bytes Timeline/ClientApp/public/apple-touch-icon.png | Bin 0 -> 2088 bytes Timeline/ClientApp/public/browserconfig.xml | 9 + Timeline/ClientApp/public/favicon-16x16.png | Bin 0 -> 682 bytes Timeline/ClientApp/public/favicon-32x32.png | Bin 0 -> 821 bytes Timeline/ClientApp/public/favicon.ico | Bin 0 -> 15086 bytes Timeline/ClientApp/public/mstile-144x144.png | Bin 0 -> 2259 bytes Timeline/ClientApp/public/mstile-150x150.png | Bin 0 -> 2240 bytes Timeline/ClientApp/public/mstile-310x150.png | Bin 0 -> 2482 bytes Timeline/ClientApp/public/mstile-310x310.png | Bin 0 -> 4937 bytes Timeline/ClientApp/public/mstile-70x70.png | Bin 0 -> 1561 bytes Timeline/ClientApp/public/safari-pinned-tab.svg | 25 + Timeline/ClientApp/public/site.webmanifest | 22 + Timeline/ClientApp/sandbox.config.json | 11 + Timeline/ClientApp/src/app/App.tsx | 84 +++ Timeline/ClientApp/src/app/about/About.tsx | 172 ++++++ Timeline/ClientApp/src/app/about/about.sass | 4 + Timeline/ClientApp/src/app/about/author-avatar.png | Bin 0 -> 12038 bytes Timeline/ClientApp/src/app/about/github.png | Bin 0 -> 4268 bytes Timeline/ClientApp/src/app/admin/Admin.tsx | 78 +++ Timeline/ClientApp/src/app/admin/UserAdmin.tsx | 464 ++++++++++++++++ Timeline/ClientApp/src/app/common.ts | 44 ++ Timeline/ClientApp/src/app/common/AlertHost.tsx | 96 ++++ Timeline/ClientApp/src/app/common/AppBar.tsx | 106 ++++ Timeline/ClientApp/src/app/common/FileInput.tsx | 41 ++ Timeline/ClientApp/src/app/common/ImageCropper.tsx | 306 +++++++++++ Timeline/ClientApp/src/app/common/LoadingPage.tsx | 12 + .../ClientApp/src/app/common/OperationDialog.tsx | 381 +++++++++++++ Timeline/ClientApp/src/app/common/SearchInput.tsx | 63 +++ Timeline/ClientApp/src/app/common/TimelineLogo.tsx | 26 + .../ClientApp/src/app/common/UserTimelineLogo.tsx | 26 + Timeline/ClientApp/src/app/common/alert-service.ts | 61 +++ Timeline/ClientApp/src/app/common/alert.sass | 15 + Timeline/ClientApp/src/app/common/common.sass | 33 ++ Timeline/ClientApp/src/app/data/SubscriptionHub.ts | 125 +++++ Timeline/ClientApp/src/app/data/common.ts | 4 + Timeline/ClientApp/src/app/data/timeline.ts | 265 +++++++++ Timeline/ClientApp/src/app/data/user.ts | 296 ++++++++++ Timeline/ClientApp/src/app/home/Home.tsx | 150 ++++++ Timeline/ClientApp/src/app/home/TimelineBoard.tsx | 54 ++ .../src/app/home/TimelineBoardAreaWithUser.tsx | 36 ++ .../src/app/home/TimelineBoardAreaWithoutUser.tsx | 26 + .../src/app/home/TimelineCreateDialog.tsx | 54 ++ Timeline/ClientApp/src/app/home/home.sass | 13 + Timeline/ClientApp/src/app/http/common.ts | 140 +++++ Timeline/ClientApp/src/app/http/mock/common.ts | 48 ++ .../ClientApp/src/app/http/mock/default-avatar.png | Bin 0 -> 26442 bytes Timeline/ClientApp/src/app/http/mock/install.ts | 11 + Timeline/ClientApp/src/app/http/mock/timeline.ts | 600 +++++++++++++++++++++ Timeline/ClientApp/src/app/http/mock/token.ts | 53 ++ Timeline/ClientApp/src/app/http/mock/user.ts | 132 +++++ Timeline/ClientApp/src/app/http/timeline.ts | 476 ++++++++++++++++ Timeline/ClientApp/src/app/http/token.ts | 72 +++ Timeline/ClientApp/src/app/http/user.ts | 131 +++++ Timeline/ClientApp/src/app/i18n.ts | 79 +++ Timeline/ClientApp/src/app/index.sass | 82 +++ Timeline/ClientApp/src/app/index.tsx | 15 + .../ClientApp/src/app/locales/en/translation.ts | 194 +++++++ Timeline/ClientApp/src/app/locales/scheme.ts | 175 ++++++ .../ClientApp/src/app/locales/zh/translation.ts | 187 +++++++ Timeline/ClientApp/src/app/service-worker.tsx | 109 ++++ Timeline/ClientApp/src/app/settings/Settings.tsx | 221 ++++++++ Timeline/ClientApp/src/app/timeline/Timeline.tsx | 91 ++++ .../src/app/timeline/TimelineDeleteDialog.tsx | 54 ++ .../src/app/timeline/TimelineInfoCard.tsx | 109 ++++ .../ClientApp/src/app/timeline/TimelineItem.tsx | 183 +++++++ .../ClientApp/src/app/timeline/TimelineMember.tsx | 218 ++++++++ .../ClientApp/src/app/timeline/TimelinePage.tsx | 36 ++ .../src/app/timeline/TimelinePageTemplate.tsx | 288 ++++++++++ .../src/app/timeline/TimelinePageTemplateUI.tsx | 236 ++++++++ .../ClientApp/src/app/timeline/TimelinePageUI.tsx | 21 + .../src/app/timeline/TimelinePostEdit.tsx | 234 ++++++++ .../app/timeline/TimelinePropertyChangeDialog.tsx | 72 +++ .../ClientApp/src/app/timeline/timeline-ui.sass | 18 + Timeline/ClientApp/src/app/timeline/timeline.sass | 125 +++++ Timeline/ClientApp/src/app/tsconfig.json | 13 + Timeline/ClientApp/src/app/typings.d.ts | 24 + .../ClientApp/src/app/user/ChangeAvatarDialog.tsx | 306 +++++++++++ .../src/app/user/ChangeNicknameDialog.tsx | 28 + Timeline/ClientApp/src/app/user/Login.tsx | 148 +++++ Timeline/ClientApp/src/app/user/User.tsx | 77 +++ Timeline/ClientApp/src/app/user/UserInfoCard.tsx | 104 ++++ Timeline/ClientApp/src/app/user/UserPage.tsx | 19 + Timeline/ClientApp/src/app/user/api.ts | 10 + Timeline/ClientApp/src/app/user/user-page.sass | 11 + Timeline/ClientApp/src/app/utilities/rxjs.ts | 14 + Timeline/ClientApp/src/app/utilities/type.ts | 1 + Timeline/ClientApp/src/app/utilities/url.ts | 41 ++ Timeline/ClientApp/src/sw/sw.ts | 28 + Timeline/ClientApp/src/sw/tsconfig.json | 12 + Timeline/ClientApp/src/tsconfig.json | 17 + Timeline/ClientApp/webpack.common.js | 60 +++ Timeline/ClientApp/webpack.config.dev.js | 95 ++++ Timeline/ClientApp/webpack.config.prod.js | 84 +++ 112 files changed, 9017 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/back-ci.yaml delete mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/front-ci.yaml create mode 100644 Timeline/ClientApp/.babelrc create mode 100644 Timeline/ClientApp/.editorconfig create mode 100644 Timeline/ClientApp/.eslintignore create mode 100644 Timeline/ClientApp/.eslintrc.js create mode 100644 Timeline/ClientApp/.gitattributes create mode 100644 Timeline/ClientApp/.gitignore create mode 100644 Timeline/ClientApp/.prettierrc create mode 100644 Timeline/ClientApp/.yarnrc.yml create mode 100644 Timeline/ClientApp/Jenkinsfile create mode 100644 Timeline/ClientApp/LICENSE create mode 100644 Timeline/ClientApp/README.md create mode 100644 Timeline/ClientApp/package.json create mode 100644 Timeline/ClientApp/public/android-chrome-192x192.png create mode 100644 Timeline/ClientApp/public/android-chrome-512x512.png create mode 100644 Timeline/ClientApp/public/apple-touch-icon.png create mode 100644 Timeline/ClientApp/public/browserconfig.xml create mode 100644 Timeline/ClientApp/public/favicon-16x16.png create mode 100644 Timeline/ClientApp/public/favicon-32x32.png create mode 100644 Timeline/ClientApp/public/favicon.ico create mode 100644 Timeline/ClientApp/public/mstile-144x144.png create mode 100644 Timeline/ClientApp/public/mstile-150x150.png create mode 100644 Timeline/ClientApp/public/mstile-310x150.png create mode 100644 Timeline/ClientApp/public/mstile-310x310.png create mode 100644 Timeline/ClientApp/public/mstile-70x70.png create mode 100644 Timeline/ClientApp/public/safari-pinned-tab.svg create mode 100644 Timeline/ClientApp/public/site.webmanifest create mode 100644 Timeline/ClientApp/sandbox.config.json create mode 100644 Timeline/ClientApp/src/app/App.tsx create mode 100644 Timeline/ClientApp/src/app/about/About.tsx create mode 100644 Timeline/ClientApp/src/app/about/about.sass create mode 100644 Timeline/ClientApp/src/app/about/author-avatar.png create mode 100644 Timeline/ClientApp/src/app/about/github.png create mode 100644 Timeline/ClientApp/src/app/admin/Admin.tsx create mode 100644 Timeline/ClientApp/src/app/admin/UserAdmin.tsx create mode 100644 Timeline/ClientApp/src/app/common.ts create mode 100644 Timeline/ClientApp/src/app/common/AlertHost.tsx create mode 100644 Timeline/ClientApp/src/app/common/AppBar.tsx create mode 100644 Timeline/ClientApp/src/app/common/FileInput.tsx create mode 100644 Timeline/ClientApp/src/app/common/ImageCropper.tsx create mode 100644 Timeline/ClientApp/src/app/common/LoadingPage.tsx create mode 100644 Timeline/ClientApp/src/app/common/OperationDialog.tsx create mode 100644 Timeline/ClientApp/src/app/common/SearchInput.tsx create mode 100644 Timeline/ClientApp/src/app/common/TimelineLogo.tsx create mode 100644 Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx create mode 100644 Timeline/ClientApp/src/app/common/alert-service.ts create mode 100644 Timeline/ClientApp/src/app/common/alert.sass create mode 100644 Timeline/ClientApp/src/app/common/common.sass create mode 100644 Timeline/ClientApp/src/app/data/SubscriptionHub.ts create mode 100644 Timeline/ClientApp/src/app/data/common.ts create mode 100644 Timeline/ClientApp/src/app/data/timeline.ts create mode 100644 Timeline/ClientApp/src/app/data/user.ts create mode 100644 Timeline/ClientApp/src/app/home/Home.tsx create mode 100644 Timeline/ClientApp/src/app/home/TimelineBoard.tsx create mode 100644 Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx create mode 100644 Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx create mode 100644 Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx create mode 100644 Timeline/ClientApp/src/app/home/home.sass create mode 100644 Timeline/ClientApp/src/app/http/common.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/common.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/default-avatar.png create mode 100644 Timeline/ClientApp/src/app/http/mock/install.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/timeline.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/token.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/user.ts create mode 100644 Timeline/ClientApp/src/app/http/timeline.ts create mode 100644 Timeline/ClientApp/src/app/http/token.ts create mode 100644 Timeline/ClientApp/src/app/http/user.ts create mode 100644 Timeline/ClientApp/src/app/i18n.ts create mode 100644 Timeline/ClientApp/src/app/index.sass create mode 100644 Timeline/ClientApp/src/app/index.tsx create mode 100644 Timeline/ClientApp/src/app/locales/en/translation.ts create mode 100644 Timeline/ClientApp/src/app/locales/scheme.ts create mode 100644 Timeline/ClientApp/src/app/locales/zh/translation.ts create mode 100644 Timeline/ClientApp/src/app/service-worker.tsx create mode 100644 Timeline/ClientApp/src/app/settings/Settings.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/Timeline.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineItem.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineMember.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePage.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/timeline-ui.sass create mode 100644 Timeline/ClientApp/src/app/timeline/timeline.sass create mode 100644 Timeline/ClientApp/src/app/tsconfig.json create mode 100644 Timeline/ClientApp/src/app/typings.d.ts create mode 100644 Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx create mode 100644 Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx create mode 100644 Timeline/ClientApp/src/app/user/Login.tsx create mode 100644 Timeline/ClientApp/src/app/user/User.tsx create mode 100644 Timeline/ClientApp/src/app/user/UserInfoCard.tsx create mode 100644 Timeline/ClientApp/src/app/user/UserPage.tsx create mode 100644 Timeline/ClientApp/src/app/user/api.ts create mode 100644 Timeline/ClientApp/src/app/user/user-page.sass create mode 100644 Timeline/ClientApp/src/app/utilities/rxjs.ts create mode 100644 Timeline/ClientApp/src/app/utilities/type.ts create mode 100644 Timeline/ClientApp/src/app/utilities/url.ts create mode 100644 Timeline/ClientApp/src/sw/sw.ts create mode 100644 Timeline/ClientApp/src/sw/tsconfig.json create mode 100644 Timeline/ClientApp/src/tsconfig.json create mode 100644 Timeline/ClientApp/webpack.common.js create mode 100644 Timeline/ClientApp/webpack.config.dev.js create mode 100644 Timeline/ClientApp/webpack.config.prod.js diff --git a/.github/workflows/back-ci.yaml b/.github/workflows/back-ci.yaml new file mode 100644 index 00000000..c4e45872 --- /dev/null +++ b/.github/workflows/back-ci.yaml @@ -0,0 +1,45 @@ +name: Back End CI + +on: + push: + branches: [master] + paths-ignore: + - "Timeline/ClientApp/**" + pull_request: + branches: [master] + paths-ignore: + - "Timeline/ClientApp/**" + +jobs: + build: + name: Build And Test + runs-on: ubuntu-latest + env: + ASPNETCORE_ENVIRONMENT: "Development" + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + + steps: + - uses: actions/checkout@v2 + + - name: Restore Cache + uses: actions/cache@v2 + with: + path: ${{ env.NUGET_PACKAGES }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Use .Net Core 3.1.x + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "3.1.x" + + - name: Build And Test + run: dotnet test --logger html --collect:"XPlat Code Coverage" --settings './Timeline.Tests/coverletArgs.runsettings' + + - name: Upload Test Artifact + uses: actions/upload-artifact@v2 + if: ${{ success() || failure() }} + with: + name: test-result + path: Timeline.Tests/TestResults/ diff --git a/.github/workflows/cd-version.yaml b/.github/workflows/cd-version.yaml index 72531eef..a15acd2a 100644 --- a/.github/workflows/cd-version.yaml +++ b/.github/workflows/cd-version.yaml @@ -12,8 +12,6 @@ jobs: steps: - uses: actions/checkout@v2 - with: - submodules: true - name: Build And Push to Docker Hub uses: docker/build-push-action@v1 diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 4ae002c4..4ddfd6d1 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -11,8 +11,6 @@ jobs: steps: - uses: actions/checkout@v2 - with: - submodules: true - name: Build And Push to Docker Hub uses: docker/build-push-action@v1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index b59f94fb..00000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,41 +0,0 @@ -name: CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - build: - name: Build And Test - runs-on: ubuntu-latest - env: - ASPNETCORE_ENVIRONMENT: "Development" - NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages - - steps: - - uses: actions/checkout@v2 - - - name: Restore Cache - uses: actions/cache@v2 - with: - path: ${{ env.NUGET_PACKAGES }} - key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} - restore-keys: | - nuget-${{ runner.os }}- - - - name: Use .Net Core 3.1.x - uses: actions/setup-dotnet@v1 - with: - dotnet-version: "3.1.x" - - - name: Build And Test - run: dotnet test --logger html --collect:"XPlat Code Coverage" --settings './Timeline.Tests/coverletArgs.runsettings' - - - name: Upload Test Artifact - uses: actions/upload-artifact@v2 - if: ${{ success() || failure() }} - with: - name: test-result - path: Timeline.Tests/TestResults/ diff --git a/.github/workflows/front-ci.yaml b/.github/workflows/front-ci.yaml new file mode 100644 index 00000000..b549479a --- /dev/null +++ b/.github/workflows/front-ci.yaml @@ -0,0 +1,43 @@ +name: Front End CI + +on: + push: + branches: [master] + paths: + - "Timeline/ClientApp/**" + pull_request: + branches: [master] + paths: + - "Timeline/ClientApp/**" + +jobs: + build: + name: Build + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: Timeline/ClientApp + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js 14 + uses: actions/setup-node@v1 + with: + node-version: "14" + + - name: Restore Packages + run: yarn + + - name: Build App + run: yarn build + + - name: Lint Code + run: yarn lint + + - name: Upload Artifact + uses: actions/upload-artifact@v2 + with: + name: dist + path: Timeline/ClientApp/dist/ diff --git a/Timeline/ClientApp/.babelrc b/Timeline/ClientApp/.babelrc new file mode 100644 index 00000000..78d36a3d --- /dev/null +++ b/Timeline/ClientApp/.babelrc @@ -0,0 +1,28 @@ +{ + "presets": [ + "@babel/env", + "@babel/preset-react", + "@babel/preset-typescript" + ], + "plugins": [ + "@babel/plugin-syntax-dynamic-import", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-optional-chaining", + "@babel/plugin-proposal-nullish-coalescing-operator", + [ + "@babel/plugin-proposal-decorators", + { + "decoratorsBeforeExport": true + } + ], + [ + "babel-plugin-transform-builtin-extend", + { + "globals": [ + "Error", + "Array" + ] + } + ] + ] +} \ No newline at end of file diff --git a/Timeline/ClientApp/.editorconfig b/Timeline/ClientApp/.editorconfig new file mode 100644 index 00000000..54d8316a --- /dev/null +++ b/Timeline/ClientApp/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*.ts] +tab_width = 2 + +[*.tsx] +tab_width = 2 + +[*.css] +tab_width = 2 + +[*.sass] +tab_width = 2 diff --git a/Timeline/ClientApp/.eslintignore b/Timeline/ClientApp/.eslintignore new file mode 100644 index 00000000..371b5fcb --- /dev/null +++ b/Timeline/ClientApp/.eslintignore @@ -0,0 +1,5 @@ +.yarn +node_modules +dist +webpack.*.js +.eslintrc.js diff --git a/Timeline/ClientApp/.eslintrc.js b/Timeline/ClientApp/.eslintrc.js new file mode 100644 index 00000000..3cbc1cb6 --- /dev/null +++ b/Timeline/ClientApp/.eslintrc.js @@ -0,0 +1,48 @@ +module.exports = { + env: { + browser: true, + es6: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + 'prettier/react', + 'prettier/@typescript-eslint', + 'plugin:react-hooks/recommended', + ], + globals: { + Atomics: 'readonly', + SharedArrayBuffer: 'readonly', + }, + parser: '@typescript-eslint/parser', + parserOptions: { + project: ['./src/app/tsconfig.json', './src/sw/tsconfig.json'], + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + plugins: ['react', '@typescript-eslint', 'react-hooks'], + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': [ + 'warn', + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + allowHigherOrderFunctions: true, + }, + ], + }, +}; diff --git a/Timeline/ClientApp/.gitattributes b/Timeline/ClientApp/.gitattributes new file mode 100644 index 00000000..c1aa21ac --- /dev/null +++ b/Timeline/ClientApp/.gitattributes @@ -0,0 +1 @@ +.yarn/** linguist-vendored \ No newline at end of file diff --git a/Timeline/ClientApp/.gitignore b/Timeline/ClientApp/.gitignore new file mode 100644 index 00000000..1de0b58f --- /dev/null +++ b/Timeline/ClientApp/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +.yarn/* +!.yarn/cache +!.yarn/releases +!.yarn/plugins +!.yarn/sdks +!.yarn/versions + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.vscode/launch.json + +/dist \ No newline at end of file diff --git a/Timeline/ClientApp/.prettierrc b/Timeline/ClientApp/.prettierrc new file mode 100644 index 00000000..5e59eca6 --- /dev/null +++ b/Timeline/ClientApp/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/Timeline/ClientApp/.yarnrc.yml b/Timeline/ClientApp/.yarnrc.yml new file mode 100644 index 00000000..2e4e43a7 --- /dev/null +++ b/Timeline/ClientApp/.yarnrc.yml @@ -0,0 +1,5 @@ +plugins: + - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs + spec: "@yarnpkg/plugin-interactive-tools" + +yarnPath: .yarn/releases/yarn-2.1.1.cjs diff --git a/Timeline/ClientApp/Jenkinsfile b/Timeline/ClientApp/Jenkinsfile new file mode 100644 index 00000000..04ad7d75 --- /dev/null +++ b/Timeline/ClientApp/Jenkinsfile @@ -0,0 +1,45 @@ +pipeline { + agent { + docker { + reuseNode true + registryUrl 'https://coding-public-docker.pkg.coding.net' + image 'public/docker/nodejs:14' + } + + } + stages { + stage('检出') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: env.GIT_BUILD_REF]], + userRemoteConfigs: [[ + url: env.GIT_REPO_URL, + credentialsId: env.CREDENTIALS_ID + ]]]) + } + } + stage('安装依赖') { + steps { + sh 'yarn' + } + } + stage('构建和Lint') { + parallel { + stage('构建') { + steps { + sh 'yarn build' + } + } + stage('Lint') { + steps { + sh 'yarn lint' + } + } + } + } + } + environment { + YARN_NPM_REGISTRY_SERVER = 'https://registry.npm.taobao.org' + } + } \ No newline at end of file diff --git a/Timeline/ClientApp/LICENSE b/Timeline/ClientApp/LICENSE new file mode 100644 index 00000000..238cd2d9 --- /dev/null +++ b/Timeline/ClientApp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 杨宇千 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Timeline/ClientApp/README.md b/Timeline/ClientApp/README.md new file mode 100644 index 00000000..1046f45f --- /dev/null +++ b/Timeline/ClientApp/README.md @@ -0,0 +1,13 @@ +# Welcome to Timeline! + +![CI](https://github.com/crupest/Timeline-FrontEnd/workflows/CI/badge.svg) + +## Overview + +This is the first web app of [me](https://github.com/crupest). + +The front-end is written in is written in [Typescript](https://www.typescriptlang.org/) and [React](https://reactjs.org/). + +The back-end is located [here](https://github.com/crupest/Timeline). See more info there. + +The final product is hosting on my [Tencent Cloud](https://cloud.tencent.com/) Cloud Virtual Machine on https://crupest.xyz . diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json new file mode 100644 index 00000000..3c06baff --- /dev/null +++ b/Timeline/ClientApp/package.json @@ -0,0 +1,111 @@ +{ + "name": "timeline", + "version": "0.1.0", + "private": true, + "homepage": "https://crupest.xyz", + "keywords": [], + "description": "Timeline app.", + "dependencies": { + "axios": "^0.19.2", + "bootstrap": "^4.5.0", + "bootstrap-icons": "^1.0.0-alpha4", + "classnames": "^2.2.6", + "clsx": "^1.1.1", + "core-js": "^3.6.5", + "i18next": "^19.6.2", + "i18next-browser-languagedetector": "^5.0.0", + "localforage": "^1.7.4", + "lodash": "^4.17.15", + "pepjs": "^0.5.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-hot-loader": "^4.12.21", + "react-i18next": "^11.5.0", + "react-inlinesvg": "^2.0.0", + "react-responsive": "^8.1.0", + "react-router": "^5.2.0", + "react-router-dom": "^5.2.0", + "reactstrap": "^8.4.1", + "regenerator-runtime": "^0.13.5", + "rxjs": "^6.5.5", + "workbox-precaching": "^5.1.3", + "workbox-routing": "^5.1.3", + "workbox-strategies": "^5.1.3", + "workbox-window": "^5.1.3", + "xregexp": "^4.3.0" + }, + "scripts": { + "start": "webpack-dev-server --config ./webpack.config.dev.js", + "start:mock": "webpack-dev-server --config ./webpack.config.dev.js --env.TIMELINE_USE_MOCK_BACKEND", + "build": "webpack --config ./webpack.config.prod.js", + "lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@babel/core": "^7.10.5", + "@babel/plugin-proposal-class-properties": "^7.10.1", + "@babel/plugin-proposal-decorators": "^7.10.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.1", + "@babel/plugin-proposal-optional-chaining": "^7.10.1", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/preset-env": "^7.10.2", + "@babel/preset-react": "^7.10.1", + "@babel/preset-typescript": "^7.10.1", + "@hot-loader/react-dom": "^16.13.0", + "@types/classnames": "^2.2.10", + "@types/crypto-js": "^3.1.47", + "@types/lodash": "^4.14.155", + "@types/node": "^14.0.11", + "@types/react": "^16.9.35", + "@types/react-dom": "^16.9.8", + "@types/react-responsive": "^8.0.2", + "@types/react-router": "^5.1.7", + "@types/react-router-dom": "^5.1.5", + "@types/reactstrap": "^8.4.2", + "@types/webpack-env": "^1.15.2", + "@types/xregexp": "^4.3.0", + "@typescript-eslint/eslint-plugin": "^3.6.1", + "@typescript-eslint/parser": "^3.6.1", + "@yarnpkg/pnpify": "^2.1.0", + "autoprefixer": "^9.8.0", + "babel-loader": "^8.1.0", + "babel-plugin-transform-builtin-extend": "^1.1.2", + "clean-webpack-plugin": "^3.0.0", + "copy-webpack-plugin": "^6.0.2", + "crypto-js": "^4.0.0", + "css-loader": "^3.5.3", + "eslint": "^7.2.0", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-prettier": "^3.1.3", + "eslint-plugin-react": "^7.20.0", + "eslint-plugin-react-hooks": "^4.0.8", + "file-loader": "^6.0.0", + "html-webpack-plugin": "^3.2.0", + "html-webpack-template": "^6.2.0", + "http-server": "^0.12.3", + "pnp-webpack-plugin": "^1.6.4", + "postcss-loader": "^3.0.0", + "prettier": "^2.0.5", + "sass": "^1.26.8", + "sass-loader": "^9.0.2", + "style-loader": "^1.2.1", + "ts-loader": "^8.0.1", + "typescript": "^3.9.7", + "url-loader": "^4.1.0", + "webpack": "^4.43.0", + "webpack-cli": "^3.3.11", + "webpack-dev-server": "^3.11.0", + "workbox-webpack-plugin": "^5.1.3" + } +} diff --git a/Timeline/ClientApp/public/android-chrome-192x192.png b/Timeline/ClientApp/public/android-chrome-192x192.png new file mode 100644 index 00000000..da9b6b81 Binary files /dev/null and b/Timeline/ClientApp/public/android-chrome-192x192.png differ diff --git a/Timeline/ClientApp/public/android-chrome-512x512.png b/Timeline/ClientApp/public/android-chrome-512x512.png new file mode 100644 index 00000000..fa84e055 Binary files /dev/null and b/Timeline/ClientApp/public/android-chrome-512x512.png differ diff --git a/Timeline/ClientApp/public/apple-touch-icon.png b/Timeline/ClientApp/public/apple-touch-icon.png new file mode 100644 index 00000000..d5a3fb45 Binary files /dev/null and b/Timeline/ClientApp/public/apple-touch-icon.png differ diff --git a/Timeline/ClientApp/public/browserconfig.xml b/Timeline/ClientApp/public/browserconfig.xml new file mode 100644 index 00000000..f2c89409 --- /dev/null +++ b/Timeline/ClientApp/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2d89ef + + + diff --git a/Timeline/ClientApp/public/favicon-16x16.png b/Timeline/ClientApp/public/favicon-16x16.png new file mode 100644 index 00000000..6c978995 Binary files /dev/null and b/Timeline/ClientApp/public/favicon-16x16.png differ diff --git a/Timeline/ClientApp/public/favicon-32x32.png b/Timeline/ClientApp/public/favicon-32x32.png new file mode 100644 index 00000000..bbde902f Binary files /dev/null and b/Timeline/ClientApp/public/favicon-32x32.png differ diff --git a/Timeline/ClientApp/public/favicon.ico b/Timeline/ClientApp/public/favicon.ico new file mode 100644 index 00000000..d4cd3db6 Binary files /dev/null and b/Timeline/ClientApp/public/favicon.ico differ diff --git a/Timeline/ClientApp/public/mstile-144x144.png b/Timeline/ClientApp/public/mstile-144x144.png new file mode 100644 index 00000000..61eaaf43 Binary files /dev/null and b/Timeline/ClientApp/public/mstile-144x144.png differ diff --git a/Timeline/ClientApp/public/mstile-150x150.png b/Timeline/ClientApp/public/mstile-150x150.png new file mode 100644 index 00000000..85fa83ee Binary files /dev/null and b/Timeline/ClientApp/public/mstile-150x150.png differ diff --git a/Timeline/ClientApp/public/mstile-310x150.png b/Timeline/ClientApp/public/mstile-310x150.png new file mode 100644 index 00000000..41889953 Binary files /dev/null and b/Timeline/ClientApp/public/mstile-310x150.png differ diff --git a/Timeline/ClientApp/public/mstile-310x310.png b/Timeline/ClientApp/public/mstile-310x310.png new file mode 100644 index 00000000..cddce02e Binary files /dev/null and b/Timeline/ClientApp/public/mstile-310x310.png differ diff --git a/Timeline/ClientApp/public/mstile-70x70.png b/Timeline/ClientApp/public/mstile-70x70.png new file mode 100644 index 00000000..52f59d43 Binary files /dev/null and b/Timeline/ClientApp/public/mstile-70x70.png differ diff --git a/Timeline/ClientApp/public/safari-pinned-tab.svg b/Timeline/ClientApp/public/safari-pinned-tab.svg new file mode 100644 index 00000000..e91f046a --- /dev/null +++ b/Timeline/ClientApp/public/safari-pinned-tab.svg @@ -0,0 +1,25 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/Timeline/ClientApp/public/site.webmanifest b/Timeline/ClientApp/public/site.webmanifest new file mode 100644 index 00000000..74f0901a --- /dev/null +++ b/Timeline/ClientApp/public/site.webmanifest @@ -0,0 +1,22 @@ +{ + "$schema": "http://json.schemastore.org/web-manifest", + + "name": "Timeline", + "short_name": "Timeline", + "description": "Record your life in Timeline! Created by crupest.", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/Timeline/ClientApp/sandbox.config.json b/Timeline/ClientApp/sandbox.config.json new file mode 100644 index 00000000..42b540ce --- /dev/null +++ b/Timeline/ClientApp/sandbox.config.json @@ -0,0 +1,11 @@ +{ + "infiniteLoopProtection": true, + "hardReloadOnChange": false, + "view": "browser", + "container": { + "port": 3000, + "startScript": "start:mock" + }, + "port": 3000, + "startScript": "start:mock" +} diff --git a/Timeline/ClientApp/src/app/App.tsx b/Timeline/ClientApp/src/app/App.tsx new file mode 100644 index 00000000..03775275 --- /dev/null +++ b/Timeline/ClientApp/src/app/App.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import { hot } from 'react-hot-loader/root'; + +import AppBar from './common/AppBar'; +import LoadingPage from './common/LoadingPage'; +import Home from './home/Home'; +import Login from './user/Login'; +import Settings from './settings/Settings'; +import About from './about/About'; +import User from './user/User'; +import TimelinePage from './timeline/TimelinePage'; +import AlertHost from './common/AlertHost'; + +import { userService, useRawUser } from './data/user'; + +const NoMatch: React.FC = () => { + return ( + <> + +
+
Ah-oh, 404!
+ + ); +}; + +const LazyAdmin = React.lazy(() => + import(/* webpackChunkName: "admin" */ './admin/Admin') +); + +const App: React.FC = () => { + const user = useRawUser(); + + React.useEffect(() => { + void userService.checkLoginState(); + }, []); + + let body; + if (user === undefined) { + body = ; + } else { + body = ( + + + + + + + + + + + + + + + + + + + + + {user && user.administrator && ( + + + + )} + + + + + + ); + } + + return ( + }> + {body} + + + ); +}; + +export default hot(App); diff --git a/Timeline/ClientApp/src/app/about/About.tsx b/Timeline/ClientApp/src/app/about/About.tsx new file mode 100644 index 00000000..38e16c7c --- /dev/null +++ b/Timeline/ClientApp/src/app/about/About.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { useTranslation, Trans } from 'react-i18next'; + +import authorAvatarUrl from './author-avatar.png'; +import githubLogoUrl from './github.png'; + +import AppBar from '../common/AppBar'; + +const frontendCredits: { + name: string; + url: string; +}[] = [ + { + name: 'reactjs', + url: 'https://reactjs.org', + }, + { + name: 'typescript', + url: 'https://www.typescriptlang.org', + }, + { + name: 'bootstrap', + url: 'https://getbootstrap.com', + }, + { + name: 'reactstrap', + url: 'https://reactstrap.github.io', + }, + { + name: 'babeljs', + url: 'https://babeljs.io', + }, + { + name: 'webpack', + url: 'https://webpack.js.org', + }, + { + name: 'sass', + url: 'https://sass-lang.com', + }, + { + name: 'eslint', + url: 'https://eslint.org', + }, + { + name: 'prettier', + url: 'https://prettier.io', + }, + { + name: 'pepjs', + url: 'https://github.com/jquery/PEP', + }, + { + name: 'react-inlinesvg', + url: 'https://github.com/gilbarbara/react-inlinesvg', + }, +]; + +const backendCredits: { + name: string; + url: string; +}[] = [ + { + name: 'ASP.NET Core', + url: 'https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core', + }, + { name: 'sqlite', url: 'https://sqlite.org' }, + { + name: 'ImageSharp', + url: 'https://github.com/SixLabors/ImageSharp', + }, +]; + +const About: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + +
+
+

{t('about.author.title')}

+
+
+ +
+

+ {t('about.author.fullname')} + 杨宇千 +

+

+ {t('about.author.nickname')} + crupest +

+

+ {t('about.author.introduction')} + {t('about.author.introductionContent')} +

+
+
+

+ {t('about.author.links')} + + + +

+
+
+
+

{t('about.site.title')}

+

+ + 01234 + 56 + +

+

+ + {t('about.site.repo')} + +

+
+
+

{t('about.credits.title')}

+

{t('about.credits.content')}

+

{t('about.credits.frontend')}

+
    + {frontendCredits.map((item, index) => { + return ( +
  • + + {item.name} + +
  • + ); + })} +
  • ...
  • +
+

{t('about.credits.backend')}

+
    + {backendCredits.map((item, index) => { + return ( +
  • + + {item.name} + +
  • + ); + })} +
  • ...
  • +
+
+
+ + ); +}; + +export default About; diff --git a/Timeline/ClientApp/src/app/about/about.sass b/Timeline/ClientApp/src/app/about/about.sass new file mode 100644 index 00000000..f4d00cae --- /dev/null +++ b/Timeline/ClientApp/src/app/about/about.sass @@ -0,0 +1,4 @@ +.about-link-icon + @extend .mx-2 + width: 1.2em + height: 1.2em diff --git a/Timeline/ClientApp/src/app/about/author-avatar.png b/Timeline/ClientApp/src/app/about/author-avatar.png new file mode 100644 index 00000000..d890d8d0 Binary files /dev/null and b/Timeline/ClientApp/src/app/about/author-avatar.png differ diff --git a/Timeline/ClientApp/src/app/about/github.png b/Timeline/ClientApp/src/app/about/github.png new file mode 100644 index 00000000..ea6ff545 Binary files /dev/null and b/Timeline/ClientApp/src/app/about/github.png differ diff --git a/Timeline/ClientApp/src/app/admin/Admin.tsx b/Timeline/ClientApp/src/app/admin/Admin.tsx new file mode 100644 index 00000000..af07dd2b --- /dev/null +++ b/Timeline/ClientApp/src/app/admin/Admin.tsx @@ -0,0 +1,78 @@ +import React, { Fragment } from 'react'; +import { Nav, NavItem, NavLink } from 'reactstrap'; +import { + Redirect, + Route, + Switch, + useRouteMatch, + useHistory +} from 'react-router'; +import classnames from 'classnames'; + +import AppBar from '../common/AppBar'; +import UserAdmin from './UserAdmin'; + +import { UserWithToken } from '../data/user'; + +interface AdminProps { + user: UserWithToken; +} + +const Admin: React.FC = props => { + const match = useRouteMatch(); + const history = useHistory(); + type TabNames = 'users' | 'more'; + + const tabName = history.location.pathname.replace(match.path + '/', ''); + + function toggle(newTab: TabNames): void { + history.push(`${match.url}/${newTab}`); + } + + const createRoute = ( + name: string, + body: React.ReactNode + ): React.ReactNode => { + return ( + + +
+ + {body} + + ); + }; + + return ( + + + + {createRoute('users', )} + {createRoute('more',
More Page Works
)} +
+
+ ); +}; + +export default Admin; diff --git a/Timeline/ClientApp/src/app/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx new file mode 100644 index 00000000..a3115073 --- /dev/null +++ b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx @@ -0,0 +1,464 @@ +import React, { useState, useEffect } from 'react'; +import { + ListGroupItem, + Row, + Col, + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Spinner, + Button, +} from 'reactstrap'; +import axios from 'axios'; + +import OperationDialog from '../common/OperationDialog'; + +import { User, UserWithToken } from '../data/user'; + +const apiBaseUrl = '/api'; + +async function fetchUserList(_token: string): Promise { + const res = await axios.get(`${apiBaseUrl}/users`); + return res.data; +} + +interface CreateUserInfo { + username: string; + password: string; + administrator: boolean; +} + +async function createUser(user: CreateUserInfo, token: string): Promise { + const res = await axios.post( + `${apiBaseUrl}/userop/createuser?token=${token}`, + user + ); + return res.data; +} + +function deleteUser(username: string, token: string): Promise { + return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`); +} + +function changeUsername( + oldUsername: string, + newUsername: string, + token: string +): Promise { + return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, { + username: newUsername, + }); +} + +function changePassword( + username: string, + newPassword: string, + token: string +): Promise { + return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, { + password: newPassword, + }); +} + +function changePermission( + username: string, + newPermission: boolean, + token: string +): Promise { + return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, { + administrator: newPermission, + }); +} + +const kChangeUsername = 'changeusername'; +const kChangePassword = 'changepassword'; +const kChangePermission = 'changepermission'; +const kDelete = 'delete'; + +type TChangeUsername = typeof kChangeUsername; +type TChangePassword = typeof kChangePassword; +type TChangePermission = typeof kChangePermission; +type TDelete = typeof kDelete; + +type ContextMenuItem = + | TChangeUsername + | TChangePassword + | TChangePermission + | TDelete; + +interface UserCardProps { + onContextMenu: (item: ContextMenuItem) => void; + user: User; +} + +const UserItem: React.FC = (props) => { + const user = props.user; + + const createClickCallback = (item: ContextMenuItem): (() => void) => { + return () => { + props.onContextMenu(item); + }; + }; + + return ( + + + +

{user.username}

+ + {user.administrator ? 'administrator' : 'user'} + + + + + + Manage + + + + Change Username + + + Change Password + + + Change Permission + + + Delete + + + + +
+
+ ); +}; + +interface DialogProps { + open: boolean; + close: () => void; +} + +interface CreateUserDialogProps extends DialogProps { + process: (user: CreateUserInfo) => Promise; +} + +const CreateUserDialog: React.FC = (props) => { + return ( + + props.process({ + username: username as string, + password: password as string, + administrator: administrator as boolean, + }) + } + close={props.close} + open={props.open} + /> + ); +}; + +const UsernameLabel: React.FC = (props) => { + return {props.children}; +}; + +interface UserDeleteDialogProps extends DialogProps { + username: string; + process: () => Promise; +} + +const UserDeleteDialog: React.FC = (props) => { + return ( + ( + <> + {'You are deleting user '} + {props.username} + {' !'} + + )} + onProcess={props.process} + /> + ); +}; + +interface UserModifyDialogProps extends DialogProps { + username: string; + process: (value: T) => Promise; +} + +const UserChangeUsernameDialog: React.FC> = ( + props +) => { + return ( + ( + <> + {'You are change the username of user '} + {props.username} + {' !'} + + )} + inputScheme={[{ type: 'text', label: 'New Username' }]} + onProcess={([newUsername]) => { + return props.process(newUsername as string); + }} + /> + ); +}; + +const UserChangePasswordDialog: React.FC> = ( + props +) => { + return ( + ( + <> + {'You are change the password of user '} + {props.username} + {' !'} + + )} + inputScheme={[{ type: 'text', label: 'New Password' }]} + onProcess={([newPassword]) => { + return props.process(newPassword as string); + }} + /> + ); +}; + +interface UserChangePermissionDialogProps extends DialogProps { + username: string; + newPermission: boolean; + process: () => Promise; +} + +const UserChangePermissionDialog: React.FC = ( + props +) => { + return ( + ( + <> + {'You are change user '} + {props.username} + {' to '} + + {props.newPermission ? 'administrator' : 'normal user'} + + {' !'} + + )} + onProcess={props.process} + /> + ); +}; + +interface UserAdminProps { + user: UserWithToken; +} + +const UserAdmin: React.FC = (props) => { + type DialogInfo = + | null + | { + type: 'create'; + } + | { type: TDelete; username: string } + | { + type: TChangeUsername; + username: string; + } + | { + type: TChangePassword; + username: string; + } + | { + type: TChangePermission; + username: string; + newPermission: boolean; + }; + + const [users, setUsers] = useState(null); + const [dialog, setDialog] = useState(null); + + const token = props.user.token; + + useEffect(() => { + let subscribe = true; + void fetchUserList(props.user.token).then((us) => { + if (subscribe) { + setUsers(us); + } + }); + return () => { + subscribe = false; + }; + }, [props.user]); + + let dialogNode: React.ReactNode; + if (dialog) + switch (dialog.type) { + case 'create': + dialogNode = ( + setDialog(null)} + process={async (user) => { + const u = await createUser(user, token); + setUsers((oldUsers) => [...(oldUsers ?? []), u]); + }} + /> + ); + break; + case 'delete': + dialogNode = ( + setDialog(null)} + username={dialog.username} + process={async () => { + await deleteUser(dialog.username, token); + setUsers((oldUsers) => + (oldUsers ?? []).filter((u) => u.username !== dialog.username) + ); + }} + /> + ); + break; + case kChangeUsername: + dialogNode = ( + setDialog(null)} + username={dialog.username} + process={async (newUsername) => { + await changeUsername(dialog.username, newUsername, token); + setUsers((oldUsers) => { + const users = (oldUsers ?? []).slice(); + const findedUser = users.find( + (u) => u.username === dialog.username + ); + if (findedUser) findedUser.username = newUsername; + return users; + }); + }} + /> + ); + break; + case kChangePassword: + dialogNode = ( + setDialog(null)} + username={dialog.username} + process={async (newPassword) => { + await changePassword(dialog.username, newPassword, token); + }} + /> + ); + break; + case kChangePermission: { + const newPermission = dialog.newPermission; + dialogNode = ( + setDialog(null)} + username={dialog.username} + newPermission={newPermission} + process={async () => { + await changePermission(dialog.username, newPermission, token); + setUsers((oldUsers) => { + const users = (oldUsers ?? []).slice(); + const findedUser = users.find( + (u) => u.username === dialog.username + ); + if (findedUser) findedUser.administrator = newPermission; + return users; + }); + }} + /> + ); + break; + } + } + + if (users) { + const userComponents = users.map((user) => { + return ( + { + setDialog( + item === kChangePermission + ? { + type: kChangePermission, + username: user.username, + newPermission: !user.administrator, + } + : { + type: item, + username: user.username, + } + ); + }} + /> + ); + }); + + return ( + <> + + {userComponents} + {dialogNode} + + ); + } else { + return ; + } +}; + +export default UserAdmin; diff --git a/Timeline/ClientApp/src/app/common.ts b/Timeline/ClientApp/src/app/common.ts new file mode 100644 index 00000000..297b4a77 --- /dev/null +++ b/Timeline/ClientApp/src/app/common.ts @@ -0,0 +1,44 @@ +import React from 'react'; +import { Observable, Subject } from 'rxjs'; + +// This error is thrown when ui goes wrong with bad logic. +// Such as a variable should not be null, but it does. +// This error should never occur. If it does, it indicates there is some logic bug in codes. +export class UiLogicError extends Error {} + +export function useEventEmiiter(): [() => Observable, () => void] { + const ref = React.useRef | null>(null); + + return React.useMemo(() => { + const getter = (): Subject => { + if (ref.current == null) { + ref.current = new Subject(); + } + return ref.current; + }; + const trigger = (): void => { + getter().next(null); + }; + return [getter, trigger]; + }, []); +} + +export function useValueEventEmiiter(): [ + () => Observable, + (value: T) => void +] { + const ref = React.useRef | null>(null); + + return React.useMemo(() => { + const getter = (): Subject => { + if (ref.current == null) { + ref.current = new Subject(); + } + return ref.current; + }; + const trigger = (value: T): void => { + getter().next(value); + }; + return [getter, trigger]; + }, []); +} diff --git a/Timeline/ClientApp/src/app/common/AlertHost.tsx b/Timeline/ClientApp/src/app/common/AlertHost.tsx new file mode 100644 index 00000000..89831073 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/AlertHost.tsx @@ -0,0 +1,96 @@ +import React, { useCallback } from 'react'; +import { Alert } from 'reactstrap'; +import without from 'lodash/without'; +import concat from 'lodash/concat'; + +import { + alertService, + AlertInfoEx, + kAlertHostId, + AlertInfo, +} from './alert-service'; +import { useTranslation } from 'react-i18next'; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC = (props) => { + const { alert } = props; + const { dismissTime } = alert; + + const { t } = useTranslation(); + + React.useEffect(() => { + const tag = + dismissTime === 'never' + ? null + : typeof dismissTime === 'number' + ? window.setTimeout(props.close, dismissTime) + : window.setTimeout(props.close, 5000); + return () => { + if (tag != null) { + window.clearTimeout(tag); + } + }; + }, [dismissTime, props.close]); + + return ( + + {(() => { + const { message } = alert; + if (typeof message === 'function') { + const Message = message; + return ; + } else if (typeof message === 'object' && message.type === 'i18n') { + return t(message.key); + } else return alert.message; + })()} + + ); +}; + +// oh what a bad name! +interface AlertInfoExEx extends AlertInfoEx { + close: () => void; +} + +export const AlertHost: React.FC = () => { + const [alerts, setAlerts] = React.useState([]); + + // react guarantee that state setters are stable, so we don't need to add it to dependency list + + const consume = useCallback((alert: AlertInfoEx): void => { + const alertEx: AlertInfoExEx = { + ...alert, + close: () => { + setAlerts((oldAlerts) => { + return without(oldAlerts, alertEx); + }); + }, + }; + setAlerts((oldAlerts) => { + return concat(oldAlerts, alertEx); + }); + }, []); + + React.useEffect(() => { + alertService.registerConsumer(consume); + return () => { + alertService.unregisterConsumer(consume); + }; + }, [consume]); + + return ( +
+ {alerts.map((alert) => { + return ( + + ); + })} +
+ ); +}; + +export default AlertHost; diff --git a/Timeline/ClientApp/src/app/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx new file mode 100644 index 00000000..061ba08c --- /dev/null +++ b/Timeline/ClientApp/src/app/common/AppBar.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { useHistory, matchPath } from 'react-router'; +import { Link, NavLink } from 'react-router-dom'; +import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap'; +import { useMediaQuery } from 'react-responsive'; +import { useTranslation } from 'react-i18next'; + +import { useUser, useAvatarUrl } from '../data/user'; + +import TimelineLogo from './TimelineLogo'; + +const AppBar: React.FC = (_) => { + const history = useHistory(); + const user = useUser(); + const avatarUrl = useAvatarUrl(user?.username); + + const { t } = useTranslation(); + + const isUpMd = useMediaQuery({ + minWidth: getComputedStyle(document.documentElement).getPropertyValue( + '--breakpoint-md' + ), + }); + + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + + const toggleMenu = React.useCallback((): void => { + setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen); + }, []); + + const isAdministrator = user && user.administrator; + + const rightArea = ( +
+ {user != null ? ( + + + + ) : ( + + {t('nav.login')} + + )} +
+ ); + + return ( + + + + Timeline + + + {isUpMd ? null : rightArea} + + + + + {isUpMd ? rightArea : null} + + + ); +}; + +export default AppBar; diff --git a/Timeline/ClientApp/src/app/common/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx new file mode 100644 index 00000000..94f4aa43 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/FileInput.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { ExcludeKey } from '../utilities/type'; + +export interface FileInputProps + extends ExcludeKey< + React.InputHTMLAttributes, + 'type' | 'id' + > { + inputId?: string; + labelText: string; + color?: string; + className?: string; +} + +const FileInput: React.FC = props => { + const { inputId, labelText, color, className, ...otherProps } = props; + + const realInputId = React.useMemo(() => { + if (inputId != null) return inputId; + return ( + 'file-input-' + + (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1) + ); + }, [inputId]); + + return ( + <> + + + + ); +}; + +export default FileInput; diff --git a/Timeline/ClientApp/src/app/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/common/ImageCropper.tsx new file mode 100644 index 00000000..fb9bb4f9 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/ImageCropper.tsx @@ -0,0 +1,306 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +import { UiLogicError } from '../common'; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + imageUrl: string; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, imageUrl, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState( + null + ); + const [imageInfo, setImageInfo] = React.useState(null); + + const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { + if (c == null) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + return { + left: c.left || 0, + top: c.top || 0, + width: c.width || 0, + height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, + }; + }; + + const c = normalizeClip(clip); + + const imgElementRef = React.useRef(null); + + const onImageRef = React.useCallback( + (e: HTMLImageElement | null) => { + imgElementRef.current = e; + if (imageElementCallback != null && e == null) { + imageElementCallback(null); + } + }, + [imageElementCallback] + ); + + const onImageLoad = React.useCallback( + (e: React.SyntheticEvent) => { + const img = e.currentTarget; + const landscape = img.naturalWidth >= img.naturalHeight; + + const info = { + width: img.naturalWidth, + height: img.naturalHeight, + landscape, + ratio: img.naturalHeight / img.naturalWidth, + maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, + maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, + }; + setImageInfo(info); + onChange({ left: 0, top: 0, width: info.maxClipWidth }); + if (imageElementCallback != null) { + imageElementCallback(img); + } + }, + [onChange, imageElementCallback] + ); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (oldState != null) return; + e.currentTarget.setPointerCapture(e.pointerId); + setOldState({ + x: e.clientX, + y: e.clientY, + clip: c, + pointerId: e.pointerId, + }); + }, + [oldState, c] + ); + + const onPointerUp = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null || oldState.pointerId !== e.pointerId) return; + e.currentTarget.releasePointerCapture(e.pointerId); + setOldState(null); + }, + [oldState] + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError('Image element is null.'); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.y / imgElement.height, + }; + + const newRatio = { + x: oldClip.left + moveRatio.x, + y: oldClip.top + moveRatio.y, + }; + if (newRatio.x < 0) { + newRatio.x = 0; + } else if (newRatio.x > 1 - oldClip.width) { + newRatio.x = 1 - oldClip.width; + } + if (newRatio.y < 0) { + newRatio.y = 0; + } else if (newRatio.y > 1 - oldClip.height) { + newRatio.y = 1 - oldClip.height; + } + + onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); + }, + [oldState, onChange] + ); + + const onHandlerPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const ratio = imageInfo == null ? 1 : imageInfo.ratio; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError('Image element is null.'); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.x / imgElement.width / ratio, + }; + + const newRatio = { + x: oldClip.width + moveRatio.x, + y: oldClip.height + moveRatio.y, + }; + + const maxRatio = { + x: Math.min(1 - oldClip.left, newRatio.x), + y: Math.min(1 - oldClip.top, newRatio.y), + }; + + const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + + let newWidth; + if (newRatio.x < 0) { + newWidth = 0; + } else if (newRatio.x > maxWidthRatio) { + newWidth = maxWidthRatio; + } else { + newWidth = newRatio.x; + } + + onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); + }, + [imageInfo, oldState, onChange] + ); + + const toPercentage = (n: number): string => `${n}%`; + + // fuck!!! I just can't find a better way to implement this in pure css + const containerStyle: React.CSSProperties = (() => { + if (imageInfo == null) { + return { width: '100%', paddingTop: '100%', height: 0 }; + } else { + if (imageInfo.ratio > 1) { + return { + width: toPercentage(100 / imageInfo.ratio), + paddingTop: '100%', + height: 0, + }; + } else { + return { + width: '100%', + paddingTop: toPercentage(100 * imageInfo.ratio), + height: 0, + }; + } + } + })(); + + return ( +
+ to crop +
+
+
+
+
+ ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string +): Promise { + return new Promise((resolve, reject) => { + const naturalSize = { + width: image.naturalWidth, + height: image.naturalHeight, + }; + const clipArea = { + x: naturalSize.width * clip.left, + y: naturalSize.height * clip.top, + length: naturalSize.width * clip.width, + }; + + const canvas = document.createElement('canvas'); + canvas.width = clipArea.length; + canvas.height = clipArea.length; + const context = canvas.getContext('2d'); + + if (context == null) throw new Error('Failed to create context.'); + + context.drawImage( + image, + clipArea.x, + clipArea.y, + clipArea.length, + clipArea.length, + 0, + 0, + clipArea.length, + clipArea.length + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error('canvas.toBlob returns null')); + } else { + resolve(blob); + } + }, mimeType); + }); +} diff --git a/Timeline/ClientApp/src/app/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/common/LoadingPage.tsx new file mode 100644 index 00000000..c1bc7105 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/LoadingPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; + +const LoadingPage: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default LoadingPage; diff --git a/Timeline/ClientApp/src/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx new file mode 100644 index 00000000..501a353e --- /dev/null +++ b/Timeline/ClientApp/src/app/common/OperationDialog.tsx @@ -0,0 +1,381 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Spinner, + Container, + ModalBody, + Label, + Input, + FormGroup, + FormFeedback, + ModalFooter, + Button, + Modal, + ModalHeader, + FormText, +} from 'reactstrap'; + +import { UiLogicError } from '../common'; + +const DefaultProcessPrompt: React.FC = (_) => { + return ( + + + + ); +}; + +interface DefaultErrorPromptProps { + error?: string; +} + +const DefaultErrorPrompt: React.FC = (props) => { + const { t } = useTranslation(); + + let result =

{t('operationDialog.error')}

; + + if (props.error != null) { + result = ( + <> + {result} +

{props.error}

+ + ); + } + + return result; +}; + +export type OperationInputOptionalError = undefined | null | string; + +export interface OperationInputErrorInfo { + [index: number]: OperationInputOptionalError; +} + +export type OperationInputValidator = ( + value: TValue, + values: (string | boolean)[] +) => OperationInputOptionalError | OperationInputErrorInfo; + +export interface OperationTextInputInfo { + type: 'text'; + password?: boolean; + label?: string; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes, + 'type' | 'value' | 'onChange' + >; + helperText?: string; + validator?: OperationInputValidator; +} + +export interface OperationBoolInputInfo { + type: 'bool'; + label: string; + initValue?: boolean; +} + +export interface OperationSelectInputInfoOption { + value: string; + label: string; + icon?: React.ReactElement; +} + +export interface OperationSelectInputInfo { + type: 'select'; + label: string; + options: OperationSelectInputInfoOption[]; + initValue?: string; +} + +export type OperationInputInfo = + | OperationTextInputInfo + | OperationBoolInputInfo + | OperationSelectInputInfo; + +interface OperationResult { + type: 'success' | 'failure'; + data: unknown; +} + +interface OperationDialogProps { + open: boolean; + close: () => void; + title: React.ReactNode; + titleColor?: 'default' | 'dangerous' | 'create' | string; + onProcess: (inputs: (string | boolean)[]) => Promise; + inputScheme?: OperationInputInfo[]; + inputPrompt?: string | (() => React.ReactNode); + processPrompt?: () => React.ReactNode; + successPrompt?: (data: unknown) => React.ReactNode; + failurePrompt?: (error: unknown) => React.ReactNode; + onSuccessAndClose?: () => void; +} + +const OperationDialog: React.FC = (props) => { + const inputScheme = props.inputScheme ?? []; + + const { t } = useTranslation(); + + type Step = 'input' | 'process' | OperationResult; + const [step, setStep] = useState('input'); + const [values, setValues] = useState<(boolean | string)[]>( + inputScheme.map((i) => { + if (i.type === 'bool') { + return i.initValue ?? false; + } else if (i.type === 'text' || i.type === 'select') { + return i.initValue ?? ''; + } else { + throw new UiLogicError('Unknown input scheme.'); + } + }) + ); + const [inputError, setInputError] = useState({}); + + const close = (): void => { + if (step !== 'process') { + props.close(); + if ( + typeof step === 'object' && + step.type === 'success' && + props.onSuccessAndClose + ) { + props.onSuccessAndClose(); + } + } else { + console.log('Attempt to close modal when processing.'); + } + }; + + const onConfirm = (): void => { + setStep('process'); + props.onProcess(values).then( + (d: unknown) => { + setStep({ + type: 'success', + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: 'failure', + data: e, + }); + } + ); + }; + + let body: React.ReactNode; + if (step === 'input') { + let inputPrompt = + typeof props.inputPrompt === 'function' + ? props.inputPrompt() + : props.inputPrompt; + inputPrompt =
{inputPrompt}
; + + const updateValue = ( + index: number, + newValue: string | boolean + ): (string | boolean)[] => { + const oldValues = values; + const newValues = oldValues.slice(); + newValues[index] = newValue; + setValues(newValues); + return newValues; + }; + + const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => { + for (let i = 0; i < inputScheme.length; i++) { + if (inputScheme[i].type === 'text' && errorInfo[i] != null) { + return true; + } + } + return false; + }; + + const calculateError = ( + oldError: OperationInputErrorInfo, + index: number, + newError: OperationInputOptionalError | OperationInputErrorInfo + ): OperationInputErrorInfo => { + if (newError === undefined) { + return oldError; + } else if (newError === null || typeof newError === 'string') { + return { ...oldError, [index]: newError }; + } else { + const newInputError: OperationInputErrorInfo = { ...oldError }; + for (const [index, error] of Object.entries(newError)) { + if (error !== undefined) { + newInputError[+index] = error as OperationInputOptionalError; + } + } + return newInputError; + } + }; + + const validateAll = (): boolean => { + let newInputError = inputError; + for (let i = 0; i < inputScheme.length; i++) { + const item = inputScheme[i]; + if (item.type === 'text') { + newInputError = calculateError( + newInputError, + i, + item.validator?.(values[i] as string, values) + ); + } + } + const result = !testErrorInfo(newInputError); + setInputError(newInputError); + return result; + }; + + body = ( + <> + + {inputPrompt} + {inputScheme.map((item, index) => { + const value = values[index]; + const error: string | undefined = ((e) => + typeof e === 'string' ? t(e) : undefined)(inputError?.[index]); + + if (item.type === 'text') { + return ( + + {item.label && } + { + const v = e.target.value; + const newValues = updateValue(index, v); + setInputError( + calculateError( + inputError, + index, + item.validator?.(v, newValues) + ) + ); + }} + invalid={error != null} + {...item.textFieldProps} + /> + {error != null && {error}} + {item.helperText && {t(item.helperText)}} + + ); + } else if (item.type === 'bool') { + return ( + + { + updateValue( + index, + (e.target as HTMLInputElement).checked + ); + }} + /> + + + ); + } else if (item.type === 'select') { + return ( + + + { + updateValue(index, event.target.value); + }} + > + {item.options.map((option, i) => { + return ( + + ); + })} + + + ); + } + })} + + + + + + + ); + } else if (step === 'process') { + body = ( + + {props.processPrompt?.() ?? } + + ); + } else { + let content: React.ReactNode; + const result = step; + if (result.type === 'success') { + content = + props.successPrompt?.(result.data) ?? t('operationDialog.success'); + if (typeof content === 'string') + content =

{content}

; + } else { + content = props.failurePrompt?.(result.data) ?? ; + if (typeof content === 'string') + content = ; + } + body = ( + <> + {content} + + + + + ); + } + + const title = typeof props.title === 'string' ? t(props.title) : props.title; + + return ( + + + {title} + + {body} + + ); +}; + +export default OperationDialog; diff --git a/Timeline/ClientApp/src/app/common/SearchInput.tsx b/Timeline/ClientApp/src/app/common/SearchInput.tsx new file mode 100644 index 00000000..46fb00d1 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/SearchInput.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react'; +import clsx from 'clsx'; +import { Spinner, Input, Button } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +export interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + className?: string; + loading?: boolean; + buttonText?: string; + placeholder?: string; + additionalButton?: React.ReactNode; +} + +const SearchInput: React.FC = (props) => { + const { onChange, onButtonClick } = props; + + const { t } = useTranslation(); + + const onInputChange = useCallback( + (event: React.ChangeEvent): void => { + onChange(event.currentTarget.value); + }, + [onChange] + ); + + const onInputKeyPress = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + onButtonClick(); + } + }, + [onButtonClick] + ); + + return ( +
+ +
+ {props.additionalButton} +
+
+ {props.loading ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default SearchInput; diff --git a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx new file mode 100644 index 00000000..1ec62021 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from 'react'; + +export interface TimelineLogoProps extends SVGAttributes { + color?: string; +} + +const TimelineLogo: React.FC = props => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? 'currentcolor'; + return ( + + + + + + ); +}; + +export default TimelineLogo; diff --git a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx new file mode 100644 index 00000000..3b721985 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from 'react'; + +export interface UserTimelineLogoProps extends SVGAttributes { + color?: string; +} + +const UserTimelineLogo: React.FC = props => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? 'currentcolor'; + + return ( + + + + + + + + + + + + ); +}; + +export default UserTimelineLogo; diff --git a/Timeline/ClientApp/src/app/common/alert-service.ts b/Timeline/ClientApp/src/app/common/alert-service.ts new file mode 100644 index 00000000..957c5e10 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/alert-service.ts @@ -0,0 +1,61 @@ +import React from 'react'; +import pull from 'lodash/pull'; + +export interface AlertInfo { + type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; + message: string | React.FC | { type: 'i18n'; key: string }; + dismissTime?: number | 'never'; +} + +export interface AlertInfoEx extends AlertInfo { + id: number; +} + +export type AlertConsumer = (alerts: AlertInfoEx) => void; + +export class AlertService { + private consumers: AlertConsumer[] = []; + private savedAlerts: AlertInfoEx[] = []; + private currentId = 1; + + private produce(alert: AlertInfoEx): void { + for (const consumer of this.consumers) { + consumer(alert); + } + } + + registerConsumer(consumer: AlertConsumer): void { + this.consumers.push(consumer); + if (this.savedAlerts.length !== 0) { + for (const alert of this.savedAlerts) { + this.produce(alert); + } + this.savedAlerts = []; + } + } + + unregisterConsumer(consumer: AlertConsumer): void { + pull(this.consumers, consumer); + } + + push(alert: AlertInfo): void { + const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ }; + if (this.consumers.length === 0) { + this.savedAlerts.push(newAlert); + } else { + this.produce(newAlert); + } + } +} + +export const alertService = new AlertService(); + +export function pushAlert(alert: AlertInfo): void { + alertService.push(alert); +} + +export const kAlertHostId = 'alert-host'; + +export function getAlertHost(): HTMLElement | null { + return document.getElementById(kAlertHostId); +} diff --git a/Timeline/ClientApp/src/app/common/alert.sass b/Timeline/ClientApp/src/app/common/alert.sass new file mode 100644 index 00000000..c3560b87 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/alert.sass @@ -0,0 +1,15 @@ +.alert-container + position: fixed + z-index: $zindex-popover + +@include media-breakpoint-up(sm) + .alert-container + bottom: 0 + right: 0 + +@include media-breakpoint-down(sm) + .alert-container + bottom: 0 + right: 0 + left: 0 + text-align: center diff --git a/Timeline/ClientApp/src/app/common/common.sass b/Timeline/ClientApp/src/app/common/common.sass new file mode 100644 index 00000000..78e6fd14 --- /dev/null +++ b/Timeline/ClientApp/src/app/common/common.sass @@ -0,0 +1,33 @@ +.image-cropper-container + position: relative + box-sizing: border-box + user-select: none + +.image-cropper-container img + position: absolute + left: 0 + top: 0 + width: 100% + height: 100% + +.image-cropper-mask-container + position: absolute + left: 0 + top: 0 + right: 0 + bottom: 0 + overflow: hidden + +.image-cropper-mask + position: absolute + box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%) + touch-action: none + +.image-cropper-handler + position: absolute + width: 26px + height: 26px + border: black solid 2px + border-radius: 50% + background: white + touch-action: none diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts new file mode 100644 index 00000000..2bc6de56 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts @@ -0,0 +1,125 @@ +// Remarks for SubscriptionHub: +// 1. Compared with 'push' sematics in rxjs subject, we need 'pull'. In other words, no subscription, no updating. +// 2. We need a way to finalize the last object. For example, if it has an object url, we need to revoke it. +// 3. Make api easier to use and write less boilerplate codes. +// +// There might be some bugs, especially memory leaks and in asynchronization codes. + +import * as rxjs from 'rxjs'; +import { filter } from 'rxjs/operators'; + +export type Subscriber = (data: TData) => void; + +export class Subscription { + constructor(private _onUnsubscribe: () => void) {} + + unsubscribe(): void { + this._onUnsubscribe(); + } +} + +class SubscriptionToken { + constructor(public _subscription: rxjs.Subscription) {} +} + +class SubscriptionLine { + private _lastDataPromise: Promise; + private _dataSubject = new rxjs.BehaviorSubject(undefined); + private _data$: rxjs.Observable = this._dataSubject.pipe( + filter((d) => d !== undefined) + ) as rxjs.Observable; + private _refCount = 0; + + constructor( + _creator: () => Promise, + private _destroyer: (data: TData) => void, + private _onZeroRef: (self: SubscriptionLine) => void + ) { + this._lastDataPromise = _creator().then((data) => { + this._dataSubject.next(data); + }); + } + + subscribe(subscriber: Subscriber): SubscriptionToken { + const subscription = this._data$.subscribe(subscriber); + this._refCount += 1; + return new SubscriptionToken(subscription); + } + + unsubscribe(token: SubscriptionToken): void { + token._subscription.unsubscribe(); + this._refCount -= 1; + if (this._refCount === 0) { + void this._lastDataPromise.then(() => { + const last = this._dataSubject.value; + if (last !== undefined) { + this._destroyer(last); + } + }); + this._onZeroRef(this); + } + } + + next(updator: () => Promise): void { + this._lastDataPromise = this._lastDataPromise + .then(() => updator()) + .then((data) => { + const last = this._dataSubject.value; + if (last !== undefined) { + this._destroyer(last); + } + this._dataSubject.next(data); + }); + } +} + +export interface ISubscriptionHub { + subscribe(key: TKey, subscriber: Subscriber): Subscription; +} + +export class SubscriptionHub + implements ISubscriptionHub { + constructor( + public keyToString: (key: TKey) => string, + public creator: (key: TKey) => Promise, + public destroyer: (key: TKey, data: TData) => void + ) {} + + private subscriptionLineMap = new Map>(); + + subscribe(key: TKey, subscriber: Subscriber): Subscription { + const keyString = this.keyToString(key); + const line = (() => { + const savedLine = this.subscriptionLineMap.get(keyString); + if (savedLine == null) { + const newLine = new SubscriptionLine( + () => this.creator(key), + (data) => { + this.destroyer(key, data); + }, + () => { + this.subscriptionLineMap.delete(keyString); + } + ); + this.subscriptionLineMap.set(keyString, newLine); + return newLine; + } else { + return savedLine; + } + })(); + const token = line.subscribe(subscriber); + return new Subscription(() => { + line.unsubscribe(token); + }); + } + + // Old data is destroyed automatically. + // updator is called only if there is subscription. + update(key: TKey, updator: (key: TKey) => Promise): void { + const keyString = this.keyToString(key); + const line = this.subscriptionLineMap.get(keyString); + if (line != null) { + line.next(() => updator(key)); + } + } +} diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts new file mode 100644 index 00000000..7f3f4e93 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -0,0 +1,4 @@ +export interface BlobWithUrl { + blob: Blob; + url: string; +} diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts new file mode 100644 index 00000000..dde204be --- /dev/null +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -0,0 +1,265 @@ +import React from 'react'; +import XRegExp from 'xregexp'; +import { Observable, from } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { UserAuthInfo, checkLogin, userService } from './user'; + +import { BlobWithUrl } from './common'; +import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; + +export { kTimelineVisibilities } from '../http/timeline'; + +export type { TimelineVisibility } from '../http/timeline'; + +import { + TimelineVisibility, + HttpTimelineInfo, + HttpTimelinePatchRequest, + HttpTimelinePostPostRequest, + HttpTimelinePostPostRequestContent, + HttpTimelinePostPostRequestTextContent, + HttpTimelinePostPostRequestImageContent, + HttpTimelinePostInfo, + HttpTimelinePostContent, + HttpTimelinePostTextContent, + HttpTimelinePostImageContent, + getHttpTimelineClient, + HttpTimelineNotExistError, + HttpTimelineNameConflictError, +} from '../http/timeline'; +import { convertError } from '../utilities/rxjs'; + +export type TimelineInfo = HttpTimelineInfo; +export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; +export type TimelineCreatePostRequest = HttpTimelinePostPostRequest; +export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent; +export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent; +export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent; + +export interface TimelinePostInfo extends HttpTimelinePostInfo { + timelineName: string; +} + +export type TimelinePostContent = HttpTimelinePostContent; +export type TimelinePostTextContent = HttpTimelinePostTextContent; +export type TimelinePostImageContent = HttpTimelinePostImageContent; + +export const timelineVisibilityTooltipTranslationMap: Record< + TimelineVisibility, + string +> = { + Public: 'timeline.visibilityTooltip.public', + Register: 'timeline.visibilityTooltip.register', + Private: 'timeline.visibilityTooltip.private', +}; + +export class TimelineNotExistError extends Error {} +export class TimelineNameConflictError extends Error {} + +export interface PostKey { + timelineName: string; + postId: number; +} + +export class TimelineService { + getTimeline(timelineName: string): Observable { + return from(getHttpTimelineClient().getTimeline(timelineName)).pipe( + convertError(HttpTimelineNotExistError, TimelineNotExistError) + ); + } + + createTimeline(timelineName: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().postTimeline( + { + name: timelineName, + }, + user.token + ) + ).pipe( + convertError(HttpTimelineNameConflictError, TimelineNameConflictError) + ); + } + + changeTimelineProperty( + timelineName: string, + req: TimelineChangePropertyRequest + ): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().patchTimeline(timelineName, req, user.token) + ); + } + + deleteTimeline(timelineName: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().deleteTimeline(timelineName, user.token) + ); + } + + addMember(timelineName: string, username: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().memberPut(timelineName, username, user.token) + ); + } + + removeMember(timelineName: string, username: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().memberDelete(timelineName, username, user.token) + ); + } + + getPosts(timelineName: string): Observable { + const token = userService.currentUser?.token; + return from(getHttpTimelineClient().listPost(timelineName, token)).pipe( + map((posts) => { + return posts.map((post) => ({ + ...post, + timelineName, + })); + }) + ); + } + + private _postDataSubscriptionHub = new SubscriptionHub( + (key) => `${key.timelineName}/${key.postId}`, + async (key) => { + const blob = ( + await getHttpTimelineClient().getPostData( + key.timelineName, + key.postId, + userService.currentUser?.token + ) + ).data; + const url = URL.createObjectURL(blob); + return { + blob, + url, + }; + }, + (_key, data) => { + URL.revokeObjectURL(data.url); + } + ); + + get postDataHub(): ISubscriptionHub { + return this._postDataSubscriptionHub; + } + + createPost( + timelineName: string, + request: TimelineCreatePostRequest + ): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().postPost(timelineName, request, user.token) + ).pipe(map((post) => ({ ...post, timelineName }))); + } + + deletePost(timelineName: string, postId: number): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().deletePost(timelineName, postId, user.token) + ); + } + + isMemberOf(username: string, timeline: TimelineInfo): boolean { + return timeline.members.findIndex((m) => m.username == username) >= 0; + } + + hasReadPermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo + ): boolean { + if (user != null && user.administrator) return true; + + const { visibility } = timeline; + if (visibility === 'Public') { + return true; + } else if (visibility === 'Register') { + if (user != null) return true; + } else if (visibility === 'Private') { + if (user != null && this.isMemberOf(user.username, timeline)) { + return true; + } + } + return false; + } + + hasPostPermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo + ): boolean { + if (user != null && user.administrator) return true; + + return ( + user != null && + (timeline.owner.username === user.username || + this.isMemberOf(user.username, timeline)) + ); + } + + hasManagePermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo + ): boolean { + if (user != null && user.administrator) return true; + + return user != null && user.username == timeline.owner.username; + } + + hasModifyPostPermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo, + post: TimelinePostInfo + ): boolean { + if (user != null && user.administrator) return true; + + return ( + user != null && + (user.username === timeline.owner.username || + user.username === post.author.username) + ); + } +} + +export const timelineService = new TimelineService(); + +const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u'); + +export function validateTimelineName(name: string): boolean { + return timelineNameReg.test(name); +} + +export function usePostDataUrl( + enable: boolean, + timelineName: string, + postId: number +): string | undefined { + const [url, setUrl] = React.useState(undefined); + React.useEffect(() => { + if (!enable) { + setUrl(undefined); + return; + } + + const subscription = timelineService.postDataHub.subscribe( + { + timelineName, + postId, + }, + ({ url }) => { + setUrl(url); + } + ); + return () => { + subscription.unsubscribe(); + }; + }, [timelineName, postId, enable]); + return url; +} diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts new file mode 100644 index 00000000..1be5cd3e --- /dev/null +++ b/Timeline/ClientApp/src/app/data/user.ts @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from 'react'; +import { BehaviorSubject, Observable, of, from } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { UiLogicError } from '../common'; +import { convertError } from '../utilities/rxjs'; +import { pushAlert } from '../common/alert-service'; + +import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; + +import { HttpNetworkError } from '../http/common'; +import { + getHttpTokenClient, + HttpCreateTokenBadCredentialError, +} from '../http/token'; +import { + getHttpUserClient, + HttpUserNotExistError, + HttpUser, +} from '../http/user'; + +import { BlobWithUrl } from './common'; + +export type User = HttpUser; + +export interface UserAuthInfo { + username: string; + administrator: boolean; +} + +export interface UserWithToken extends User { + token: string; +} + +export interface LoginCredentials { + username: string; + password: string; +} + +export class BadCredentialError { + message = 'login.badCredential'; +} + +const TOKEN_STORAGE_KEY = 'token'; + +export class UserService { + private userSubject = new BehaviorSubject( + undefined + ); + + get user$(): Observable { + return this.userSubject; + } + + get currentUser(): UserWithToken | null | undefined { + return this.userSubject.value; + } + + checkLoginState(): Observable { + if (this.currentUser !== undefined) + throw new UiLogicError("Already checked user. Can't check twice."); + + const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY); + if (savedToken) { + const u$ = from(getHttpTokenClient().verify({ token: savedToken })).pipe( + map( + (res) => + ({ + ...res.user, + token: savedToken, + } as UserWithToken) + ) + ); + u$.subscribe( + (user) => { + if (user != null) { + pushAlert({ + type: 'success', + message: { + type: 'i18n', + key: 'user.welcomeBack', + }, + }); + } + this.userSubject.next(user); + }, + (error) => { + if (error instanceof HttpNetworkError) { + pushAlert({ + type: 'danger', + message: { type: 'i18n', key: 'user.verifyTokenFailedNetwork' }, + }); + } else { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + pushAlert({ + type: 'danger', + message: { type: 'i18n', key: 'user.verifyTokenFailed' }, + }); + } + this.userSubject.next(null); + } + ); + return u$; + } + this.userSubject.next(null); + return of(null); + } + + login( + credentials: LoginCredentials, + rememberMe: boolean + ): Observable { + if (this.currentUser) { + throw new UiLogicError('Already login.'); + } + const u$ = from( + getHttpTokenClient().create({ + ...credentials, + expire: 30, + }) + ).pipe( + map( + (res) => + ({ + ...res.user, + token: res.token, + } as UserWithToken) + ), + convertError(HttpCreateTokenBadCredentialError, BadCredentialError) + ); + u$.subscribe((user) => { + if (rememberMe) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, user.token); + } + this.userSubject.next(user); + }); + return u$; + } + + logout(): void { + if (this.currentUser === undefined) { + throw new UiLogicError('Please check user first.'); + } + if (this.currentUser === null) { + throw new UiLogicError('No login.'); + } + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + this.userSubject.next(null); + } + + changePassword( + oldPassword: string, + newPassword: string + ): Observable { + if (this.currentUser == undefined) { + throw new UiLogicError("Not login or checked now, can't log out."); + } + const $ = from( + getHttpUserClient().changePassword( + { + oldPassword, + newPassword, + }, + this.currentUser.token + ) + ); + $.subscribe(() => { + this.logout(); + }); + return $; + } +} + +export const userService = new UserService(); + +export function useRawUser(): UserWithToken | null | undefined { + const [user, setUser] = useState( + userService.currentUser + ); + useEffect(() => { + const subscription = userService.user$.subscribe((u) => setUser(u)); + return () => { + subscription.unsubscribe(); + }; + }); + return user; +} + +export function useUser(): UserWithToken | null { + const [user, setUser] = useState(() => { + const initUser = userService.currentUser; + if (initUser === undefined) { + throw new UiLogicError( + "This is a logic error in user module. Current user can't be undefined in useUser." + ); + } + return initUser; + }); + useEffect(() => { + const sub = userService.user$.subscribe((u) => { + if (u === undefined) { + throw new UiLogicError( + "This is a logic error in user module. User emitted can't be undefined later." + ); + } + setUser(u); + }); + return () => { + sub.unsubscribe(); + }; + }); + return user; +} + +export function useUserLoggedIn(): UserWithToken { + const user = useUser(); + if (user == null) { + throw new UiLogicError('You assert user has logged in but actually not.'); + } + return user; +} + +export function checkLogin(): UserWithToken { + const user = userService.currentUser; + if (user == null) { + throw new UiLogicError('You must login to perform the operation.'); + } + return user; +} + +export class UserNotExistError extends Error {} + +export type AvatarInfo = BlobWithUrl; + +export class UserInfoService { + private _avatarSubscriptionHub = new SubscriptionHub( + (key) => key, + async (key) => { + const blob = (await getHttpUserClient().getAvatar(key)).data; + const url = URL.createObjectURL(blob); + return { + blob, + url, + }; + }, + (_key, data) => { + URL.revokeObjectURL(data.url); + } + ); + + getUserInfo(username: string): Observable { + return from(getHttpUserClient().get(username)).pipe( + convertError(HttpUserNotExistError, UserNotExistError) + ); + } + + async setAvatar(username: string, blob: Blob): Promise { + const user = checkLogin(); + await getHttpUserClient().putAvatar(username, blob, user.token); + this._avatarSubscriptionHub.update(username, () => + Promise.resolve({ + blob, + url: URL.createObjectURL(blob), + }) + ); + } + + get avatarHub(): ISubscriptionHub { + return this._avatarSubscriptionHub; + } +} + +export const userInfoService = new UserInfoService(); + +export function useAvatarUrl(username?: string): string | undefined { + const [avatarUrl, setAvatarUrl] = React.useState( + undefined + ); + React.useEffect(() => { + if (username == null) { + setAvatarUrl(undefined); + return; + } + + const subscription = userInfoService.avatarHub.subscribe( + username, + ({ url }) => { + setAvatarUrl(url); + } + ); + return () => { + subscription.unsubscribe(); + }; + }, [username]); + return avatarUrl; +} diff --git a/Timeline/ClientApp/src/app/home/Home.tsx b/Timeline/ClientApp/src/app/home/Home.tsx new file mode 100644 index 00000000..de25d5c1 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/Home.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { useHistory } from 'react-router'; +import { Row, Container, Button, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +import { useUser } from '../data/user'; +import { TimelineInfo } from '../data/timeline'; +import { getHttpTimelineClient } from '../http/timeline'; + +import AppBar from '../common/AppBar'; +import SearchInput from '../common/SearchInput'; +import TimelineBoardAreaWithoutUser from './TimelineBoardAreaWithoutUser'; +import TimelineBoardAreaWithUser from './TimelineBoardAreaWithUser'; +import TimelineCreateDialog from './TimelineCreateDialog'; + +const Home: React.FC = (_) => { + const history = useHistory(); + + const { t } = useTranslation(); + + const user = useUser(); + + const [navText, setNavText] = React.useState(''); + + const [publicTimelines, setPublicTimelines] = React.useState< + TimelineInfo[] | undefined + >(undefined); + const [ownTimelines, setOwnTimelines] = React.useState< + TimelineInfo[] | undefined + >(undefined); + const [joinTimelines, setJoinTimelines] = React.useState< + TimelineInfo[] | undefined + >(undefined); + + React.useEffect(() => { + let subscribe = true; + if (user == null) { + setOwnTimelines(undefined); + setJoinTimelines(undefined); + void getHttpTimelineClient() + .listTimeline({ visibility: 'Public' }) + .then((timelines) => { + if (subscribe) { + setPublicTimelines(timelines); + } + }); + } else { + setPublicTimelines(undefined); + void getHttpTimelineClient() + .listTimeline({ relate: user.username, relateType: 'own' }) + .then((timelines) => { + if (subscribe) { + setOwnTimelines(timelines); + } + }); + void getHttpTimelineClient() + .listTimeline({ relate: user.username, relateType: 'join' }) + .then((timelines) => { + if (subscribe) { + setJoinTimelines(timelines); + } + }); + } + return () => { + subscribe = false; + }; + }, [user]); + + const [dialog, setDialog] = React.useState<'create' | null>(null); + + const goto = React.useCallback((): void => { + if (navText === '') { + history.push('users/crupest'); + } else if (navText.startsWith('@')) { + history.push(`users/${navText.slice(1)}`); + } else { + history.push(`timelines/${navText}`); + } + }, [navText, history]); + + const openCreateDialog = React.useCallback(() => { + setDialog('create'); + }, []); + + const closeDialog = React.useCallback(() => { + setDialog(null); + }, []); + + return ( + <> + + + + + + {t('home.createButton')} + + ) + } + /> + + + {(() => { + if (user == null) { + return ( + + ); + } else { + return ( + + ); + } + })()} + + + {dialog === 'create' && } + + ); +}; + +export default Home; diff --git a/Timeline/ClientApp/src/app/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx new file mode 100644 index 00000000..2e017bf7 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Link } from 'react-router-dom'; +import { Spinner } from 'reactstrap'; + +import { TimelineInfo } from '../data/timeline'; + +import TimelineLogo from '../common/TimelineLogo'; +import UserTimelineLogo from '../common/UserTimelineLogo'; + +export interface TimelineBoardProps { + title?: string; + timelines?: TimelineInfo[]; + className?: string; +} + +const TimelineBoard: React.FC = props => { + const { title, timelines, className } = props; + + return ( +
+ {title != null &&

{title}

} + {(() => { + if (timelines == null) { + return ( +
+ +
+ ); + } else { + return timelines.map(timeline => { + const { name } = timeline; + const isPersonal = name.startsWith('@'); + const url = isPersonal + ? `/users/${timeline.owner.username}` + : `/timelines/${name}`; + return ( +
+ {isPersonal ? ( + + ) : ( + + )} + {name} +
+ ); + }); + } + })()} +
+ ); +}; + +export default TimelineBoard; diff --git a/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx new file mode 100644 index 00000000..a8603b9e --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Row, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +import TimelineBoard from './TimelineBoard'; +import { TimelineInfo } from '../data/timeline'; + +interface TimelineBoardAreaWithUserProps { + ownTimelines?: TimelineInfo[]; + joinTimelines?: TimelineInfo[]; +} + +const TimelineBoardAreaWithUser: React.FC = ( + props +) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + ); +}; + +export default TimelineBoardAreaWithUser; diff --git a/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx new file mode 100644 index 00000000..dc05ff09 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Row, Col } from 'reactstrap'; + +import { TimelineInfo } from '../data/timeline'; + +import TimelineBoard from './TimelineBoard'; + +interface TimelineBoardAreaWithoutUserProps { + publicTimelines?: TimelineInfo[]; +} + +const TimelineBoardAreaWithoutUser: React.FC = ( + props +) => { + const { publicTimelines } = props; + + return ( + + + + + + ); +}; + +export default TimelineBoardAreaWithoutUser; diff --git a/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx new file mode 100644 index 00000000..06f908f9 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useHistory } from 'react-router'; + +import { validateTimelineName, timelineService } from '../data/timeline'; + +import OperationDialog from '../common/OperationDialog'; + +interface TimelineCreateDialogProps { + open: boolean; + close: () => void; +} + +const TimelineCreateDialog: React.FC = (props) => { + const history = useHistory(); + + let nameSaved: string; + + return ( + { + if (name.length === 0) { + return 'home.createDialog.noEmpty'; + } else if (name.length > 26) { + return 'home.createDialog.tooLong'; + } else if (!validateTimelineName(name)) { + return 'home.createDialog.badFormat'; + } else { + return null; + } + }, + }, + ]} + onProcess={([name]) => { + nameSaved = name as string; + return timelineService.createTimeline(nameSaved).toPromise(); + }} + onSuccessAndClose={() => { + history.push(`timelines/${nameSaved}`); + }} + failurePrompt={(e) => `${e as string}`} + /> + ); +}; + +export default TimelineCreateDialog; diff --git a/Timeline/ClientApp/src/app/home/home.sass b/Timeline/ClientApp/src/app/home/home.sass new file mode 100644 index 00000000..28a2e5f3 --- /dev/null +++ b/Timeline/ClientApp/src/app/home/home.sass @@ -0,0 +1,13 @@ +.timeline-board-item + font-size: 1.1em + @extend .my-2 + .icon + height: 1.3em + @extend .mr-2 + +.timeline-board + @extend .cru-card + @extend .d-flex + @extend .flex-column + @extend .p-3 + min-height: 200px diff --git a/Timeline/ClientApp/src/app/http/common.ts b/Timeline/ClientApp/src/app/http/common.ts new file mode 100644 index 00000000..8fb8eb69 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/common.ts @@ -0,0 +1,140 @@ +import { AxiosError, AxiosResponse } from 'axios'; + +export const apiBaseUrl = '/api'; + +export function base64(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = function () { + resolve((reader.result as string).replace(/^data:.+;base64,/, '')); + }; + reader.readAsDataURL(blob); + }); +} + +export function extractStatusCode(error: AxiosError): number | null { + if (error.isAxiosError) { + const code = error?.response?.status; + if (typeof code === 'number') { + return code; + } + } + return null; +} + +export interface CommonErrorResponse { + code: number; + message: string; +} + +export function extractErrorCode( + error: AxiosError +): number | null { + if (error.isAxiosError) { + const code = error.response?.data?.code; + if (typeof code === 'number') { + return code; + } + } + return null; +} + +export class HttpNetworkError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class NotModified {} + +export interface BlobWithEtag { + data: Blob; + etag: string; +} + +export function extractResponseData(res: AxiosResponse): T { + return res.data; +} + +export function catchIfStatusCodeIs< + TResult, + TErrorHandlerResult extends TResult | PromiseLike | null | undefined +>( + statusCode: number, + errorHandler: (error: AxiosError) => TErrorHandlerResult +): (error: AxiosError) => TErrorHandlerResult { + return (error: AxiosError) => { + if (extractStatusCode(error) == statusCode) { + return errorHandler(error); + } else { + throw error; + } + }; +} + +export function convertToIfStatusCodeIs( + statusCode: number, + newErrorType: { + new (innerError: AxiosError): NewError; + } +): (error: AxiosError) => never { + return catchIfStatusCodeIs(statusCode, (error) => { + throw new newErrorType(error); + }); +} + +export function catchIfErrorCodeIs< + TResult, + TErrorHandlerResult extends TResult | PromiseLike | null | undefined +>( + errorCode: number, + errorHandler: (error: AxiosError) => TErrorHandlerResult +): (error: AxiosError) => TErrorHandlerResult { + return (error: AxiosError) => { + if (extractErrorCode(error) == errorCode) { + return errorHandler(error); + } else { + throw error; + } + }; +} +export function convertToIfErrorCodeIs( + errorCode: number, + newErrorType: { + new (innerError: AxiosError): NewError; + } +): (error: AxiosError) => never { + return catchIfErrorCodeIs(errorCode, (error) => { + throw new newErrorType(error); + }); +} + +export function convertToNetworkError( + error: AxiosError +): never { + if (error.isAxiosError && error.response == null) { + throw new HttpNetworkError(error); + } else { + throw error; + } +} + +export function convertToBlobWithEtag(res: AxiosResponse): BlobWithEtag { + return { + data: res.data, + etag: (res.headers as Record<'etag', string>)['etag'], + }; +} + +export function convertToBlobWithEtagOrNotModified( + res: AxiosResponse +): BlobWithEtag | NotModified { + if (res.status === 304) { + return new NotModified(); + } else { + return { + data: res.data, + etag: (res.headers as Record<'etag', string>)['etag'], + }; + } +} diff --git a/Timeline/ClientApp/src/app/http/mock/common.ts b/Timeline/ClientApp/src/app/http/mock/common.ts new file mode 100644 index 00000000..11939c2b --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/common.ts @@ -0,0 +1,48 @@ +import localforage from 'localforage'; +import { SHA1 } from 'crypto-js'; + +import { HttpNetworkError } from '../common'; + +export const mockStorage = localforage.createInstance({ + name: 'mock-backend', + description: 'Database for mock back end.', + driver: localforage.INDEXEDDB, +}); + +export async function sha1(data: Blob): Promise { + const s = await new Promise((resolve) => { + const fileReader = new FileReader(); + fileReader.readAsBinaryString(data); + fileReader.onload = () => { + resolve(fileReader.result as string); + }; + }); + + return SHA1(s).toString(); +} + +const disableNetworkKey = 'mockServer.disableNetwork'; + +let disableNetwork: boolean = + localStorage.getItem(disableNetworkKey) === 'true' ? true : false; + +Object.defineProperty(window, 'disableNetwork', { + get: () => disableNetwork, + set: (value) => { + if (value) { + disableNetwork = true; + localStorage.setItem(disableNetworkKey, 'true'); + } else { + disableNetwork = false; + localStorage.setItem(disableNetworkKey, 'false'); + } + }, +}); + +export async function mockPrepare(): Promise { + if (disableNetwork) { + console.warn('Network is disabled for mock server.'); + throw new HttpNetworkError(); + } + await Promise.resolve(); +} diff --git a/Timeline/ClientApp/src/app/http/mock/default-avatar.png b/Timeline/ClientApp/src/app/http/mock/default-avatar.png new file mode 100644 index 00000000..4086e1d2 Binary files /dev/null and b/Timeline/ClientApp/src/app/http/mock/default-avatar.png differ diff --git a/Timeline/ClientApp/src/app/http/mock/install.ts b/Timeline/ClientApp/src/app/http/mock/install.ts new file mode 100644 index 00000000..66174d41 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/install.ts @@ -0,0 +1,11 @@ +import { setHttpTokenClient } from '../token'; +import { setHttpUserClient } from '../user'; +import { setHttpTimelineClient } from '../timeline'; + +import { MockHttpTokenClient } from './token'; +import { MockHttpUserClient } from './user'; +import { MockHttpTimelineClient } from './timeline'; + +setHttpTokenClient(new MockHttpTokenClient()); +setHttpUserClient(new MockHttpUserClient()); +setHttpTimelineClient(new MockHttpTimelineClient()); diff --git a/Timeline/ClientApp/src/app/http/mock/timeline.ts b/Timeline/ClientApp/src/app/http/mock/timeline.ts new file mode 100644 index 00000000..2a34ef10 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/timeline.ts @@ -0,0 +1,600 @@ +import { random, without, range } from 'lodash'; + +import { BlobWithEtag, NotModified } from '../common'; +import { + IHttpTimelineClient, + HttpTimelineInfo, + TimelineVisibility, + HttpTimelineListQuery, + HttpTimelineNotExistError, + HttpTimelinePostRequest, + HttpTimelineNameConflictError, + HttpTimelinePatchRequest, + HttpTimelinePostInfo, + HttpTimelinePostContent, + HttpTimelinePostPostRequest, + HttpTimelinePostNotExistError, + HttpTimelineGenericPostInfo, +} from '../timeline'; +import { HttpUser } from '../user'; + +import { mockStorage, sha1, mockPrepare } from './common'; +import { getUser, MockUserNotExistError, checkToken } from './user'; + +async function getTimelineNameList(): Promise { + return (await mockStorage.getItem('timelines')) ?? []; +} + +async function setTimelineNameList(newOne: string[]): Promise { + await mockStorage.setItem('timelines', newOne); +} + +type TimelinePropertyKey = + | 'uniqueId' + | 'owner' + | 'description' + | 'visibility' + | 'members' + | 'currentPostId'; + +function getTimelinePropertyKey( + name: string, + property: TimelinePropertyKey +): string { + return `timeline.${name}.${property}`; +} + +function getTimelinePropertyValue( + name: string, + property: TimelinePropertyKey +): Promise { + return mockStorage.getItem(getTimelinePropertyKey(name, property)); +} + +function setTimelinePropertyValue( + name: string, + property: TimelinePropertyKey, + value: T +): Promise { + return mockStorage + .setItem(getTimelinePropertyKey(name, property), value) + .then(); +} + +interface HttpTimelineInfoEx extends HttpTimelineInfo { + memberUsernames: string[]; +} + +function createUniqueId(): string { + const s = 'abcdefghijklmnopqrstuvwxz0123456789'; + let result = ''; + for (let i = 0; i < 16; i++) { + result += s[random(0, s.length - 1)]; + } + return result; +} + +class MockTimelineNotExistError extends Error { + constructor() { + super('Timeline not exist.'); + } +} + +class MockTimelineAlreadyExistError extends Error { + constructor() { + super('Timeline already exist.'); + } +} + +async function getTimelineInfo(name: string): Promise { + let owner: HttpUser; + if (name.startsWith('@')) { + const ownerUsername = name.substr(1); + owner = await getUser(ownerUsername); + const optionalUniqueId = await getTimelinePropertyValue( + name, + 'uniqueId' + ); + if (optionalUniqueId == null) { + await setTimelineNameList([...(await getTimelineNameList()), name]); + await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); + } + } else { + const optionalOwnerUsername = await getTimelinePropertyValue( + name, + 'owner' + ); + if (optionalOwnerUsername == null) { + throw new MockTimelineNotExistError(); + } else { + owner = await getUser(optionalOwnerUsername); + } + } + + const memberUsernames = + (await getTimelinePropertyValue(name, 'members')) ?? []; + const members = await Promise.all( + memberUsernames.map(async (username) => { + return await getUser(username); + }) + ); + + return { + name, + uniqueId: await getTimelinePropertyValue(name, 'uniqueId'), + owner, + description: + (await getTimelinePropertyValue(name, 'description')) ?? + '', + visibility: + (await getTimelinePropertyValue( + name, + 'visibility' + )) ?? 'Register', + members, + memberUsernames, + }; +} + +async function createTimeline(name: string, owner: string): Promise { + const optionalOwnerUsername = await getTimelinePropertyValue( + name, + 'owner' + ); + if (optionalOwnerUsername != null) { + throw new MockTimelineAlreadyExistError(); + } + + await setTimelineNameList([...(await getTimelineNameList()), name]); + await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); + await setTimelinePropertyValue(name, 'owner', owner); +} + +type TimelinePostPropertyKey = + | 'type' + | 'data' + | 'etag' + | 'author' + | 'time' + | 'lastUpdated'; + +function getTimelinePostPropertyKey( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): string { + return `timeline.${timelineName}.posts.${id}.${propertyKey}`; +} + +function getTimelinePostPropertyValue( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): Promise { + return mockStorage.getItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey) + ); +} + +function setTimelinePostPropertyValue( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey, + value: T +): Promise { + return mockStorage.setItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey), + value + ); +} + +function removeTimelinePostProperty( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): Promise { + return mockStorage.removeItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey) + ); +} + +async function getTimelinePostInfo( + timelineName: string, + id: number +): Promise { + const currentPostId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + if (currentPostId == null || id > currentPostId) { + throw new HttpTimelinePostNotExistError(); + } + + const type = await getTimelinePostPropertyValue( + timelineName, + id, + 'type' + ); + + if (type == null) { + return { + id, + author: await getUser( + await getTimelinePostPropertyValue(timelineName, id, 'author') + ), + time: new Date( + await getTimelinePostPropertyValue(timelineName, id, 'time') + ), + lastUpdated: new Date( + await getTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated' + ) + ), + deleted: true, + }; + } else { + let content: HttpTimelinePostContent; + if (type === 'text') { + content = { + type: 'text', + text: await getTimelinePostPropertyValue(timelineName, id, 'data'), + }; + } else { + content = { + type: 'image', + }; + } + + return { + id, + author: await getUser( + await getTimelinePostPropertyValue(timelineName, id, 'author') + ), + time: new Date( + await getTimelinePostPropertyValue(timelineName, id, 'time') + ), + lastUpdated: new Date( + await getTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated' + ) + ), + content, + deleted: false, + }; + } +} + +export class MockHttpTimelineClient implements IHttpTimelineClient { + async listTimeline( + query: HttpTimelineListQuery + ): Promise { + await mockPrepare(); + return ( + await Promise.all( + (await getTimelineNameList()).map((name) => getTimelineInfo(name)) + ) + ).filter((timeline) => { + if ( + query.visibility != null && + query.visibility !== timeline.visibility + ) { + return false; + } + if (query.relate != null) { + if (query.relateType === 'own') { + if (timeline.owner.username !== query.relate) { + return false; + } + } else if (query.relateType === 'join') { + if (!timeline.memberUsernames.includes(query.relate)) { + return false; + } + } else if ( + timeline.owner.username !== query.relate && + !timeline.memberUsernames.includes(query.relate) + ) { + return false; + } + } + return true; + }); + } + + async getTimeline(timelineName: string): Promise { + await mockPrepare(); + try { + return await getTimelineInfo(timelineName); + } catch (e) { + if ( + e instanceof MockTimelineNotExistError || + e instanceof MockUserNotExistError + ) { + throw new HttpTimelineNotExistError(); + } + throw e; + } + } + + async postTimeline( + req: HttpTimelinePostRequest, + token: string + ): Promise { + await mockPrepare(); + const user = checkToken(token); + try { + await createTimeline(req.name, user); + } catch (e) { + if (e instanceof MockTimelineAlreadyExistError) { + throw new HttpTimelineNameConflictError(); + } + throw e; + } + return await getTimelineInfo(req.name); + } + + async patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest, + _token: string + ): Promise { + await mockPrepare(); + if (req.description != null) { + await setTimelinePropertyValue( + timelineName, + 'description', + req.description + ); + } + if (req.visibility != null) { + await setTimelinePropertyValue( + timelineName, + 'visibility', + req.visibility + ); + } + return await getTimelineInfo(timelineName); + } + + async deleteTimeline(timelineName: string, _token: string): Promise { + await mockPrepare(); + await setTimelineNameList( + without(await getTimelineNameList(), timelineName) + ); + await mockStorage.removeItem( + getTimelinePropertyKey(timelineName, 'uniqueId') + ); + + // TODO: remove other things + } + + async memberPut( + timelineName: string, + username: string, + _token: string + ): Promise { + await mockPrepare(); + const oldMembers = + (await getTimelinePropertyValue( + timelineName, + 'members' + )) ?? []; + if (!oldMembers.includes(username)) { + await setTimelinePropertyValue(timelineName, 'members', [ + ...oldMembers, + username, + ]); + } + } + + async memberDelete( + timelineName: string, + username: string, + _token: string + ): Promise { + await mockPrepare(); + const oldMembers = + (await getTimelinePropertyValue( + timelineName, + 'members' + )) ?? []; + if (oldMembers.includes(username)) { + await setTimelinePropertyValue( + timelineName, + 'members', + without(oldMembers, username) + ); + } + } + + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + async listPost( + timelineName: string, + _token?: string, + query?: { + modifiedSince?: Date; + includeDeleted?: boolean; + } + ): Promise { + await mockPrepare(); + // TODO: Permission check. + + const currentPostId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + + return ( + await Promise.all( + range(1, currentPostId == null ? 1 : currentPostId + 1).map( + async (id) => { + return await getTimelinePostInfo(timelineName, id); + } + ) + ) + ) + .filter((post) => { + if (query?.includeDeleted !== true && post.deleted) { + return false; + } + return true; + }) + .filter((post) => { + if (query?.modifiedSince != null) { + return post.lastUpdated >= query.modifiedSince; + } + return true; + }); + } + + getPostData( + timelineName: string, + postId: number, + token: string + ): Promise; + async getPostData( + timelineName: string, + postId: number, + _token?: string, + etag?: string + ): Promise { + await mockPrepare(); + // TODO: Permission check. + + const optionalSavedEtag = await getTimelinePostPropertyValue( + timelineName, + postId, + 'etag' + ); + + if (optionalSavedEtag == null) { + const optionalType = await getTimelinePostPropertyValue( + timelineName, + postId, + 'type' + ); + + if (optionalType != null) { + throw new Error('Post of this type has no data.'); + } else { + throw new HttpTimelinePostNotExistError(); + } + } + + if (etag === optionalSavedEtag) { + return new NotModified(); + } + + return { + data: await getTimelinePostPropertyValue( + timelineName, + postId, + 'data' + ), + etag: optionalSavedEtag, + }; + } + + async postPost( + timelineName: string, + req: HttpTimelinePostPostRequest, + token: string + ): Promise { + await mockPrepare(); + const user = checkToken(token); + + const savedId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + const id = savedId ? savedId + 1 : 1; + await setTimelinePropertyValue(timelineName, 'currentPostId', id); + + await setTimelinePostPropertyValue(timelineName, id, 'author', user); + + const currentTimeString = new Date().toISOString(); + await setTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated', + currentTimeString + ); + + await setTimelinePostPropertyValue( + timelineName, + id, + 'time', + req.time != null ? req.time.toISOString() : currentTimeString + ); + + const { content } = req; + if (content.type === 'text') { + await setTimelinePostPropertyValue(timelineName, id, 'type', 'text'); + await setTimelinePostPropertyValue( + timelineName, + id, + 'data', + content.text + ); + } else { + await setTimelinePostPropertyValue(timelineName, id, 'type', 'image'); + await setTimelinePostPropertyValue( + timelineName, + id, + 'data', + content.data + ); + await setTimelinePostPropertyValue( + timelineName, + id, + 'etag', + await sha1(content.data) + ); + } + + return (await getTimelinePostInfo( + timelineName, + id + )) as HttpTimelinePostInfo; + } + + async deletePost( + timelineName: string, + postId: number, + _token: string + ): Promise { + await mockPrepare(); + // TODO: permission check + await removeTimelinePostProperty(timelineName, postId, 'type'); + await removeTimelinePostProperty(timelineName, postId, 'data'); + await removeTimelinePostProperty(timelineName, postId, 'etag'); + await setTimelinePostPropertyValue( + timelineName, + postId, + 'lastUpdated', + new Date().toISOString() + ); + } +} diff --git a/Timeline/ClientApp/src/app/http/mock/token.ts b/Timeline/ClientApp/src/app/http/mock/token.ts new file mode 100644 index 00000000..6924e7d7 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/token.ts @@ -0,0 +1,53 @@ +import { AxiosError } from 'axios'; + +import { + IHttpTokenClient, + HttpCreateTokenRequest, + HttpCreateTokenResponse, + HttpVerifyTokenRequest, + HttpVerifyTokenResponse, +} from '../token'; + +import { mockPrepare } from './common'; +import { getUser, MockUserNotExistError, checkToken } from './user'; + +export class MockHttpTokenClient implements IHttpTokenClient { + // TODO: Mock bad credentials error. + async create(req: HttpCreateTokenRequest): Promise { + await mockPrepare(); + try { + const user = await getUser(req.username); + return { + user, + token: `token-${req.username}`, + }; + } catch (e) { + if (e instanceof MockUserNotExistError) { + throw { + isAxiosError: true, + response: { + status: 400, + }, + } as Partial; + } + throw e; + } + } + + async verify(req: HttpVerifyTokenRequest): Promise { + await mockPrepare(); + try { + const user = await getUser(checkToken(req.token)); + return { + user, + }; + } catch (e) { + throw { + isAxiosError: true, + response: { + status: 400, + }, + } as Partial; + } + } +} diff --git a/Timeline/ClientApp/src/app/http/mock/user.ts b/Timeline/ClientApp/src/app/http/mock/user.ts new file mode 100644 index 00000000..d16302d4 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/user.ts @@ -0,0 +1,132 @@ +import axios from 'axios'; + +import { BlobWithEtag, NotModified } from '../common'; +import { + IHttpUserClient, + HttpUser, + HttpUserNotExistError, + HttpUserPatchRequest, + HttpChangePasswordRequest, +} from '../user'; + +import { mockStorage, sha1, mockPrepare } from './common'; + +import defaultAvatarUrl from './default-avatar.png'; + +let _defaultAvatar: BlobWithEtag | undefined = undefined; + +async function getDefaultAvatar(): Promise { + if (_defaultAvatar == null) { + const blob = ( + await axios.get(defaultAvatarUrl, { + responseType: 'blob', + }) + ).data; + const etag = await sha1(blob); + _defaultAvatar = { + data: blob, + etag, + }; + } + return _defaultAvatar; +} + +export class MockTokenError extends Error { + constructor() { + super('Token bad format.'); + } +} + +export class MockUserNotExistError extends Error { + constructor() { + super('Only two user "user" and "admin".'); + } +} + +export function checkUsername(username: string): void { + if (!['user', 'admin'].includes(username)) throw new MockUserNotExistError(); +} + +export function checkToken(token: string): string { + if (!token.startsWith('token-')) { + throw new MockTokenError(); + } + return token.substr(6); +} + +export async function getUser( + username: 'user' | 'admin' | string +): Promise { + checkUsername(username); + const savedNickname = await mockStorage.getItem( + `user.${username}.nickname` + ); + return { + username: username, + nickname: + savedNickname == null || savedNickname === '' ? username : savedNickname, + administrator: username === 'admin', + }; +} + +export class MockHttpUserClient implements IHttpUserClient { + async get(username: string): Promise { + await mockPrepare(); + return await getUser(username).catch((e) => { + if (e instanceof MockUserNotExistError) { + throw new HttpUserNotExistError(); + } else { + throw e; + } + }); + } + + async patch( + username: string, + req: HttpUserPatchRequest, + _token: string + ): Promise { + await mockPrepare(); + if (req.nickname != null) { + await mockStorage.setItem(`user.${username}.nickname`, req.nickname); + } + return await getUser(username); + } + + getAvatar(username: string): Promise; + async getAvatar( + username: string, + etag?: string + ): Promise { + await mockPrepare(); + + const savedEtag = await mockStorage.getItem(`user.${username}.avatar.etag`); + if (savedEtag == null) { + return await getDefaultAvatar(); + } + + if (savedEtag === etag) { + return new NotModified(); + } + + return { + data: await mockStorage.getItem(`user.${username}.avatar.data`), + etag: await mockStorage.getItem(`user.${username}.avatar.etag`), + }; + } + + async putAvatar(username: string, data: Blob, _token: string): Promise { + await mockPrepare(); + const etag = await sha1(data); + await mockStorage.setItem(`user.${username}.avatar.data`, data); + await mockStorage.setItem(`user.${username}.avatar.etag`, etag); + } + + async changePassword( + _req: HttpChangePasswordRequest, + _token: string + ): Promise { + await mockPrepare(); + throw new Error('Not Implemented.'); + } +} diff --git a/Timeline/ClientApp/src/app/http/timeline.ts b/Timeline/ClientApp/src/app/http/timeline.ts new file mode 100644 index 00000000..458ea6e6 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/timeline.ts @@ -0,0 +1,476 @@ +import axios, { AxiosError } from 'axios'; + +import { updateQueryString, applyQueryParameters } from '../utilities/url'; +import { + apiBaseUrl, + extractResponseData, + convertToNetworkError, + base64, + convertToIfStatusCodeIs, + convertToIfErrorCodeIs, + BlobWithEtag, + NotModified, + convertToBlobWithEtagOrNotModified, +} from './common'; +import { HttpUser } from './user'; + +export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const; + +export type TimelineVisibility = typeof kTimelineVisibilities[number]; + +export interface HttpTimelineInfo { + uniqueId: string; + name: string; + description: string; + owner: HttpUser; + visibility: TimelineVisibility; + members: HttpUser[]; +} + +export interface HttpTimelineListQuery { + visibility?: TimelineVisibility; + relate?: string; + relateType?: 'own' | 'join'; +} + +export interface HttpTimelinePostRequest { + name: string; +} + +export interface HttpTimelinePostTextContent { + type: 'text'; + text: string; +} + +export interface HttpTimelinePostImageContent { + type: 'image'; +} + +export type HttpTimelinePostContent = + | HttpTimelinePostTextContent + | HttpTimelinePostImageContent; + +export interface HttpTimelinePostInfo { + id: number; + content: HttpTimelinePostContent; + time: Date; + lastUpdated: Date; + author: HttpUser; + deleted: false; +} + +export interface HttpTimelineDeletedPostInfo { + id: number; + time: Date; + lastUpdated: Date; + author: HttpUser; + deleted: true; +} + +export type HttpTimelineGenericPostInfo = + | HttpTimelinePostInfo + | HttpTimelineDeletedPostInfo; + +export interface HttpTimelinePostPostRequestTextContent { + type: 'text'; + text: string; +} + +export interface HttpTimelinePostPostRequestImageContent { + type: 'image'; + data: Blob; +} + +export type HttpTimelinePostPostRequestContent = + | HttpTimelinePostPostRequestTextContent + | HttpTimelinePostPostRequestImageContent; + +export interface HttpTimelinePostPostRequest { + content: HttpTimelinePostPostRequestContent; + time?: Date; +} + +export interface HttpTimelinePatchRequest { + visibility?: TimelineVisibility; + description?: string; +} + +export class HttpTimelineNotExistError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class HttpTimelinePostNotExistError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class HttpTimelineNameConflictError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +//-------------------- begin: internal model -------------------- + +interface RawTimelinePostTextContent { + type: 'text'; + text: string; +} + +interface RawTimelinePostImageContent { + type: 'image'; + url: string; +} + +type RawTimelinePostContent = + | RawTimelinePostTextContent + | RawTimelinePostImageContent; + +interface RawTimelinePostInfo { + id: number; + content: HttpTimelinePostContent; + time: string; + lastUpdated: string; + author: HttpUser; + deleted: false; +} + +interface RawTimelineDeletedPostInfo { + id: number; + time: string; + lastUpdated: string; + author: HttpUser; + deleted: true; +} + +type RawTimelineGenericPostInfo = + | RawTimelinePostInfo + | RawTimelineDeletedPostInfo; + +interface RawTimelinePostPostRequestTextContent { + type: 'text'; + text: string; +} + +interface RawTimelinePostPostRequestImageContent { + type: 'image'; + data: string; +} + +type RawTimelinePostPostRequestContent = + | RawTimelinePostPostRequestTextContent + | RawTimelinePostPostRequestImageContent; + +interface RawTimelinePostPostRequest { + content: RawTimelinePostPostRequestContent; + time?: string; +} + +//-------------------- end: internal model -------------------- + +function processRawTimelinePostInfo( + raw: RawTimelinePostInfo +): HttpTimelinePostInfo; +function processRawTimelinePostInfo( + raw: RawTimelineGenericPostInfo +): HttpTimelineGenericPostInfo; +function processRawTimelinePostInfo( + raw: RawTimelineGenericPostInfo +): HttpTimelineGenericPostInfo { + return { + ...raw, + time: new Date(raw.time), + lastUpdated: new Date(raw.lastUpdated), + }; +} + +export interface IHttpTimelineClient { + listTimeline(query: HttpTimelineListQuery): Promise; + getTimeline(timelineName: string): Promise; + postTimeline( + req: HttpTimelinePostRequest, + token: string + ): Promise; + patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest, + token: string + ): Promise; + deleteTimeline(timelineName: string, token: string): Promise; + memberPut( + timelineName: string, + username: string, + token: string + ): Promise; + memberDelete( + timelineName: string, + username: string, + token: string + ): Promise; + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + getPostData( + timelineName: string, + postId: number, + token?: string + ): Promise; + getPostData( + timelineName: string, + postId: number, + token: string | undefined, + etag: string + ): Promise; + postPost( + timelineName: string, + req: HttpTimelinePostPostRequest, + token: string + ): Promise; + deletePost( + timelineName: string, + postId: number, + token: string + ): Promise; +} + +export class HttpTimelineClient implements IHttpTimelineClient { + listTimeline(query: HttpTimelineListQuery): Promise { + return axios + .get( + applyQueryParameters(`${apiBaseUrl}/timelines`, query) + ) + .then(extractResponseData) + .catch(convertToNetworkError); + } + + getTimeline(timelineName: string): Promise { + return axios + .get(`${apiBaseUrl}/timelines/${timelineName}`) + .then(extractResponseData) + .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError)) + .catch(convertToNetworkError); + } + + postTimeline( + req: HttpTimelinePostRequest, + token: string + ): Promise { + return axios + .post(`${apiBaseUrl}/timelines?token=${token}`, req) + .then(extractResponseData) + .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)) + .catch(convertToNetworkError); + } + + patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest, + token: string + ): Promise { + return axios + .patch( + `${apiBaseUrl}/timelines/${timelineName}?token=${token}`, + req + ) + .then(extractResponseData) + .catch(convertToNetworkError); + } + + deleteTimeline(timelineName: string, token: string): Promise { + return axios + .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + + memberPut( + timelineName: string, + username: string, + token: string + ): Promise { + return axios + .put( + `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } + + memberDelete( + timelineName: string, + username: string, + token: string + ): Promise { + return axios + .delete( + `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } + + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + listPost( + timelineName: string, + token?: string, + query?: { + modifiedSince?: Date; + includeDeleted?: boolean; + } + ): Promise { + let url = `${apiBaseUrl}/timelines/${timelineName}/posts`; + url = updateQueryString('token', token, url); + if (query != null) { + if (query.modifiedSince != null) { + url = updateQueryString( + 'modifiedSince', + query.modifiedSince.toISOString(), + url + ); + } + if (query.includeDeleted != null) { + url = updateQueryString( + 'includeDeleted', + query.includeDeleted ? 'true' : 'false', + url + ); + } + } + + return axios + .get(url) + .then(extractResponseData) + .catch(convertToNetworkError) + .then((rawPosts) => + rawPosts.map((raw) => processRawTimelinePostInfo(raw)) + ); + } + + getPostData( + timelineName: string, + postId: number, + token: string + ): Promise; + getPostData( + timelineName: string, + postId: number, + token?: string, + etag?: string + ): Promise { + const headers = + etag != null + ? { + 'If-None-Match': etag, + } + : undefined; + + let url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`; + url = updateQueryString('token', token, url); + + return axios + .get(url, { + responseType: 'blob', + headers, + }) + .then(convertToBlobWithEtagOrNotModified) + .catch(convertToIfStatusCodeIs(404, HttpTimelinePostNotExistError)) + .catch(convertToNetworkError); + } + + async postPost( + timelineName: string, + req: HttpTimelinePostPostRequest, + token: string + ): Promise { + let content: RawTimelinePostPostRequestContent; + if (req.content.type === 'image') { + const base64Data = await base64(req.content.data); + content = { + ...req.content, + data: base64Data, + } as RawTimelinePostPostRequestImageContent; + } else { + content = req.content; + } + const rawReq: RawTimelinePostPostRequest = { + content, + }; + if (req.time != null) { + rawReq.time = req.time.toISOString(); + } + return await axios + .post( + `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`, + rawReq + ) + .then(extractResponseData) + .catch(convertToNetworkError) + .then((rawPost) => processRawTimelinePostInfo(rawPost)); + } + + deletePost( + timelineName: string, + postId: number, + token: string + ): Promise { + return axios + .delete( + `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } +} + +let client: IHttpTimelineClient = new HttpTimelineClient(); + +export function getHttpTimelineClient(): IHttpTimelineClient { + return client; +} + +export function setHttpTimelineClient( + newClient: IHttpTimelineClient +): IHttpTimelineClient { + const old = client; + client = newClient; + return old; +} diff --git a/Timeline/ClientApp/src/app/http/token.ts b/Timeline/ClientApp/src/app/http/token.ts new file mode 100644 index 00000000..26f7891f --- /dev/null +++ b/Timeline/ClientApp/src/app/http/token.ts @@ -0,0 +1,72 @@ +import axios, { AxiosError } from 'axios'; + +import { + apiBaseUrl, + convertToNetworkError, + convertToIfErrorCodeIs, + extractResponseData, +} from './common'; +import { HttpUser } from './user'; + +export interface HttpCreateTokenRequest { + username: string; + password: string; + expire: number; +} + +export interface HttpCreateTokenResponse { + token: string; + user: HttpUser; +} + +export interface HttpVerifyTokenRequest { + token: string; +} + +export interface HttpVerifyTokenResponse { + user: HttpUser; +} + +export class HttpCreateTokenBadCredentialError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export interface IHttpTokenClient { + create(req: HttpCreateTokenRequest): Promise; + verify(req: HttpVerifyTokenRequest): Promise; +} + +export class HttpTokenClient implements IHttpTokenClient { + create(req: HttpCreateTokenRequest): Promise { + return axios + .post(`${apiBaseUrl}/token/create`, req) + .then(extractResponseData) + .catch( + convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError) + ) + .catch(convertToNetworkError); + } + + verify(req: HttpVerifyTokenRequest): Promise { + return axios + .post(`${apiBaseUrl}/token/verify`, req) + .then(extractResponseData) + .catch(convertToNetworkError); + } +} + +let client: IHttpTokenClient = new HttpTokenClient(); + +export function getHttpTokenClient(): IHttpTokenClient { + return client; +} + +export function setHttpTokenClient( + newClient: IHttpTokenClient +): IHttpTokenClient { + const old = client; + client = newClient; + return old; +} diff --git a/Timeline/ClientApp/src/app/http/user.ts b/Timeline/ClientApp/src/app/http/user.ts new file mode 100644 index 00000000..345a5e8b --- /dev/null +++ b/Timeline/ClientApp/src/app/http/user.ts @@ -0,0 +1,131 @@ +import axios, { AxiosError } from 'axios'; + +import { + apiBaseUrl, + convertToNetworkError, + extractResponseData, + convertToIfStatusCodeIs, + convertToIfErrorCodeIs, + NotModified, + BlobWithEtag, + convertToBlobWithEtagOrNotModified, +} from './common'; + +export interface HttpUser { + username: string; + administrator: boolean; + nickname: string; +} + +export interface HttpUserPatchRequest { + nickname?: string; +} + +export interface HttpChangePasswordRequest { + oldPassword: string; + newPassword: string; +} + +export class HttpUserNotExistError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class HttpChangePasswordBadCredentialError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export interface IHttpUserClient { + get(username: string): Promise; + patch( + username: string, + req: HttpUserPatchRequest, + token: string + ): Promise; + getAvatar(username: string): Promise; + getAvatar( + username: string, + etag: string + ): Promise; + putAvatar(username: string, data: Blob, token: string): Promise; + changePassword(req: HttpChangePasswordRequest, token: string): Promise; +} + +export class HttpUserClient implements IHttpUserClient { + get(username: string): Promise { + return axios + .get(`${apiBaseUrl}/users/${username}`) + .then(extractResponseData) + .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)) + .catch(convertToNetworkError); + } + + patch( + username: string, + req: HttpUserPatchRequest, + token: string + ): Promise { + return axios + .patch(`${apiBaseUrl}/users/${username}?token=${token}`, req) + .then(extractResponseData) + .catch(convertToNetworkError); + } + + getAvatar(username: string): Promise; + getAvatar( + username: string, + etag?: string + ): Promise { + const headers = + etag != null + ? { + 'If-None-Match': etag, + } + : undefined; + + return axios + .get(`${apiBaseUrl}/users/${username}/avatar`, { + responseType: 'blob', + headers, + }) + .then(convertToBlobWithEtagOrNotModified) + .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)) + .catch(convertToNetworkError); + } + + putAvatar(username: string, data: Blob, token: string): Promise { + return axios + .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, { + headers: { + 'Content-Type': data.type, + }, + }) + .catch(convertToNetworkError) + .then(); + } + + changePassword(req: HttpChangePasswordRequest, token: string): Promise { + return axios + .post(`${apiBaseUrl}/userop/changepassword?token=${token}`, req) + .catch( + convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError) + ) + .catch(convertToNetworkError) + .then(); + } +} + +let client: IHttpUserClient = new HttpUserClient(); + +export function getHttpUserClient(): IHttpUserClient { + return client; +} + +export function setHttpUserClient(newClient: IHttpUserClient): IHttpUserClient { + const old = client; + client = newClient; + return old; +} diff --git a/Timeline/ClientApp/src/app/i18n.ts b/Timeline/ClientApp/src/app/i18n.ts new file mode 100644 index 00000000..aa293819 --- /dev/null +++ b/Timeline/ClientApp/src/app/i18n.ts @@ -0,0 +1,79 @@ +import i18n, { BackendModule, ResourceKey } from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; + +const backend: BackendModule = { + type: 'backend', + async read(language, namespace, callback) { + function error(message: string): void { + callback(new Error(message), false); + } + + function success(result: ResourceKey): void { + callback(null, result); + } + + if (namespace !== 'translation') { + error("Namespace must be 'translation'."); + } + + if (language === 'en') { + const res = ( + await import( + /* webpackChunkName: "locales-en" */ './locales/en/translation' + ) + ).default; + success(res); + } else if (language === 'zh-cn' || language === 'zh') { + const res = ( + await import( + /* webpackChunkName: "locales-zh" */ './locales/zh/translation' + ) + ).default; + success(res); + } else { + error(`Language ${language} is not supported.`); + } + }, + init() {}, // eslint-disable-line @typescript-eslint/no-empty-function + create() {}, // eslint-disable-line @typescript-eslint/no-empty-function +}; + +export const i18nPromise = i18n + .use(LanguageDetector) + .use(backend) + .use(initReactI18next) // bind react-i18next to the instance + .init({ + fallbackLng: false, + lowerCaseLng: true, + + debug: process.env.NODE_ENV === 'development', + + interpolation: { + escapeValue: false, // not needed for react!! + }, + + // react i18next special options (optional) + // override if needed - omit if ok with defaults + /* + react: { + bindI18n: 'languageChanged', + bindI18nStore: '', + transEmptyNodeValue: '', + transSupportBasicHtmlNodes: true, + transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'], + useSuspense: true, + } + */ + }); + +if (module.hot) { + module.hot.accept( + ['./locales/en/translation', './locales/zh/translation'], + () => { + void i18n.reloadResources(); + } + ); +} + +export default i18n; diff --git a/Timeline/ClientApp/src/app/index.sass b/Timeline/ClientApp/src/app/index.sass new file mode 100644 index 00000000..927a1f5a --- /dev/null +++ b/Timeline/ClientApp/src/app/index.sass @@ -0,0 +1,82 @@ +@import '~bootstrap/scss/bootstrap' + +@import './common/common' +@import './common/alert' +@import './home/home' +@import './about/about' +@import './timeline/timeline' +@import './timeline/timeline-ui' +@import './user/user-page' + +body + margin: 0 + +#app + display: flex + flex-direction: column + +small + line-height: 1.2 + +.width-1px + width: 1px + +.flex-fix-length + flex-grow: 0 + flex-shrink: 0 + +.position-lt + left: 0 + top: 0 + +.position-rb + right: 0 + bottom: 0 + +.app-bar + z-index: 1035 + +.avatar + width: 60px + +.avatar.large + width: 100px + +.avatar.small + width: 40px + +.mt-appbar + margin-top: 56px + +.icon-button + font-size: 1.4em + +.large-icon-button + font-size: 1.6em + +.cursor-pointer + cursor: pointer + +textarea + resize: none + +.white-space-no-wrap + white-space: nowrap + +.cru-card + @extend .shadow + @extend .border + @extend .border-primary + @extend .rounded + @extend .bg-light + +.full-viewport-center-child + position: fixed + width: 100vw + height: 100vh + display: flex + justify-content: center + align-items: center + +.text-orange + color: $orange diff --git a/Timeline/ClientApp/src/app/index.tsx b/Timeline/ClientApp/src/app/index.tsx new file mode 100644 index 00000000..70b28704 --- /dev/null +++ b/Timeline/ClientApp/src/app/index.tsx @@ -0,0 +1,15 @@ +import 'regenerator-runtime'; +import 'core-js/modules/es.promise'; +import 'core-js/modules/es.array.iterator'; +import 'pepjs'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import './index.sass'; + +import './i18n'; + +import App from './App'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/Timeline/ClientApp/src/app/locales/en/translation.ts b/Timeline/ClientApp/src/app/locales/en/translation.ts new file mode 100644 index 00000000..6abe910e --- /dev/null +++ b/Timeline/ClientApp/src/app/locales/en/translation.ts @@ -0,0 +1,194 @@ +import TranslationResource from '../scheme'; + +const translation: TranslationResource = { + welcome: 'Welcome!', + search: 'Search', + serviceWorker: { + availableOffline: + 'Timeline is now cached in your computer and you can use it offline. 🎉🎉🎉', + upgradePrompt: 'App is getting a new version!', + upgradeNow: 'Update Now', + upgradeSuccess: + 'Congratulations! App update succeeded! Still you can use it offline. 🎉🎉🎉', + externalActivatedPrompt: + 'A new version of app is activated. Please refresh the page. Or it may be broken.', + reloadNow: 'Refresh Now', + }, + nav: { + settings: 'Settings', + login: 'Login', + about: 'About', + }, + chooseImage: 'Choose a image', + loadImageError: 'Failed to load image.', + home: { + go: 'Go!', + allTimeline: 'All Timelines', + joinTimeline: 'Joined Timelines', + ownTimeline: 'Owned Timelines', + createButton: 'Create Timeline', + createDialog: { + title: 'Create Timeline!', + name: 'Name', + nameFormat: + 'Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.', + badFormat: 'Bad format.', + noEmpty: 'Empty is not allowed.', + tooLong: 'Too long.', + }, + }, + operationDialog: { + retry: 'Retry', + nextStep: 'Next', + previousStep: 'Previous', + confirm: 'Confirm', + cancel: 'Cancel', + ok: 'OK!', + processing: 'Processing...', + success: 'Success!', + error: 'An error occured.', + }, + timeline: { + messageCantSee: 'Sorry, you are not allowed to see this timeline.😅', + userNotExist: 'The user does not exist!', + timelineNotExist: 'The timeline does not exist!', + manage: 'Manage', + memberButton: 'Member', + send: 'Send', + deletePostFailed: 'Failed to delete post.', + sendPostFailed: 'Failed to send post.', + visibility: { + public: 'public to everyone', + register: 'only registed people can see', + private: 'only members can see', + }, + visibilityTooltip: { + public: + 'Everyone including those without accounts can see content of the timeline.', + register: + 'Only those who have an account and logined can see content of the timeline.', + private: 'Only members of this timeline can see content of the timeline.', + }, + dialogChangeProperty: { + title: 'Change Timeline Properties', + visibility: 'Visibility', + description: 'Description', + }, + member: { + alreadyMember: 'The user is already a member.', + add: 'Add', + remove: 'Remove', + }, + manageItem: { + nickname: 'Nickname', + avatar: 'Avatar', + property: 'Timeline Property', + member: 'Timeline Member', + delete: 'Delete Timeline', + }, + deleteDialog: { + title: 'Delete Timeline', + inputPrompt: + 'This is a dangerous action. If you are sure to delete timeline<1>{{name}}, please input its name below and click confirm button.', + notMatch: 'Name does not match.', + }, + post: { + deleteDialog: { + title: 'Confirm Delete', + prompt: + 'Are you sure to delete the post? This operation is not recoverable.', + }, + }, + }, + user: { + username: 'username', + password: 'password', + login: 'login', + rememberMe: 'Remember Me', + welcomeBack: 'Welcome back!', + verifyTokenFailed: 'User login info is expired. Please login again!', + verifyTokenFailedNetwork: + 'Verifying user login info failed. Please check your network and refresh page!', + }, + login: { + emptyUsername: "Username can't be empty.", + emptyPassword: "Password can't be empty.", + badCredential: 'Username or password is invalid.', + alreadyLogin: 'Already login! Redirect to home page in 3s!', + }, + userPage: { + dialogChangeNickname: { + title: 'Change Nickname', + inputLabel: 'New nickname', + }, + dialogChangeAvatar: { + title: 'Change Avatar', + previewImgAlt: 'preview', + prompt: { + select: 'Please select a picture.', + crop: 'Please crop the picture.', + processingCrop: 'Cropping picture...', + uploading: 'Uploading...', + preview: 'Please preview avatar', + }, + upload: 'upload', + }, + }, + settings: { + subheaders: { + account: 'Account', + customization: 'Customization', + }, + languagePrimary: 'Choose display language.', + languageSecondary: + 'You language preference will be saved locally. Next time you visit this page, last language option will be used.', + changePassword: "Change account's password.", + logout: 'Log out this account.', + gotoSelf: + 'Click here to go to timeline of myself to change nickname and avatar.', + dialogChangePassword: { + title: 'Change Password', + prompt: + 'You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.', + inputOldPassword: 'Old password', + inputNewPassword: 'New password', + inputRetypeNewPassword: 'Retype new password', + errorEmptyOldPassword: "Old password can't be empty.", + errorEmptyNewPassword: "New password can't be empty.", + errorRetypeNotMatch: 'Password retyped does not match.', + }, + dialogConfirmLogout: { + title: 'Confirm Logout', + prompt: + 'Are you sure to log out? All cached data in the browser will be deleted.', + }, + }, + about: { + author: { + title: 'Site Developer', + fullname: 'Fullname: ', + nickname: 'Nickname: ', + introduction: 'Introduction: ', + introductionContent: 'A programmer coding based on coincidence', + links: 'Links: ', + }, + site: { + title: 'Site Information', + content: + 'The name of this site is <1>Timeline, which is a Web App with <3>timeline as its core concept. Its frontend and backend are both developed by <5>me, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.', + repo: 'GitHub Repo', + }, + credits: { + title: 'Credits', + content: + 'Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.', + frontend: 'Frontend: ', + backend: 'Backend: ', + }, + }, + admin: { + title: 'admin', + }, +}; + +export default translation; diff --git a/Timeline/ClientApp/src/app/locales/scheme.ts b/Timeline/ClientApp/src/app/locales/scheme.ts new file mode 100644 index 00000000..19ac6c31 --- /dev/null +++ b/Timeline/ClientApp/src/app/locales/scheme.ts @@ -0,0 +1,175 @@ +export default interface TranslationResource { + welcome: string; + search: string; + chooseImage: string; + loadImageError: string; + serviceWorker: { + availableOffline: string; + upgradePrompt: string; + upgradeNow: string; + upgradeSuccess: string; + externalActivatedPrompt: string; + reloadNow: string; + }; + nav: { + settings: string; + login: string; + about: string; + }; + home: { + go: string; + allTimeline: string; + joinTimeline: string; + ownTimeline: string; + createButton: string; + createDialog: { + title: string; + name: string; + nameFormat: string; + badFormat: string; + noEmpty: string; + tooLong: string; + }; + }; + operationDialog: { + retry: string; + nextStep: string; + previousStep: string; + confirm: string; + cancel: string; + ok: string; + processing: string; + success: string; + error: string; + }; + timeline: { + messageCantSee: string; + userNotExist: string; + timelineNotExist: string; + manage: string; + memberButton: string; + send: string; + deletePostFailed: string; + sendPostFailed: string; + visibility: { + public: string; + register: string; + private: string; + }; + visibilityTooltip: { + public: string; + register: string; + private: string; + }; + dialogChangeProperty: { + title: string; + visibility: string; + description: string; + }; + member: { + alreadyMember: string; + add: string; + remove: string; + }; + manageItem: { + nickname: string; + avatar: string; + property: string; + member: string; + delete: string; + }; + deleteDialog: { + title: string; + inputPrompt: string; + notMatch: string; + }; + post: { + deleteDialog: { + title: string; + prompt: string; + }; + }; + }; + user: { + username: string; + password: string; + login: string; + rememberMe: string; + welcomeBack: string; + verifyTokenFailed: string; + verifyTokenFailedNetwork: string; + }; + login: { + emptyUsername: string; + emptyPassword: string; + badCredential: string; + alreadyLogin: string; + }; + userPage: { + dialogChangeNickname: { + title: string; + inputLabel: string; + }; + dialogChangeAvatar: { + title: string; + previewImgAlt: string; + prompt: { + select: string; + crop: string; + processingCrop: string; + preview: string; + uploading: string; + }; + upload: string; + }; + }; + settings: { + subheaders: { + account: string; + customization: string; + }; + languagePrimary: string; + languageSecondary: string; + changePassword: string; + logout: string; + gotoSelf: string; + dialogChangePassword: { + title: string; + prompt: string; + inputOldPassword: string; + inputNewPassword: string; + inputRetypeNewPassword: string; + errorEmptyOldPassword: string; + errorEmptyNewPassword: string; + errorRetypeNotMatch: string; + }; + dialogConfirmLogout: { + title: string; + prompt: string; + }; + }; + about: { + author: { + title: string; + fullname: string; + nickname: string; + introduction: string; + introductionContent: string; + links: string; + }; + site: { + title: string; + content: string; + repo: string; + }; + credits: { + title: string; + content: string; + frontend: string; + backend: string; + }; + }; + admin: { + title: string; + }; +} diff --git a/Timeline/ClientApp/src/app/locales/zh/translation.ts b/Timeline/ClientApp/src/app/locales/zh/translation.ts new file mode 100644 index 00000000..372979c0 --- /dev/null +++ b/Timeline/ClientApp/src/app/locales/zh/translation.ts @@ -0,0 +1,187 @@ +import TranslationResource from '../scheme'; + +const translation: TranslationResource = { + welcome: '欢迎!', + search: '搜索', + serviceWorker: { + availableOffline: 'Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉', + upgradePrompt: 'App 有新版本!', + upgradeNow: '现在升级', + upgradeSuccess: 'App 升级成功,当然,你仍可以离线使用它。 🎉🎉🎉', + externalActivatedPrompt: + '一个新的 App 版本已经激活,请刷新页面使用,否则页面可能会出现故障。', + reloadNow: '立刻刷新', + }, + nav: { + settings: '设置', + login: '登陆', + about: '关于', + }, + chooseImage: '选择一个图片', + loadImageError: '加载图片失败', + home: { + go: '冲!', + allTimeline: '所有的时间线', + joinTimeline: '加入的时间线', + ownTimeline: '拥有的时间线', + createButton: '创建时间线', + createDialog: { + title: '创建时间线!', + name: '名字', + nameFormat: + '名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.', + badFormat: '格式错误', + noEmpty: '不能为空', + tooLong: '太长了', + }, + }, + operationDialog: { + retry: '重试', + nextStep: '下一步', + previousStep: '上一步', + confirm: '确定', + cancel: '取消', + ok: '好的!', + processing: '处理中...', + success: '成功!', + error: '出错啦!', + }, + timeline: { + messageCantSee: '不好意思,你没有权限查看这个时间线。😅', + userNotExist: '该用户不存在!', + timelineNotExist: '该时间线不存在!', + manage: '管理', + memberButton: '成员', + send: '发送', + deletePostFailed: '删除消息失败。', + sendPostFailed: '发送消息失败。', + visibility: { + public: '对所有人公开', + register: '仅注册可见', + private: '仅成员可见', + }, + visibilityTooltip: { + public: '所有人都可以看到这个时间线的内容,包括没有注册的人。', + register: '只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。', + private: '只有这个时间线的成员可以看到这个时间线的内容。', + }, + dialogChangeProperty: { + title: '修改时间线属性', + visibility: '可见性', + description: '描述', + }, + member: { + alreadyMember: '该用户已经是一个成员。', + add: '添加', + remove: '移除', + }, + manageItem: { + nickname: '昵称', + avatar: '头像', + property: '时间线属性', + member: '时间线成员', + delete: '删除时间线', + }, + deleteDialog: { + title: '删除时间线', + inputPrompt: + '这是一个危险的操作。如果您确认要删除时间线<1>{{name}},请在下面输入它的名字并点击确认。', + notMatch: '名字不匹配', + }, + post: { + deleteDialog: { + title: '确认删除', + prompt: '确定删除这个消息?这个操作不可撤销。', + }, + }, + }, + user: { + username: '用户名', + password: '密码', + login: '登录', + rememberMe: '记住我', + welcomeBack: '欢迎回来!', + verifyTokenFailed: '用户登录信息已过期,请重新登陆!', + verifyTokenFailedNetwork: + '验证用户登录信息失败,请检查网络连接并刷新页面!', + }, + login: { + emptyUsername: '用户名不能为空。', + emptyPassword: '密码不能为空。', + badCredential: '用户名或密码错误。', + alreadyLogin: '已经登陆,三秒后导航到首页!', + }, + userPage: { + dialogChangeNickname: { + title: '更改昵称', + inputLabel: '新昵称', + }, + dialogChangeAvatar: { + title: '修改头像', + previewImgAlt: '预览', + prompt: { + select: '请选择一个图片', + crop: '请裁剪图片', + processingCrop: '正在裁剪图片', + uploading: '正在上传', + preview: '请预览图片', + }, + upload: '上传', + }, + }, + settings: { + subheaders: { + account: '账户', + customization: '个性化', + }, + languagePrimary: '选择显示的语言。', + languageSecondary: + '您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。', + changePassword: '更改账号的密码。', + logout: '注销此账号。', + gotoSelf: '点击前往个人时间线修改昵称和头像!', + dialogChangePassword: { + title: '修改密码', + prompt: + '您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。', + inputOldPassword: '旧密码', + inputNewPassword: '新密码', + inputRetypeNewPassword: '再次输入新密码', + errorEmptyOldPassword: '旧密码不能为空。', + errorEmptyNewPassword: '新密码不能为空', + errorRetypeNotMatch: '两次输入的密码不一致', + }, + dialogConfirmLogout: { + title: '确定注销', + prompt: '您确定注销此账号?这将删除所有已经缓存在浏览器的数据。', + }, + }, + about: { + author: { + title: '网站作者', + fullname: '姓名:', + nickname: '昵称:', + introduction: '简介:', + introductionContent: '一个基于巧合编程的代码爱好者。', + links: '链接:', + }, + site: { + title: '网站信息', + content: + '这个网站的名字叫 <1>Timeline,是一个以<3>时间线为核心概念的 Web App . 它的前端和后端都是由<5>我开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。', + repo: 'GitHub 仓库', + }, + credits: { + title: '鸣谢', + content: + 'Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。', + frontend: '前端:', + backend: '后端:', + }, + }, + admin: { + title: '管理', + }, +}; + +export default translation; diff --git a/Timeline/ClientApp/src/app/service-worker.tsx b/Timeline/ClientApp/src/app/service-worker.tsx new file mode 100644 index 00000000..a353c809 --- /dev/null +++ b/Timeline/ClientApp/src/app/service-worker.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Button } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +import { pushAlert } from './common/alert-service'; + +if ('serviceWorker' in navigator) { + let isThisTriggerUpgrade = false; + + const upgradeSuccessLocalStorageKey = 'TIMELINE_UPGRADE_SUCCESS'; + + if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) { + pushAlert({ + message: { + type: 'i18n', + key: 'serviceWorker.upgradeSuccess', + }, + type: 'success', + }); + window.localStorage.removeItem(upgradeSuccessLocalStorageKey); + } + + void import('workbox-window').then(({ Workbox, messageSW }) => { + const wb = new Workbox('/sw.js'); + let registration: ServiceWorkerRegistration | undefined; + + // externalactivated is not usable but I still use its name. + wb.addEventListener('controlling', () => { + const upgradeReload = (): void => { + window.localStorage.setItem(upgradeSuccessLocalStorageKey, 'true'); + window.location.reload(); + }; + + if (isThisTriggerUpgrade) { + upgradeReload(); + } else { + const Message: React.FC = () => { + const { t } = useTranslation(); + return ( + <> + {t('serviceWorker.externalActivatedPrompt')} + + + ); + }; + + pushAlert({ + message: Message, + dismissTime: 'never', + type: 'warning', + }); + } + }); + + wb.addEventListener('activated', (event) => { + if (!event.isUpdate) { + pushAlert({ + message: { + type: 'i18n', + key: 'serviceWorker.availableOffline', + }, + type: 'success', + }); + } + }); + + const showSkipWaitingPrompt = (): void => { + const upgrade = (): void => { + isThisTriggerUpgrade = true; + if (registration && registration.waiting) { + // Send a message to the waiting service worker, + // instructing it to activate. + // Note: for this to work, you have to add a message + // listener in your service worker. See below. + void messageSW(registration.waiting, { type: 'SKIP_WAITING' }); + } + }; + + const UpgradeMessage: React.FC = () => { + const { t } = useTranslation(); + return ( + <> + {t('serviceWorker.upgradePrompt')} + + + ); + }; + + pushAlert({ + message: UpgradeMessage, + dismissTime: 'never', + type: 'success', + }); + }; + + // Add an event listener to detect when the registered + // service worker has installed but is waiting to activate. + wb.addEventListener('waiting', showSkipWaitingPrompt); + wb.addEventListener('externalwaiting', showSkipWaitingPrompt); + + void wb.register().then((reg) => { + registration = reg; + }); + }); +} diff --git a/Timeline/ClientApp/src/app/settings/Settings.tsx b/Timeline/ClientApp/src/app/settings/Settings.tsx new file mode 100644 index 00000000..a247557d --- /dev/null +++ b/Timeline/ClientApp/src/app/settings/Settings.tsx @@ -0,0 +1,221 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + Container, + Row, + Col, + Input, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from 'reactstrap'; + +import { useUser, userService } from '../data/user'; + +import AppBar from '../common/AppBar'; +import OperationDialog, { + OperationInputErrorInfo, +} from '../common/OperationDialog'; + +interface ChangePasswordDialogProps { + open: boolean; + close: () => void; +} + +const ChangePasswordDialog: React.FC = (props) => { + const history = useHistory(); + const { t } = useTranslation(); + + const [redirect, setRedirect] = useState(false); + + return ( + + v === '' + ? 'settings.dialogChangePassword.errorEmptyOldPassword' + : null, + }, + { + type: 'text', + label: t('settings.dialogChangePassword.inputNewPassword'), + password: true, + validator: (v, values) => { + const error: OperationInputErrorInfo = {}; + error[1] = + v === '' + ? 'settings.dialogChangePassword.errorEmptyNewPassword' + : null; + if (v === values[2]) { + error[2] = null; + } else { + if (values[2] !== '') { + error[2] = 'settings.dialogChangePassword.errorRetypeNotMatch'; + } + } + return error; + }, + }, + { + type: 'text', + label: t('settings.dialogChangePassword.inputRetypeNewPassword'), + password: true, + validator: (v, values) => + v !== values[1] + ? 'settings.dialogChangePassword.errorRetypeNotMatch' + : null, + }, + ]} + onProcess={async ([oldPassword, newPassword]) => { + await userService + .changePassword(oldPassword as string, newPassword as string) + .toPromise(); + userService.logout(); + setRedirect(true); + }} + close={() => { + props.close(); + if (redirect) { + history.push('/login'); + } + }} + /> + ); +}; + +const ConfirmLogoutDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + + + {t('settings.dialogConfirmLogout.title')} + + {t('settings.dialogConfirmLogout.prompt')} + + + + + + ); +}; + +const Settings: React.FC = (_) => { + const { i18n, t } = useTranslation(); + const user = useUser(); + const history = useHistory(); + + const [dialog, setDialog] = useState( + null + ); + + const language = i18n.language.slice(0, 2); + + return ( + <> + + + {user ? ( + <> + + +
{ + history.push(`/users/${user.username}`); + }} + > + {t('settings.gotoSelf')} +
+ +
+ + +
setDialog('changepassword')} + > + {t('settings.changePassword')} +
+ +
+ + +
{ + setDialog('logout'); + }} + > + {t('settings.logout')} +
+ +
+ + ) : null} + + +
{t('settings.languagePrimary')}
+

{t('settings.languageSecondary')}

+ + + { + void i18n.changeLanguage(e.target.value); + }} + > + + + + +
+ {(() => { + switch (dialog) { + case 'changepassword': + return ( + { + setDialog(null); + }} + /> + ); + case 'logout': + return ( + setDialog(null)} + onConfirm={() => { + userService.logout(); + history.push('/'); + }} + /> + ); + default: + return null; + } + })()} +
+ + ); +}; + +export default Settings; diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx new file mode 100644 index 00000000..849933cf --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { TimelinePostInfo } from '../data/timeline'; + +import TimelineItem from './TimelineItem'; + +export interface TimelinePostInfoEx extends TimelinePostInfo { + deletable: boolean; +} + +export type TimelineDeleteCallback = (index: number, id: number) => void; + +export interface TimelineProps { + className?: string; + posts: TimelinePostInfoEx[]; + onDelete: TimelineDeleteCallback; + onResize?: () => void; + containerRef?: React.Ref; +} + +const Timeline: React.FC = (props) => { + const { posts, onDelete, onResize } = props; + + const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState< + number + >(-1); + + const onItemClick = React.useCallback(() => { + setIndexShowDeleteButton(-1); + }, []); + + const onToggleDelete = React.useMemo(() => { + return posts.map((post, i) => { + return post.deletable + ? () => { + setIndexShowDeleteButton((oldIndexShowDeleteButton) => { + return oldIndexShowDeleteButton !== i ? i : -1; + }); + } + : undefined; + }); + }, [posts]); + + const onItemDelete = React.useMemo(() => { + return posts.map((post, i) => { + return () => { + onDelete(i, post.id); + }; + }); + }, [posts, onDelete]); + + return ( +
+
+ {(() => { + const length = posts.length; + return posts.map((post, i) => { + const toggleMore = onToggleDelete[i]; + + return ( + + ); + }); + })()} +
+ ); +}; + +export default Timeline; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..7bcea6c5 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useHistory } from 'react-router'; +import { Trans } from 'react-i18next'; + +import OperationDialog from '../common/OperationDialog'; +import { timelineService } from '../data/timeline'; + +interface TimelineDeleteDialog { + open: boolean; + name: string; + close: () => void; +} + +const TimelineDeleteDialog: React.FC = (props) => { + const history = useHistory(); + + const { name } = props; + + return ( + { + return ( + + 0{{ name }}2 + + ); + }} + inputScheme={[ + { + type: 'text', + validator: (value) => { + if (value !== name) { + return 'timeline.deleteDialog.notMatch'; + } else { + return null; + } + }, + }, + ]} + onProcess={() => { + return timelineService.deleteTimeline(name).toPromise(); + }} + onSuccessAndClose={() => { + history.replace('/'); + }} + /> + ); +}; + +export default TimelineDeleteDialog; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..c25b2376 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import clsx from 'clsx'; +import { + Dropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Button, +} from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { fromEvent } from 'rxjs'; + +import { useAvatarUrl } from '../data/user'; +import { timelineVisibilityTooltipTranslationMap } from '../data/timeline'; + +import { TimelineCardComponentProps } from './TimelinePageTemplateUI'; + +export type OrdinaryTimelineManageItem = 'delete'; + +export type TimelineInfoCardProps = TimelineCardComponentProps< + OrdinaryTimelineManageItem +>; + +const TimelineInfoCard: React.FC = (props) => { + const { onHeight, onManage } = props; + + const { t } = useTranslation(); + + const avatarUrl = useAvatarUrl(props.timeline.owner.username); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef(null!); + + const notifyHeight = React.useCallback((): void => { + if (onHeight) { + onHeight(containerRef.current.getBoundingClientRect().height); + } + }, [onHeight]); + + React.useEffect(() => { + const subscription = fromEvent(window, 'resize').subscribe(notifyHeight); + return () => subscription.unsubscribe(); + }); + + const [manageDropdownOpen, setManageDropdownOpen] = React.useState( + false + ); + const toggleManageDropdown = React.useCallback( + (): void => setManageDropdownOpen((old) => !old), + [] + ); + + return ( +
+

+ {props.timeline.name} +

+
+ + {props.timeline.owner.nickname} + + @{props.timeline.owner.username} + +
+

{props.timeline.description}

+ + {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])} + +
+ {onManage != null ? ( + + + {t('timeline.manage')} + + + onManage('property')}> + {t('timeline.manageItem.property')} + + + {t('timeline.manageItem.member')} + + + onManage('delete')} + > + {t('timeline.manageItem.delete')} + + + + ) : ( + + )} +
+
+ ); +}; + +export default TimelineInfoCard; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx new file mode 100644 index 00000000..11ac9f08 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import clsx from 'clsx'; +import { + Row, + Col, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from 'reactstrap'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import Svg from 'react-inlinesvg'; + +import chevronDownIcon from 'bootstrap-icons/icons/chevron-down.svg'; +import trashIcon from 'bootstrap-icons/icons/trash.svg'; + +import { useAvatarUrl } from '../data/user'; +import { TimelinePostInfo, usePostDataUrl } from '../data/timeline'; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + + + {t('timeline.post.deleteDialog.title')} + + {t('timeline.post.deleteDialog.prompt')} + + + + + + ); +}; + +export interface TimelineItemProps { + post: TimelinePostInfo; + current?: boolean; + more?: { + isOpen: boolean; + toggle: () => void; + onDelete: () => void; + }; + onClick?: () => void; + onResize?: () => void; + className?: string; + style?: React.CSSProperties; +} + +const TimelineItem: React.FC = (props) => { + const { i18n } = useTranslation(); + + const current = props.current === true; + + const { more, onResize } = props; + + const avatarUrl = useAvatarUrl(props.post.author.username); + + const dataUrl = usePostDataUrl( + props.post.content.type === 'image', + props.post.timelineName, + props.post.id + ); + + const [deleteDialog, setDeleteDialog] = React.useState(false); + const toggleDeleteDialog = React.useCallback( + () => setDeleteDialog((old) => !old), + [] + ); + + return ( + + +
+
+
+
+
+ {current &&
} + + + +
+ + + {props.post.time.toLocaleString(i18n.languages)} + + + {props.post.author.nickname} + + +
+ {more != null ? ( +
+ { + more.toggle(); + e.stopPropagation(); + }} + /> +
+ ) : null} +
+
+ + + + {(() => { + const { content } = props.post; + if (content.type === 'text') { + return content.text; + } else { + return ( + + ); + } + })()} +
+ + {more != null && more.isOpen ? ( + <> +
+ { + toggleDeleteDialog(); + e.stopPropagation(); + }} + /> +
+ {deleteDialog ? ( + { + toggleDeleteDialog(); + more.toggle(); + }} + onConfirm={more.onDelete} + /> + ) : null} + + ) : null} + + ); +}; + +export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx new file mode 100644 index 00000000..8c637f46 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Container, + ListGroup, + ListGroupItem, + Modal, + Row, + Col, + Button, +} from 'reactstrap'; + +import { User, useAvatarUrl } from '../data/user'; + +import SearchInput from '../common/SearchInput'; + +const TimelineMemberItem: React.FC<{ + user: User; + owner: boolean; + onRemove?: (username: string) => void; +}> = ({ user, owner, onRemove }) => { + const { t } = useTranslation(); + + const avatarUrl = useAvatarUrl(user.username); + + return ( + + + + + + + {user.nickname} + + {'@' + user.username} + + + {(() => { + if (owner) { + return null; + } + if (onRemove == null) { + return null; + } + return ( + + ); + })()} + + + ); +}; + +export interface TimelineMemberCallbacks { + onCheckUser: (username: string) => Promise; + onAddUser: (user: User) => Promise; + onRemoveUser: (username: string) => void; +} + +export interface TimelineMemberProps { + members: User[]; + edit: TimelineMemberCallbacks | null | undefined; +} + +const TimelineMember: React.FC = (props) => { + const { t } = useTranslation(); + + const [userSearchText, setUserSearchText] = useState(''); + const [userSearchState, setUserSearchState] = useState< + | { + type: 'user'; + data: User; + } + | { type: 'error'; data: string } + | { type: 'loading' } + | { type: 'init' } + >({ type: 'init' }); + + const userSearchAvatarUrl = useAvatarUrl( + userSearchState.type === 'user' ? userSearchState.data.username : undefined + ); + + const members = props.members; + + return ( + + + {members.map((member, index) => ( + + ))} + + {(() => { + const edit = props.edit; + if (edit != null) { + return ( + <> + { + setUserSearchText(v); + }} + loading={userSearchState.type === 'loading'} + onButtonClick={() => { + if (userSearchText === '') { + setUserSearchState({ + type: 'error', + data: 'login.emptyUsername', + }); + return; + } + + setUserSearchState({ type: 'loading' }); + edit.onCheckUser(userSearchText).then( + (u) => { + if (u == null) { + setUserSearchState({ + type: 'error', + data: 'timeline.userNotExist', + }); + } else { + setUserSearchState({ type: 'user', data: u }); + } + }, + (e) => { + setUserSearchState({ + type: 'error', + data: `${e as string}`, + }); + } + ); + }} + /> + {(() => { + if (userSearchState.type === 'user') { + const u = userSearchState.data; + const addable = + members.findIndex((m) => m.username === u.username) === -1; + return ( + <> + {!addable ? ( +

{t('timeline.member.alreadyMember')}

+ ) : null} + + + + + + + {u.nickname} + + {'@' + u.username} + + + + + + + ); + } else if (userSearchState.type === 'error') { + return ( +

{t(userSearchState.data)}

+ ); + } + })()} + + ); + } else { + return null; + } + })()} +
+ ); +}; + +export default TimelineMember; + +export interface TimelineMemberDialogProps extends TimelineMemberProps { + open: boolean; + onClose: () => void; +} + +export const TimelineMemberDialog: React.FC = ( + props +) => { + return ( + + + + ); +}; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx new file mode 100644 index 00000000..7d0a8807 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useParams } from 'react-router'; + +import TimelinePageUI from './TimelinePageUI'; +import TimelinePageTemplate from '../timeline/TimelinePageTemplate'; +import { OrdinaryTimelineManageItem } from './TimelineInfoCard'; +import TimelineDeleteDialog from './TimelineDeleteDialog'; + +const TimelinePage: React.FC = (_) => { + const { name } = useParams<{ name: string }>(); + + const [dialog, setDialog] = React.useState( + null + ); + + let dialogElement: React.ReactElement | undefined; + if (dialog === 'delete') { + dialogElement = ( + setDialog(null)} name={name} /> + ); + } + + return ( + <> + setDialog(item)} + notFoundI18nKey="timeline.timelineNotExist" + /> + {dialogElement} + + ); +}; + +export default TimelinePage; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx new file mode 100644 index 00000000..9be7f305 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { concat, without } from 'lodash'; +import { of } from 'rxjs'; +import { catchError, switchMap, map } from 'rxjs/operators'; + +import { ExcludeKey } from '../utilities/type'; +import { pushAlert } from '../common/alert-service'; +import { useUser, userInfoService, UserNotExistError } from '../data/user'; +import { + timelineService, + TimelineInfo, + TimelineNotExistError, +} from '../data/timeline'; + +import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline'; +import { TimelineMemberDialog } from './TimelineMember'; +import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog'; +import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI'; +import { TimelinePostSendCallback } from './TimelinePostEdit'; +import { UiLogicError } from '../common'; + +export interface TimelinePageTemplateProps< + TManageItem, + TTimeline extends TimelineInfo +> { + name: string; + onManage: (item: TManageItem) => void; + UiComponent: React.ComponentType< + ExcludeKey, 'CardComponent'> + >; + dataVersion?: number; + notFoundI18nKey: string; +} + +export default function TimelinePageTemplate< + TManageItem, + TTimeline extends TimelineInfo +>( + props: TimelinePageTemplateProps +): React.ReactElement | null { + const { t } = useTranslation(); + + const { name } = props; + + const service = timelineService; + + const user = useUser(); + + const [dialog, setDialog] = React.useState( + null + ); + const [timeline, setTimeline] = React.useState( + undefined + ); + const [posts, setPosts] = React.useState< + TimelinePostInfoEx[] | 'forbid' | undefined + >(undefined); + const [error, setError] = React.useState(undefined); + + React.useEffect(() => { + const subscription = service + .getTimeline(name) + .pipe( + switchMap((ti) => { + setTimeline(ti); + if (!service.hasReadPermission(user, ti)) { + setPosts('forbid'); + return of(null); + } else { + return service + .getPosts(name) + .pipe(map((ps) => ({ timeline: ti, posts: ps }))); + } + }) + ) + .subscribe( + (data) => { + if (data != null) { + setPosts( + data.posts.map((post) => ({ + ...post, + deletable: service.hasModifyPostPermission( + user, + data.timeline, + post + ), + })) + ); + } + }, + (error) => { + if (error instanceof TimelineNotExistError) { + setError(t(props.notFoundI18nKey)); + } else { + setError( + // TODO: Convert this to a function. + (error as { message?: string })?.message ?? 'Unknown error' + ); + } + } + ); + return () => { + subscription.unsubscribe(); + }; + }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]); + + const closeDialog = React.useCallback((): void => { + setDialog(null); + }, []); + + let dialogElement: React.ReactElement | undefined; + + if (dialog === 'property') { + if (timeline == null) { + throw new UiLogicError( + 'Timeline is null but attempt to open change property dialog.' + ); + } + + dialogElement = ( + { + return service + .changeTimelineProperty(name, req) + .pipe( + map((newTimeline) => { + setTimeline(newTimeline); + }) + ) + .toPromise(); + }} + /> + ); + } else if (dialog === 'member') { + if (timeline == null) { + throw new UiLogicError( + 'Timeline is null but attempt to open change property dialog.' + ); + } + + dialogElement = ( + { + return userInfoService + .getUserInfo(u) + .pipe( + catchError((e) => { + if (e instanceof UserNotExistError) { + return of(null); + } else { + throw e; + } + }) + ) + .toPromise(); + }, + onAddUser: (u) => { + return service + .addMember(name, u.username) + .pipe( + map(() => { + setTimeline({ + ...timeline, + members: concat(timeline.members, u), + }); + }) + ) + .toPromise(); + }, + onRemoveUser: (u) => { + service.removeMember(name, u).subscribe(() => { + const toDelete = timeline.members.find( + (m) => m.username === u + ); + if (toDelete == null) { + throw new UiLogicError( + 'The member to delete is not in list.' + ); + } + setTimeline({ + ...timeline, + members: without(timeline.members, toDelete), + }); + }); + }, + } + : null + } + /> + ); + } + + const { UiComponent } = props; + + const onDelete: TimelineDeleteCallback = React.useCallback( + (index, id) => { + service.deletePost(name, id).subscribe( + () => { + setPosts((oldPosts) => + without( + oldPosts as TimelinePostInfoEx[], + (oldPosts as TimelinePostInfoEx[])[index] + ) + ); + }, + () => { + pushAlert({ + type: 'danger', + message: t('timeline.deletePostFailed'), + }); + } + ); + }, + [service, name, t] + ); + + const onPost: TimelinePostSendCallback = React.useCallback( + (req) => { + return service + .createPost(name, req) + .pipe( + map((newPost) => { + setPosts((oldPosts) => + concat(oldPosts as TimelinePostInfoEx[], { + ...newPost, + deletable: true, + }) + ); + }) + ) + .toPromise(); + }, + [service, name] + ); + + const onManageProp = props.onManage; + + const onManage = React.useCallback( + (item: 'property' | TManageItem) => { + if (item === 'property') { + setDialog(item); + } else { + onManageProp(item); + } + }, + [onManageProp] + ); + + const onMember = React.useCallback(() => { + setDialog('member'); + }, []); + + return ( + <> + + {dialogElement} + + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..4b3b3096 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -0,0 +1,236 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { fromEvent } from 'rxjs'; +import Svg from 'react-inlinesvg'; + +import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract.svg'; +import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg'; + +import { getAlertHost } from '../common/alert-service'; + +import Timeline, { + TimelinePostInfoEx, + TimelineDeleteCallback, +} from './Timeline'; +import AppBar from '../common/AppBar'; +import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; +import { useEventEmiiter } from '../common'; +import { TimelineInfo } from '../data/timeline'; + +export interface TimelineCardComponentProps { + timeline: TimelineInfo; + onManage?: (item: TManageItems | 'property') => void; + onMember: () => void; + className?: string; + onHeight?: (height: number) => void; +} + +export interface TimelinePageTemplateUIProps { + avatarKey?: string | number; + timeline?: TimelineInfo; + posts?: TimelinePostInfoEx[] | 'forbid'; + CardComponent: React.ComponentType>; + onMember: () => void; + onManage?: (item: TManageItems | 'property') => void; + onPost?: TimelinePostSendCallback; + onDelete: TimelineDeleteCallback; + error?: string; +} + +export default function TimelinePageTemplateUI( + props: TimelinePageTemplateUIProps +): React.ReactElement | null { + const { timeline } = props; + + const { t } = useTranslation(); + + const bottomSpaceRef = React.useRef(null); + + const onPostEditHeightChange = React.useCallback((height: number): void => { + const { current: bottomSpaceDiv } = bottomSpaceRef; + if (bottomSpaceDiv != null) { + bottomSpaceDiv.style.height = `${height}px`; + } + if (height === 0) { + const alertHost = getAlertHost(); + if (alertHost != null) { + alertHost.style.removeProperty('margin-bottom'); + } + } else { + const alertHost = getAlertHost(); + if (alertHost != null) { + alertHost.style.marginBottom = `${height}px`; + } + } + }, []); + + const timelineRef = React.useRef(null); + + const [getResizeEvent, triggerResizeEvent] = useEventEmiiter(); + + React.useEffect(() => { + const { current: timelineElement } = timelineRef; + if (timelineElement != null) { + let loadingScrollToBottom = true; + let pinBottom = false; + + const isAtBottom = (): boolean => + window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight; + + const disableLoadingScrollToBottom = (): void => { + loadingScrollToBottom = false; + if (isAtBottom()) pinBottom = true; + }; + + const checkAndScrollToBottom = (): void => { + if (loadingScrollToBottom || pinBottom) { + window.scrollTo(0, document.body.scrollHeight); + } + }; + + const subscriptions = [ + fromEvent(timelineElement, 'wheel').subscribe( + disableLoadingScrollToBottom + ), + fromEvent(timelineElement, 'pointerdown').subscribe( + disableLoadingScrollToBottom + ), + fromEvent(timelineElement, 'keydown').subscribe( + disableLoadingScrollToBottom + ), + fromEvent(window, 'scroll').subscribe(() => { + if (loadingScrollToBottom) return; + + if (isAtBottom()) { + pinBottom = true; + } else { + pinBottom = false; + } + }), + fromEvent(window, 'resize').subscribe(checkAndScrollToBottom), + getResizeEvent().subscribe(checkAndScrollToBottom), + ]; + + return () => { + subscriptions.forEach((s) => s.unsubscribe()); + }; + } + }, [getResizeEvent, triggerResizeEvent, timeline, props.posts]); + + const [cardHeight, setCardHeight] = React.useState(0); + + const genCardCollapseLocalStorageKey = (uniqueId: string): string => + `timeline.${uniqueId}.cardCollapse`; + + const cardCollapseLocalStorageKey = + timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null; + + const [infoCardCollapse, setInfoCardCollapse] = React.useState(true); + React.useEffect(() => { + if (cardCollapseLocalStorageKey != null) { + const savedCollapse = + window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true'; + setInfoCardCollapse(savedCollapse); + } + }, [cardCollapseLocalStorageKey]); + + let body: React.ReactElement; + + if (props.error != null) { + body =

{t(props.error)}

; + } else { + if (timeline != null) { + let timelineBody: React.ReactElement; + if (props.posts != null) { + if (props.posts === 'forbid') { + timelineBody = ( +

{t('timeline.messageCantSee')}

+ ); + } else { + timelineBody = ( + + ); + if (props.onPost != null) { + timelineBody = ( + <> + {timelineBody} +
+ + + ); + } + } + } else { + timelineBody = ( +
+ +
+ ); + } + const { CardComponent } = props; + + body = ( + <> +
+ { + const newState = !infoCardCollapse; + setInfoCardCollapse(newState); + window.localStorage.setItem( + genCardCollapseLocalStorageKey(timeline.uniqueId), + newState.toString() + ); + }} + className="float-right m-1 info-card-collapse-button text-primary icon-button" + /> + +
+ {timelineBody} + + ); + } else { + body = ( +
+ +
+ ); + } + } + + return ( + <> + +
+
+ {body} +
+ + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx new file mode 100644 index 00000000..88cc2226 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { ExcludeKey } from '../utilities/type'; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps, +} from './TimelinePageTemplateUI'; +import TimelineInfoCard, { + OrdinaryTimelineManageItem, +} from './TimelineInfoCard'; + +export type TimelinePageUIProps = ExcludeKey< + TimelinePageTemplateUIProps, + 'CardComponent' +>; + +const TimelinePageUI: React.FC = (props) => { + return ; +}; + +export default TimelinePageUI; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx new file mode 100644 index 00000000..d4d626ae --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { Button, Spinner, Row, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import Svg from 'react-inlinesvg'; + +import textIcon from 'bootstrap-icons/icons/card-text.svg'; +import imageIcon from 'bootstrap-icons/icons/image.svg'; + +import { pushAlert } from '../common/alert-service'; +import { TimelineCreatePostRequest } from '../data/timeline'; + +import FileInput from '../common/FileInput'; +import { UiLogicError } from '../common'; + +interface TimelinePostEditImageProps { + onSelect: (blob: Blob | null) => void; +} + +const TimelinePostEditImage: React.FC = (props) => { + const { onSelect } = props; + const { t } = useTranslation(); + + const [file, setFile] = React.useState(null); + const [fileUrl, setFileUrl] = React.useState(null); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (file != null) { + const url = URL.createObjectURL(file); + setFileUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } + }, [file]); + + const onInputChange: React.ChangeEventHandler = React.useCallback( + (e) => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + setFileUrl(null); + } else { + setFile(files[0]); + } + onSelect(null); + setError(null); + }, + [onSelect] + ); + + const onImgLoad = React.useCallback(() => { + onSelect(file); + }, [onSelect, file]); + + const onImgError = React.useCallback(() => { + setError('loadImageError'); + }, []); + + return ( + <> + + {fileUrl && error == null && ( + + )} + {error != null &&
{t(error)}
} + + ); +}; + +export type TimelinePostSendCallback = ( + content: TimelineCreatePostRequest +) => Promise; + +export interface TimelinePostEditProps { + className?: string; + onPost: TimelinePostSendCallback; + onHeightChange?: (height: number) => void; + timelineUniqueId: string; +} + +const TimelinePostEdit: React.FC = (props) => { + const { onPost } = props; + + const { t } = useTranslation(); + + const [state, setState] = React.useState<'input' | 'process'>('input'); + const [kind, setKind] = React.useState<'text' | 'image'>('text'); + const [text, setText] = React.useState(''); + const [imageBlob, setImageBlob] = React.useState(null); + + const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`; + + React.useEffect(() => { + setText(window.localStorage.getItem(draftLocalStorageKey) ?? ''); + }, [draftLocalStorageKey]); + + const canSend = kind === 'text' || (kind === 'image' && imageBlob != null); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef(null!); + + const notifyHeightChange = (): void => { + if (props.onHeightChange) { + props.onHeightChange(containerRef.current.clientHeight); + } + }; + + React.useEffect(() => { + if (props.onHeightChange) { + props.onHeightChange(containerRef.current.clientHeight); + } + return () => { + if (props.onHeightChange) { + props.onHeightChange(0); + } + }; + }); + + const toggleKind = React.useCallback(() => { + setKind((oldKind) => (oldKind === 'text' ? 'image' : 'text')); + setImageBlob(null); + }, []); + + const onSend = React.useCallback(() => { + setState('process'); + + const req: TimelineCreatePostRequest = (() => { + switch (kind) { + case 'text': + return { + content: { + type: 'text', + text: text, + }, + } as TimelineCreatePostRequest; + case 'image': + if (imageBlob == null) { + throw new UiLogicError( + 'Content type is image but image blob is null.' + ); + } + return { + content: { + type: 'image', + data: imageBlob, + }, + } as TimelineCreatePostRequest; + default: + throw new UiLogicError('Unknown content type.'); + } + })(); + + onPost(req).then( + (_) => { + if (kind === 'text') { + setText(''); + window.localStorage.removeItem(draftLocalStorageKey); + } + setState('input'); + setKind('text'); + }, + (_) => { + pushAlert({ + type: 'danger', + message: t('timeline.sendPostFailed'), + }); + setState('input'); + } + ); + }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]); + + const onImageSelect = React.useCallback((blob: Blob | null) => { + setImageBlob(blob); + }, []); + + return ( +
+ + + {kind === 'text' ? ( +