aboutsummaryrefslogtreecommitdiff
path: root/Timeline
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline')
-rw-r--r--Timeline/ClientApp/.babelrc19
-rw-r--r--Timeline/ClientApp/.editorconfig13
-rw-r--r--Timeline/ClientApp/.eslintrc.js49
-rw-r--r--Timeline/ClientApp/.gitignore27
-rw-r--r--Timeline/ClientApp/.prettierrc.yaml1
-rw-r--r--Timeline/ClientApp/LICENSE21
-rw-r--r--Timeline/ClientApp/README.md16
-rw-r--r--Timeline/ClientApp/package.json103
-rw-r--r--Timeline/ClientApp/public/android-chrome-192x192.pngbin0 -> 8519 bytes
-rw-r--r--Timeline/ClientApp/public/android-chrome-512x512.pngbin0 -> 23619 bytes
-rw-r--r--Timeline/ClientApp/public/apple-touch-icon.pngbin0 -> 2088 bytes
-rw-r--r--Timeline/ClientApp/public/browserconfig.xml9
-rw-r--r--Timeline/ClientApp/public/favicon-16x16.pngbin0 -> 682 bytes
-rw-r--r--Timeline/ClientApp/public/favicon-32x32.pngbin0 -> 821 bytes
-rw-r--r--Timeline/ClientApp/public/favicon.icobin0 -> 15086 bytes
-rw-r--r--Timeline/ClientApp/public/mstile-144x144.pngbin0 -> 2259 bytes
-rw-r--r--Timeline/ClientApp/public/mstile-150x150.pngbin0 -> 2240 bytes
-rw-r--r--Timeline/ClientApp/public/mstile-310x150.pngbin0 -> 2482 bytes
-rw-r--r--Timeline/ClientApp/public/mstile-310x310.pngbin0 -> 4937 bytes
-rw-r--r--Timeline/ClientApp/public/mstile-70x70.pngbin0 -> 1561 bytes
-rw-r--r--Timeline/ClientApp/public/safari-pinned-tab.svg25
-rw-r--r--Timeline/ClientApp/public/site.webmanifest22
-rw-r--r--Timeline/ClientApp/src/App.tsx84
-rw-r--r--Timeline/ClientApp/src/about/About.tsx200
-rw-r--r--Timeline/ClientApp/src/about/about.scss4
-rw-r--r--Timeline/ClientApp/src/about/author-avatar.pngbin0 -> 12038 bytes
-rw-r--r--Timeline/ClientApp/src/admin/Admin.tsx78
-rw-r--r--Timeline/ClientApp/src/admin/UserAdmin.tsx458
-rw-r--r--Timeline/ClientApp/src/common/AlertHost.tsx75
-rw-r--r--Timeline/ClientApp/src/common/AppBar.tsx107
-rw-r--r--Timeline/ClientApp/src/common/CollapseButton.tsx105
-rw-r--r--Timeline/ClientApp/src/common/FileInput.tsx41
-rw-r--r--Timeline/ClientApp/src/common/ImageCropper.tsx294
-rw-r--r--Timeline/ClientApp/src/common/LoadingPage.tsx12
-rw-r--r--Timeline/ClientApp/src/common/OperationDialog.tsx380
-rw-r--r--Timeline/ClientApp/src/common/SearchInput.tsx63
-rw-r--r--Timeline/ClientApp/src/common/TimelineLogo.tsx26
-rw-r--r--Timeline/ClientApp/src/common/UserTimelineLogo.tsx26
-rw-r--r--Timeline/ClientApp/src/common/alert-service.ts59
-rw-r--r--Timeline/ClientApp/src/common/alert.scss20
-rw-r--r--Timeline/ClientApp/src/common/common.scss38
-rw-r--r--Timeline/ClientApp/src/config.ts12
-rw-r--r--Timeline/ClientApp/src/data/base64.ts9
-rw-r--r--Timeline/ClientApp/src/data/common.ts20
-rw-r--r--Timeline/ClientApp/src/data/timeline.ts344
-rw-r--r--Timeline/ClientApp/src/data/user.ts215
-rw-r--r--Timeline/ClientApp/src/helper.ts35
-rw-r--r--Timeline/ClientApp/src/home/Home.tsx155
-rw-r--r--Timeline/ClientApp/src/home/TimelineBoard.tsx54
-rw-r--r--Timeline/ClientApp/src/home/TimelineBoardAreaWithUser.tsx36
-rw-r--r--Timeline/ClientApp/src/home/TimelineBoardAreaWithoutUser.tsx26
-rw-r--r--Timeline/ClientApp/src/home/TimelineCreateDialog.tsx60
-rw-r--r--Timeline/ClientApp/src/home/home.scss21
-rw-r--r--Timeline/ClientApp/src/i18n.ts80
-rw-r--r--Timeline/ClientApp/src/index.scss89
-rw-r--r--Timeline/ClientApp/src/index.tsx15
-rw-r--r--Timeline/ClientApp/src/locales/en/translation.ts172
-rw-r--r--Timeline/ClientApp/src/locales/scheme.ts158
-rw-r--r--Timeline/ClientApp/src/locales/zh/translation.ts169
-rw-r--r--Timeline/ClientApp/src/settings/Settings.tsx200
-rw-r--r--Timeline/ClientApp/src/timeline/Timeline.tsx87
-rw-r--r--Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx59
-rw-r--r--Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx112
-rw-r--r--Timeline/ClientApp/src/timeline/TimelineItem.tsx113
-rw-r--r--Timeline/ClientApp/src/timeline/TimelineMember.tsx194
-rw-r--r--Timeline/ClientApp/src/timeline/TimelinePage.tsx39
-rw-r--r--Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx271
-rw-r--r--Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx147
-rw-r--r--Timeline/ClientApp/src/timeline/TimelinePageUI.tsx22
-rw-r--r--Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx205
-rw-r--r--Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx70
-rw-r--r--Timeline/ClientApp/src/timeline/timeline-ui.scss27
-rw-r--r--Timeline/ClientApp/src/timeline/timeline.scss119
-rw-r--r--Timeline/ClientApp/src/type-utilities.ts1
-rw-r--r--Timeline/ClientApp/src/typings.d.ts24
-rw-r--r--Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx305
-rw-r--r--Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx28
-rw-r--r--Timeline/ClientApp/src/user/Login.tsx146
-rw-r--r--Timeline/ClientApp/src/user/User.tsx70
-rw-r--r--Timeline/ClientApp/src/user/UserInfoCard.tsx126
-rw-r--r--Timeline/ClientApp/src/user/UserPage.tsx20
-rw-r--r--Timeline/ClientApp/src/user/api.ts78
-rw-r--r--Timeline/ClientApp/src/user/user-page.scss13
-rw-r--r--Timeline/ClientApp/tsconfig-for-webpack-config.json13
-rw-r--r--Timeline/ClientApp/tsconfig.json25
-rw-r--r--Timeline/ClientApp/webpack.common.ts59
-rw-r--r--Timeline/ClientApp/webpack.config.dev.ts68
-rw-r--r--Timeline/ClientApp/webpack.config.prod.dev.ts15
-rw-r--r--Timeline/ClientApp/webpack.config.prod.ts59
-rw-r--r--Timeline/Routes/ApiRoutePrefixConvention.cs45
-rw-r--r--Timeline/Startup.cs45
-rw-r--r--Timeline/Timeline.csproj110
92 files changed, 6904 insertions, 56 deletions
diff --git a/Timeline/ClientApp/.babelrc b/Timeline/ClientApp/.babelrc
new file mode 100644
index 00000000..5a82470b
--- /dev/null
+++ b/Timeline/ClientApp/.babelrc
@@ -0,0 +1,19 @@
+{
+ "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
+ }
+ ]
+ ]
+} \ No newline at end of file
diff --git a/Timeline/ClientApp/.editorconfig b/Timeline/ClientApp/.editorconfig
new file mode 100644
index 00000000..fc0b1223
--- /dev/null
+++ b/Timeline/ClientApp/.editorconfig
@@ -0,0 +1,13 @@
+root = true
+
+[*.ts]
+tab_width = 2
+
+[*.tsx]
+tab_width = 2
+
+[*.css]
+tab_width = 2
+
+[*.scss]
+tab_width = 2
diff --git a/Timeline/ClientApp/.eslintrc.js b/Timeline/ClientApp/.eslintrc.js
new file mode 100644
index 00000000..e1cd037e
--- /dev/null
+++ b/Timeline/ClientApp/.eslintrc.js
@@ -0,0 +1,49 @@
+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: ['./tsconfig.json', './tsconfig-for-webpack-config.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-non-null-assertion': '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/.gitignore b/Timeline/ClientApp/.gitignore
new file mode 100644
index 00000000..e7a6cf65
--- /dev/null
+++ b/Timeline/ClientApp/.gitignore
@@ -0,0 +1,27 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# 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
+
+dist \ No newline at end of file
diff --git a/Timeline/ClientApp/.prettierrc.yaml b/Timeline/ClientApp/.prettierrc.yaml
new file mode 100644
index 00000000..e871b731
--- /dev/null
+++ b/Timeline/ClientApp/.prettierrc.yaml
@@ -0,0 +1 @@
+singleQuote: true
diff --git a/Timeline/ClientApp/LICENSE b/Timeline/ClientApp/LICENSE
new file mode 100644
index 00000000..c5dd66a5
--- /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..9b265493
--- /dev/null
+++ b/Timeline/ClientApp/README.md
@@ -0,0 +1,16 @@
+# Welcome to Timeline!
+
+[![Build Status](https://dev.azure.com/crupest-web/Timeline/_apis/build/status/crupest.Timeline-React?branchName=master)](https://dev.azure.com/crupest-web/Timeline/_build/latest?definitionId=8&branchName=master)
+[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=crupest/Timeline-React)](https://dependabot.com)
+
+This is the first web app front-end of [me](https://github.com/crupest).
+
+It is written with [React](https://reactjs.org/) and [Typescript](https://www.typescriptlang.org/).
+
+The final product is hosting on my [Tencent Cloud](https://cloud.tencent.com/) Cloud Virtual Machine on https://www.crupest.xyz.
+
+Feel free to comment by opening an issue.
+
+### jibber-jabber
+
+I used to write the website with Angular, you can find the old project [here](https://github.com/crupest/Timeline-App), in which I build full unit tests and CI/CD. But I finally gave up for the complexity. Then I turned to flutter to build an Android app, which can be found [here](https://github.com/crupest/Timeline-Flutter). But as time passed by, it turned out that it is simpler than Angular but still very complicated. In the end, I got here and met React. First I use [Create React App](https://github.com/facebook/create-react-app) to bootstrap this and later turn to manually configured Webpack. I also adopt the [Material-UI](https://material-ui.com/).
diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json
new file mode 100644
index 00000000..2c9f682d
--- /dev/null
+++ b/Timeline/ClientApp/package.json
@@ -0,0 +1,103 @@
+{
+ "name": "timeline",
+ "version": "0.1.0",
+ "private": true,
+ "homepage": "https://crupest.xyz",
+ "dependencies": {
+ "@fortawesome/fontawesome-free": "^5.13.0",
+ "axios": "^0.19.0",
+ "bootstrap": "^4.5.0",
+ "classnames": "^2.2.6",
+ "clsx": "^1.0.4",
+ "core-js": "^3.6.5",
+ "i18next": "^19.4.5",
+ "i18next-browser-languagedetector": "^4.2.0",
+ "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-responsive": "^8.0.3",
+ "react-router-dom": "^5.2.0",
+ "reactstrap": "^8.4.1",
+ "regenerator-runtime": "^0.13.5",
+ "rxjs": "^6.5.5",
+ "xregexp": "^4.3.0"
+ },
+ "scripts": {
+ "start": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack-dev-server --config ./webpack.config.dev.ts",
+ "start-prod": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack --config ./webpack.config.prod.dev.ts && http-server dist -p 3000",
+ "build": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack --config ./webpack.config.prod.ts",
+ "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.1",
+ "@babel/plugin-proposal-class-properties": "^7.10.1",
+ "@babel/plugin-proposal-decorators": "^7.10.1",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.1",
+ "@babel/plugin-proposal-optional-chaining": "^7.10.1",
+ "@babel/plugin-syntax-dynamic-import": "^7.2.0",
+ "@babel/preset-env": "^7.10.1",
+ "@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/copy-webpack-plugin": "^5.0.1",
+ "@types/html-webpack-plugin": "^3.2.3",
+ "@types/lodash": "^4.14.153",
+ "@types/node": "^14.0.6",
+ "@types/react": "^16.9.35",
+ "@types/react-dom": "^16.9.8",
+ "@types/react-responsive": "^8.0.2",
+ "@types/react-router-dom": "^5.1.5",
+ "@types/reactstrap": "^8.4.2",
+ "@types/webpack": "^4.41.14",
+ "@types/webpack-dev-server": "^3.11.0",
+ "@types/webpack-env": "^1.15.2",
+ "@types/xregexp": "^4.3.0",
+ "@typescript-eslint/eslint-plugin": "^2.34.0",
+ "@typescript-eslint/parser": "^2.34.0",
+ "autoprefixer": "^9.8.0",
+ "babel-loader": "^8.1.0",
+ "clean-webpack-plugin": "^3.0.0",
+ "copy-webpack-plugin": "^5.0.4",
+ "cross-env": "^7.0.2",
+ "css-loader": "^3.5.3",
+ "eslint": "^6.7.1",
+ "eslint-config-prettier": "^6.11.0",
+ "eslint-plugin-prettier": "^3.1.3",
+ "eslint-plugin-react": "^7.20.0",
+ "eslint-plugin-react-hooks": "^4.0.4",
+ "file-loader": "^6.0.0",
+ "fork-ts-checker-webpack-plugin": "^4.1.6",
+ "html-webpack-plugin": "^3.2.0",
+ "html-webpack-template": "^6.2.0",
+ "http-server": "^0.12.3",
+ "image-webpack-loader": "^6.0.0",
+ "postcss-loader": "^3.0.0",
+ "prettier": "^2.0.5",
+ "sass": "^1.26.7",
+ "sass-loader": "^8.0.2",
+ "style-loader": "^1.2.1",
+ "ts-node": "^8.10.2",
+ "tsconfig-paths": "^3.9.0",
+ "typescript": "^3.9.3",
+ "url-loader": "^4.1.0",
+ "webpack": "^4.43.0",
+ "webpack-cli": "^3.3.9",
+ "webpack-dev-server": "^3.11.0"
+ }
+}
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
--- /dev/null
+++ b/Timeline/ClientApp/public/android-chrome-192x192.png
Binary files 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
--- /dev/null
+++ b/Timeline/ClientApp/public/android-chrome-512x512.png
Binary files 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
--- /dev/null
+++ b/Timeline/ClientApp/public/apple-touch-icon.png
Binary files differ
diff --git a/Timeline/ClientApp/public/browserconfig.xml b/Timeline/ClientApp/public/browserconfig.xml
new file mode 100644
index 00000000..a47e5a5b
--- /dev/null
+++ b/Timeline/ClientApp/public/browserconfig.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+ <msapplication>
+ <tile>
+ <square150x150logo src="/mstile-150x150.png"/>
+ <TileColor>#2d89ef</TileColor>
+ </tile>
+ </msapplication>
+</browserconfig>
diff --git a/Timeline/ClientApp/public/favicon-16x16.png b/Timeline/ClientApp/public/favicon-16x16.png
new file mode 100644
index 00000000..6c978995
--- /dev/null
+++ b/Timeline/ClientApp/public/favicon-16x16.png
Binary files differ
diff --git a/Timeline/ClientApp/public/favicon-32x32.png b/Timeline/ClientApp/public/favicon-32x32.png
new file mode 100644
index 00000000..bbde902f
--- /dev/null
+++ b/Timeline/ClientApp/public/favicon-32x32.png
Binary files differ
diff --git a/Timeline/ClientApp/public/favicon.ico b/Timeline/ClientApp/public/favicon.ico
new file mode 100644
index 00000000..d4cd3db6
--- /dev/null
+++ b/Timeline/ClientApp/public/favicon.ico
Binary files differ
diff --git a/Timeline/ClientApp/public/mstile-144x144.png b/Timeline/ClientApp/public/mstile-144x144.png
new file mode 100644
index 00000000..61eaaf43
--- /dev/null
+++ b/Timeline/ClientApp/public/mstile-144x144.png
Binary files differ
diff --git a/Timeline/ClientApp/public/mstile-150x150.png b/Timeline/ClientApp/public/mstile-150x150.png
new file mode 100644
index 00000000..85fa83ee
--- /dev/null
+++ b/Timeline/ClientApp/public/mstile-150x150.png
Binary files differ
diff --git a/Timeline/ClientApp/public/mstile-310x150.png b/Timeline/ClientApp/public/mstile-310x150.png
new file mode 100644
index 00000000..41889953
--- /dev/null
+++ b/Timeline/ClientApp/public/mstile-310x150.png
Binary files differ
diff --git a/Timeline/ClientApp/public/mstile-310x310.png b/Timeline/ClientApp/public/mstile-310x310.png
new file mode 100644
index 00000000..cddce02e
--- /dev/null
+++ b/Timeline/ClientApp/public/mstile-310x310.png
Binary files differ
diff --git a/Timeline/ClientApp/public/mstile-70x70.png b/Timeline/ClientApp/public/mstile-70x70.png
new file mode 100644
index 00000000..52f59d43
--- /dev/null
+++ b/Timeline/ClientApp/public/mstile-70x70.png
Binary files 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..8886ebcc
--- /dev/null
+++ b/Timeline/ClientApp/public/safari-pinned-tab.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+</metadata>
+<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M3080 6207 l0 -794 -57 -13 c-148 -33 -321 -102 -503 -202 -250 -136
+-519 -398 -687 -669 -122 -196 -224 -463 -264 -694 -24 -139 -32 -418 -16
+-560 72 -648 485 -1236 1069 -1523 68 -33 139 -67 158 -75 51 -22 219 -74 263
+-82 l37 -7 0 -794 0 -794 420 0 420 0 0 794 0 794 38 7 c128 23 423 146 573
+238 503 309 846 851 916 1447 10 83 10 355 1 441 -15 127 -49 283 -89 399 -38
+111 -36 105 -108 252 -151 310 -366 554 -673 765 -62 43 -260 149 -306 164
+-20 6 -41 15 -47 19 -15 11 -183 66 -248 81 l-57 14 0 792 0 793 -420 0 -420
+0 0 -793z m600 -1601 c14 -3 39 -9 55 -12 17 -2 48 -10 70 -16 22 -6 42 -12
+45 -13 21 -4 117 -45 170 -74 277 -145 492 -417 565 -716 24 -100 26 -116 31
+-235 12 -327 -107 -619 -344 -847 -342 -329 -846 -405 -1272 -192 -293 147
+-488 387 -584 719 -24 81 -31 151 -31 300 1 234 65 432 203 630 57 82 177 201
+263 263 137 97 323 173 474 193 28 3 52 8 54 10 6 5 274 -3 301 -10z"/>
+</g>
+</svg>
diff --git a/Timeline/ClientApp/public/site.webmanifest b/Timeline/ClientApp/public/site.webmanifest
new file mode 100644
index 00000000..6746fe59
--- /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/src/App.tsx b/Timeline/ClientApp/src/App.tsx
new file mode 100644
index 00000000..d3cfbd8a
--- /dev/null
+++ b/Timeline/ClientApp/src/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 { checkUserLoginState, useOptionalUser } from './data/user';
+
+const NoMatch: React.FC = () => {
+ return (
+ <>
+ <AppBar />
+ <div style={{ height: 56 }} />
+ <div>Ah-oh, 404!</div>
+ </>
+ );
+};
+
+const LazyAdmin = React.lazy(() =>
+ import(/* webpackChunkName: "admin" */ './admin/Admin')
+);
+
+const App: React.FC = () => {
+ const user = useOptionalUser();
+
+ React.useEffect(() => {
+ checkUserLoginState();
+ }, []);
+
+ let body;
+ if (user === undefined) {
+ body = <LoadingPage />;
+ } else {
+ body = (
+ <React.Suspense fallback={<LoadingPage />}>
+ <Switch>
+ <Route exact path="/">
+ <Home />
+ </Route>
+ <Route exact path="/login">
+ <Login />
+ </Route>
+ <Route path="/settings">
+ <Settings />
+ </Route>
+ <Route path="/about">
+ <About />
+ </Route>
+ <Route path="/timelines/:name">
+ <TimelinePage />
+ </Route>
+ <Route path="/users/:username">
+ <User />
+ </Route>
+ {user && user.administrator && (
+ <Route path="/admin">
+ <LazyAdmin user={user} />
+ </Route>
+ )}
+ <Route>
+ <NoMatch />
+ </Route>
+ </Switch>
+ </React.Suspense>
+ );
+ }
+
+ return (
+ <>
+ <Router>{body}</Router>
+ <AlertHost />
+ </>
+ );
+};
+
+export default hot(App);
diff --git a/Timeline/ClientApp/src/about/About.tsx b/Timeline/ClientApp/src/about/About.tsx
new file mode 100644
index 00000000..70cc653f
--- /dev/null
+++ b/Timeline/ClientApp/src/about/About.tsx
@@ -0,0 +1,200 @@
+import React from 'react';
+import { useTranslation, Trans } from 'react-i18next';
+
+import authorAvatarUrl from './author-avatar.png';
+
+import AppBar from '../common/AppBar';
+
+const frontendCredits: {
+ name: string;
+ url: string;
+ icon?: string;
+}[] = [
+ {
+ name: 'reactjs',
+ url: 'https://reactjs.org',
+ icon: 'react',
+ },
+ {
+ name: 'typescript',
+ url: 'https://www.typescriptlang.org',
+ },
+ {
+ name: 'bootstrap',
+ url: 'https://getbootstrap.com',
+ icon: 'bootstrap',
+ },
+ {
+ 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',
+ icon: 'sass',
+ },
+ {
+ name: 'fontawesome',
+ url: 'https://fontawesome.com',
+ icon: 'font-awesome-flag',
+ },
+ {
+ name: 'eslint',
+ url: 'https://eslint.org',
+ },
+ {
+ name: 'prettier',
+ url: 'https://prettier.io',
+ },
+ {
+ name: 'pepjs',
+ url: 'https://github.com/jquery/PEP',
+ },
+ {
+ name: 'cross-env',
+ url: 'https://github.com/kentcdodds/cross-env',
+ },
+];
+
+const backendCredits: {
+ name: string;
+ url: string;
+ icon?: 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 (
+ <>
+ <AppBar />
+ <div className="mt-appbar px-2 mb-4">
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4 id="author-info">{t('about.author.title')}</h4>
+ <div>
+ <div className="d-flex">
+ <img
+ src={authorAvatarUrl}
+ className="align-self-start avatar large rounded-circle"
+ />
+ <div>
+ <p>
+ <small>{t('about.author.fullname')}</small>
+ <span className="text-primary">杨宇千</span>
+ </p>
+ <p>
+ <small>{t('about.author.nickname')}</small>
+ <span className="text-primary">crupest</span>
+ </p>
+ <p>
+ <small>{t('about.author.introduction')}</small>
+ {t('about.author.introductionContent')}
+ </p>
+ </div>
+ </div>
+ <p>
+ <small>{t('about.author.links')}</small>
+ <a
+ href="https://github.com/crupest"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <i className="fab fa-github about-link-icon text-body" />
+ </a>
+ <a
+ href="https://blog.crupest.xyz"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <i className="fas fa-blog about-link-icon text-body" />
+ </a>
+ </p>
+ </div>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t('about.site.title')}</h4>
+ <p>
+ <Trans i18nKey="about.site.content">
+ 0<span className="text-primary">1</span>2<b>3</b>4
+ <a href="#author-info">5</a>6
+ </Trans>
+ </p>
+ <p>
+ <a
+ href="https://github.com/crupest/Timeline-React"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t('about.site.frontendRepo')}
+ </a>
+ </p>
+ <p>
+ <a
+ href="https://github.com/crupest/Timeline"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t('about.site.backendRepo')}
+ </a>
+ </p>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t('about.credits.title')}</h4>
+ <p>{t('about.credits.content')}</p>
+ <p>{t('about.credits.frontend')}</p>
+ <ul>
+ {frontendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.icon != null && (
+ <i className={'fab fa-' + item.icon + ' mx-2'} />
+ )}
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ <p>{t('about.credits.backend')}</p>
+ <ul>
+ {backendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.icon != null && (
+ <i className={'fab fa-' + item.icon + ' mx-2'} />
+ )}
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ </div>
+ </div>
+ </>
+ );
+};
+
+export default About;
diff --git a/Timeline/ClientApp/src/about/about.scss b/Timeline/ClientApp/src/about/about.scss
new file mode 100644
index 00000000..b097aa89
--- /dev/null
+++ b/Timeline/ClientApp/src/about/about.scss
@@ -0,0 +1,4 @@
+.about-link-icon {
+ @extend .mx-2;
+ font-size: 1.2em;
+}
diff --git a/Timeline/ClientApp/src/about/author-avatar.png b/Timeline/ClientApp/src/about/author-avatar.png
new file mode 100644
index 00000000..d890d8d0
--- /dev/null
+++ b/Timeline/ClientApp/src/about/author-avatar.png
Binary files differ
diff --git a/Timeline/ClientApp/src/admin/Admin.tsx b/Timeline/ClientApp/src/admin/Admin.tsx
new file mode 100644
index 00000000..af07dd2b
--- /dev/null
+++ b/Timeline/ClientApp/src/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<AdminProps> = 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 (
+ <Route path={`${match.path}/${name}`}>
+ <AppBar />
+ <div style={{ height: 56 }} className="flex-fix-length" />
+ <Nav tabs>
+ <NavItem>
+ <NavLink
+ className={classnames({ active: tabName === 'users' })}
+ onClick={() => {
+ toggle('users');
+ }}
+ >
+ Users
+ </NavLink>
+ </NavItem>
+ <NavItem>
+ <NavLink
+ className={classnames({ active: tabName === 'more' })}
+ onClick={() => {
+ toggle('more');
+ }}
+ >
+ More
+ </NavLink>
+ </NavItem>
+ </Nav>
+ {body}
+ </Route>
+ );
+ };
+
+ return (
+ <Fragment>
+ <Switch>
+ <Redirect from={match.path} to={`${match.path}/users`} exact />
+ {createRoute('users', <UserAdmin user={props.user} />)}
+ {createRoute('more', <div>More Page Works</div>)}
+ </Switch>
+ </Fragment>
+ );
+};
+
+export default Admin;
diff --git a/Timeline/ClientApp/src/admin/UserAdmin.tsx b/Timeline/ClientApp/src/admin/UserAdmin.tsx
new file mode 100644
index 00000000..afef79c9
--- /dev/null
+++ b/Timeline/ClientApp/src/admin/UserAdmin.tsx
@@ -0,0 +1,458 @@
+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';
+import { apiBaseUrl } from '../config';
+
+async function fetchUserList(_token: string): Promise<User[]> {
+ 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<User> {
+ const res = await axios.post<User>(
+ `${apiBaseUrl}/userop/createuser?token=${token}`,
+ user
+ );
+ return res.data;
+}
+
+function deleteUser(username: string, token: string): Promise<void> {
+ return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
+}
+
+function changeUsername(
+ oldUsername: string,
+ newUsername: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
+ username: newUsername,
+ });
+}
+
+function changePassword(
+ username: string,
+ newPassword: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ password: newPassword,
+ });
+}
+
+function changePermission(
+ username: string,
+ newPermission: boolean,
+ token: string
+): Promise<void> {
+ 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<UserCardProps> = (props) => {
+ const user = props.user;
+
+ const createClickCallback = (item: ContextMenuItem): (() => void) => {
+ return () => {
+ props.onContextMenu(item);
+ };
+ };
+
+ return (
+ <ListGroupItem className="container">
+ <Row className="align-items-center">
+ <Col>
+ <p className="mb-0 text-primary">{user.username}</p>
+ <small
+ className={user.administrator ? 'text-danger' : 'text-secondary'}
+ >
+ {user.administrator ? 'administrator' : 'user'}
+ </small>
+ </Col>
+ <Col className="col-auto">
+ <UncontrolledDropdown>
+ <DropdownToggle color="warning" className="text-light" caret>
+ Manage
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={createClickCallback(kChangeUsername)}>
+ Change Username
+ </DropdownItem>
+ <DropdownItem onClick={createClickCallback(kChangePassword)}>
+ Change Password
+ </DropdownItem>
+ <DropdownItem onClick={createClickCallback(kChangePermission)}>
+ Change Permission
+ </DropdownItem>
+ <DropdownItem
+ className="text-danger"
+ onClick={createClickCallback(kDelete)}
+ >
+ Delete
+ </DropdownItem>
+ </DropdownMenu>
+ </UncontrolledDropdown>
+ </Col>
+ </Row>
+ </ListGroupItem>
+ );
+};
+
+interface DialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+interface CreateUserDialogProps extends DialogProps {
+ process: (user: CreateUserInfo) => Promise<void>;
+}
+
+const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ title="Create"
+ titleColor="create"
+ inputPrompt="You are creating a new user."
+ inputScheme={[
+ { type: 'text', label: 'Username' },
+ { type: 'text', label: 'Password' },
+ { type: 'bool', label: 'Administrator' },
+ ]}
+ onProcess={([username, password, administrator]) =>
+ 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 <span style={{ color: 'blue' }}>{props.children}</span>;
+};
+
+interface UserDeleteDialogProps extends DialogProps {
+ username: string;
+ process: () => Promise<void>;
+}
+
+const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Dangerous"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are deleting user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' !'}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserModifyDialogProps<T> extends DialogProps {
+ username: string;
+ process: (value: T) => Promise<void>;
+}
+
+const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are change the username of user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' !'}
+ </>
+ )}
+ inputScheme={[{ type: 'text', label: 'New Username' }]}
+ onProcess={([newUsername]) => {
+ return props.process(newUsername as string);
+ }}
+ />
+ );
+};
+
+const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are change the password of user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' !'}
+ </>
+ )}
+ inputScheme={[{ type: 'text', label: 'New Password' }]}
+ onProcess={([newPassword]) => {
+ return props.process(newPassword as string);
+ }}
+ />
+ );
+};
+
+interface UserChangePermissionDialogProps extends DialogProps {
+ username: string;
+ newPermission: boolean;
+ process: () => Promise<void>;
+}
+
+const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {'You are change user '}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {' to '}
+ <span style={{ color: 'orange' }}>
+ {props.newPermission ? 'administrator' : 'normal user'}
+ </span>
+ {' !'}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserAdminProps {
+ user: UserWithToken;
+}
+
+const UserAdmin: React.FC<UserAdminProps> = (props) => {
+ type DialogInfo =
+ | null
+ | {
+ type: 'create';
+ }
+ | { type: 'delete'; username: string }
+ | {
+ type: TChangeUsername;
+ username: string;
+ }
+ | {
+ type: TChangePassword;
+ username: string;
+ }
+ | {
+ type: TChangePermission;
+ username: string;
+ newPermission: boolean;
+ };
+
+ const [users, setUsers] = useState<User[] | null>(null);
+ const [dialog, setDialog] = useState<DialogInfo>(null);
+
+ const token = props.user.token;
+
+ useEffect(() => {
+ let subscribe = true;
+ 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 = (
+ <CreateUserDialog
+ open
+ close={() => setDialog(null)}
+ process={async (user) => {
+ const u = await createUser(user, token);
+ setUsers((oldUsers) => [...oldUsers!, u]);
+ }}
+ />
+ );
+ break;
+ case 'delete':
+ dialogNode = (
+ <UserDeleteDialog
+ open
+ close={() => 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 = (
+ <UserChangeUsernameDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newUsername) => {
+ await changeUsername(dialog.username, newUsername, token);
+ setUsers((oldUsers) => {
+ const users = oldUsers!.slice();
+ users.find(
+ (u) => u.username === dialog.username
+ )!.username = newUsername;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ case kChangePassword:
+ dialogNode = (
+ <UserChangePasswordDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newPassword) => {
+ await changePassword(dialog.username, newPassword, token);
+ }}
+ />
+ );
+ break;
+ case kChangePermission: {
+ const newPermission = dialog.newPermission;
+ dialogNode = (
+ <UserChangePermissionDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ newPermission={newPermission}
+ process={async () => {
+ await changePermission(dialog.username, newPermission, token);
+ setUsers((oldUsers) => {
+ const users = oldUsers!.slice();
+ users.find(
+ (u) => u.username === dialog.username
+ )!.administrator = newPermission;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ }
+ }
+
+ if (users) {
+ const userComponents = users.map((user) => {
+ return (
+ <UserItem
+ key={user.username}
+ user={user}
+ onContextMenu={(item) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const dialogInfo: any = {
+ type: item,
+ username: user.username,
+ };
+ if (item === 'changepermission') {
+ dialogInfo.newPermission = !user.administrator;
+ }
+ setDialog(dialogInfo);
+ }}
+ />
+ );
+ });
+
+ return (
+ <>
+ <Button
+ color="success"
+ onClick={() =>
+ setDialog({
+ type: 'create',
+ })
+ }
+ className="align-self-end"
+ >
+ Create User
+ </Button>
+ {userComponents}
+ {dialogNode}
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+};
+
+export default UserAdmin;
diff --git a/Timeline/ClientApp/src/common/AlertHost.tsx b/Timeline/ClientApp/src/common/AlertHost.tsx
new file mode 100644
index 00000000..2d9dbb13
--- /dev/null
+++ b/Timeline/ClientApp/src/common/AlertHost.tsx
@@ -0,0 +1,75 @@
+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';
+
+interface AutoCloseAlertProps {
+ alert: AlertInfo;
+ close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+ const { alert } = props;
+
+ React.useEffect(() => {
+ const tag = window.setTimeout(props.close, 5000);
+ return () => window.clearTimeout(tag);
+ }, [props.close]);
+
+ return (
+ <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}>
+ {alert.message}
+ </Alert>
+ );
+};
+
+// oh what a bad name!
+interface AlertInfoExEx extends AlertInfoEx {
+ close: () => void;
+}
+
+export const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
+
+ // 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 (
+ <div id={kAlertHostId} className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
+ );
+ })}
+ </div>
+ );
+};
+
+export default AlertHost;
diff --git a/Timeline/ClientApp/src/common/AppBar.tsx b/Timeline/ClientApp/src/common/AppBar.tsx
new file mode 100644
index 00000000..39794b0f
--- /dev/null
+++ b/Timeline/ClientApp/src/common/AppBar.tsx
@@ -0,0 +1,107 @@
+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 } from '../data/user';
+import { useOptionalVersionedAvatarUrl } from '../user/api';
+
+import TimelineLogo from './TimelineLogo';
+
+const AppBar: React.FC<{}> = (_) => {
+ const history = useHistory();
+ const user = useUser();
+ const avatarUrl = useOptionalVersionedAvatarUrl(user?._links?.avatar);
+
+ 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 = (
+ <div className="ml-auto mr-2">
+ {user != null ? (
+ <NavLink to={`/users/${user.username}`}>
+ <img
+ className="avatar small rounded-circle bg-white"
+ src={avatarUrl}
+ />
+ </NavLink>
+ ) : (
+ <NavLink className="text-light" to="/login">
+ {t('nav.login')}
+ </NavLink>
+ )}
+ </div>
+ );
+
+ return (
+ <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
+ <Link to="/" className="navbar-brand d-flex align-items-center">
+ <TimelineLogo style={{ height: '1em' }} />
+ Timeline
+ </Link>
+
+ {isUpMd ? null : rightArea}
+
+ <NavbarToggler onClick={toggleMenu} />
+ <Collapse isOpen={isMenuOpen} navbar>
+ <Nav className="mr-auto" navbar>
+ <NavItem
+ className={
+ matchPath(history.location.pathname, '/settings')
+ ? 'active'
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/settings">
+ {t('nav.settings')}
+ </NavLink>
+ </NavItem>
+
+ <NavItem
+ className={
+ matchPath(history.location.pathname, '/about')
+ ? 'active'
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/about">
+ {t('nav.about')}
+ </NavLink>
+ </NavItem>
+
+ {isAdministrator && (
+ <NavItem
+ className={
+ matchPath(history.location.pathname, '/admin')
+ ? 'active'
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/admin">
+ Administration
+ </NavLink>
+ </NavItem>
+ )}
+ </Nav>
+ {isUpMd ? rightArea : null}
+ </Collapse>
+ </Navbar>
+ );
+};
+
+export default AppBar;
diff --git a/Timeline/ClientApp/src/common/CollapseButton.tsx b/Timeline/ClientApp/src/common/CollapseButton.tsx
new file mode 100644
index 00000000..03f42e73
--- /dev/null
+++ b/Timeline/ClientApp/src/common/CollapseButton.tsx
@@ -0,0 +1,105 @@
+import React from 'react';
+
+export interface CollapseButtonProps {
+ collapse: boolean;
+ toggle: (visibility: boolean) => void;
+ className?: string;
+}
+
+const CollapseButton: React.FC<CollapseButtonProps> = (props) => {
+ const { toggle, collapse, className } = props;
+
+ const onClick = React.useCallback(() => {
+ toggle(!collapse);
+ }, [toggle, collapse]);
+
+ return (
+ <svg
+ width="25"
+ height="25"
+ viewBox="0 0 100 100"
+ className={className}
+ onClick={onClick}
+ >
+ {(() => {
+ if (collapse) {
+ return (
+ <>
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="50"
+ x2="90"
+ y1="17"
+ y2="17"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="10"
+ x2="50"
+ y1="83"
+ y2="83"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="17"
+ x2="17"
+ y1="50"
+ y2="90"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="83"
+ x2="83"
+ y1="10"
+ y2="50"
+ />
+ </>
+ );
+ } else {
+ return (
+ <>
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="55"
+ x2="95"
+ y1="38"
+ y2="38"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="5"
+ x2="45"
+ y1="62"
+ y2="62"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="38"
+ x2="38"
+ y1="55"
+ y2="95"
+ />
+ <line
+ stroke="currentcolor"
+ strokeWidth="14"
+ x1="62"
+ x2="62"
+ y1="5"
+ y2="45"
+ />
+ </>
+ );
+ }
+ })()}
+ </svg>
+ );
+};
+
+export default CollapseButton;
diff --git a/Timeline/ClientApp/src/common/FileInput.tsx b/Timeline/ClientApp/src/common/FileInput.tsx
new file mode 100644
index 00000000..d434f0fa
--- /dev/null
+++ b/Timeline/ClientApp/src/common/FileInput.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import clsx from 'clsx';
+
+import { ExcludeKey } from '../type-utilities';
+
+export interface FileInputProps
+ extends ExcludeKey<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ 'type' | 'id'
+ > {
+ inputId?: string;
+ labelText: string;
+ color?: string;
+ className?: string;
+}
+
+const FileInput: React.FC<FileInputProps> = props => {
+ const { inputId, labelText, color, className, ...otherProps } = props;
+
+ const realInputId = React.useMemo<string>(() => {
+ if (inputId != null) return inputId;
+ return (
+ 'file-input-' +
+ (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
+ );
+ }, [inputId]);
+
+ return (
+ <>
+ <input className="d-none" type="file" id={realInputId} {...otherProps} />
+ <label
+ htmlFor={realInputId}
+ className={clsx('btn', 'btn-' + (color ?? 'primary'), className)}
+ >
+ {labelText}
+ </label>
+ </>
+ );
+};
+
+export default FileInput;
diff --git a/Timeline/ClientApp/src/common/ImageCropper.tsx b/Timeline/ClientApp/src/common/ImageCropper.tsx
new file mode 100644
index 00000000..3ef35b44
--- /dev/null
+++ b/Timeline/ClientApp/src/common/ImageCropper.tsx
@@ -0,0 +1,294 @@
+import * as React from 'react';
+import clsx from 'clsx';
+
+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<ImageCropperSavedState | null>(
+ null
+ );
+ const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(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 imgElement = React.useRef<HTMLImageElement | null>(null);
+
+ const onImageRef = React.useCallback(
+ (e: HTMLImageElement | null) => {
+ imgElement.current = e;
+ if (imageElementCallback != null && e == null) {
+ imageElementCallback(null);
+ }
+ },
+ [imageElementCallback]
+ );
+
+ const onImageLoad = React.useCallback(
+ (e: React.SyntheticEvent<HTMLImageElement>) => {
+ 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 moveRatio = {
+ x: movement.x / imgElement.current!.width,
+ y: movement.y / imgElement.current!.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 moveRatio = {
+ x: movement.x / imgElement.current!.width,
+ y: movement.x / imgElement.current!.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]
+ );
+
+ // 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: 100 / imageInfo.ratio + '%',
+ paddingTop: '100%',
+ height: 0,
+ };
+ } else {
+ return {
+ width: '100%',
+ paddingTop: 100 * imageInfo.ratio + '%',
+ height: 0,
+ };
+ }
+ }
+ })();
+
+ return (
+ <div
+ className={clsx('image-cropper-container', className)}
+ style={containerStyle}
+ >
+ <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
+ <div className="image-cropper-mask-container">
+ <div
+ className="image-cropper-mask"
+ touch-action="none"
+ style={{
+ left: c.left * 100 + '%',
+ top: c.top * 100 + '%',
+ width: c.width * 100 + '%',
+ height: c.height * 100 + '%',
+ }}
+ onPointerMove={onPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="image-cropper-handler"
+ touch-action="none"
+ style={{
+ left: 'calc(' + (c.left + c.width) * 100 + '% - 15px)',
+ top: 'calc(' + (c.top + c.height) * 100 + '% - 15px)',
+ }}
+ onPointerMove={onHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+};
+
+export default ImageCropper;
+
+export function applyClipToImage(
+ image: HTMLImageElement,
+ clip: Clip,
+ mimeType: string
+): Promise<Blob> {
+ 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/common/LoadingPage.tsx b/Timeline/ClientApp/src/common/LoadingPage.tsx
new file mode 100644
index 00000000..c1bc7105
--- /dev/null
+++ b/Timeline/ClientApp/src/common/LoadingPage.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Spinner } from 'reactstrap';
+
+const LoadingPage: React.FC = () => {
+ return (
+ <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+ <Spinner style={{ height: '2.5rem', width: '2.5rem' }} color="primary" />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/Timeline/ClientApp/src/common/OperationDialog.tsx b/Timeline/ClientApp/src/common/OperationDialog.tsx
new file mode 100644
index 00000000..e7b6612c
--- /dev/null
+++ b/Timeline/ClientApp/src/common/OperationDialog.tsx
@@ -0,0 +1,380 @@
+import React, { useState, InputHTMLAttributes } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Spinner,
+ Container,
+ ModalBody,
+ Label,
+ Input,
+ FormGroup,
+ FormFeedback,
+ ModalFooter,
+ Button,
+ Modal,
+ ModalHeader,
+ FormText
+} from 'reactstrap';
+
+const DefaultProcessPrompt: React.FC = _ => {
+ return (
+ <Container className="justify-content-center align-items-center">
+ <Spinner />
+ </Container>
+ );
+};
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = props => {
+ const { t } = useTranslation();
+
+ let result = <p className="text-danger">{t('operationDialog.error')}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="text-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export type OperationInputOptionalError = undefined | null | string;
+
+export interface OperationInputErrorInfo {
+ [index: number]: OperationInputOptionalError;
+}
+
+export type OperationInputValidator<TValue> = (
+ value: TValue,
+ values: (string | boolean)[]
+) => OperationInputOptionalError | OperationInputErrorInfo;
+
+export interface OperationTextInputInfo {
+ type: 'text';
+ password?: boolean;
+ label?: string;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ 'type' | 'value' | 'onChange'
+ >;
+ helperText?: string;
+ validator?: OperationInputValidator<string>;
+}
+
+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<unknown>;
+ 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<OperationDialogProps> = props => {
+ const inputScheme = props.inputScheme ?? [];
+
+ const { t } = useTranslation();
+
+ type Step = 'input' | 'process' | OperationResult;
+ const [step, setStep] = useState<Step>('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 Error('Unknown input scheme.');
+ }
+ })
+ );
+ const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+
+ 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 => {
+ setStep({
+ type: 'success',
+ data: d
+ });
+ },
+ e => {
+ setStep({
+ type: 'failure',
+ data: e
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step === 'input') {
+ let inputPrompt =
+ typeof props.inputPrompt === 'function'
+ ? props.inputPrompt()
+ : props.inputPrompt;
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ 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) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (newInputError as any)[index] = error;
+ }
+ }
+ 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 = (
+ <>
+ <ModalBody>
+ {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 (
+ <FormGroup key={index}>
+ {item.label && <Label>{t(item.label)}</Label>}
+ <Input
+ type={item.password === true ? 'password' : 'text'}
+ value={value as string}
+ onChange={e => {
+ 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 && <FormFeedback>{error}</FormFeedback>}
+ {item.helperText && <FormText>{t(item.helperText)}</FormText>}
+ </FormGroup>
+ );
+ } else if (item.type === 'bool') {
+ return (
+ <FormGroup check key={index}>
+ <Input
+ type="checkbox"
+ value={value as string}
+ onChange={e => {
+ updateValue(
+ index,
+ (e.target as HTMLInputElement).checked
+ );
+ }}
+ />
+ <Label check>{t(item.label)}</Label>
+ </FormGroup>
+ );
+ } else if (item.type === 'select') {
+ return (
+ <FormGroup key={index}>
+ <Label>{t(item.label)}</Label>
+ <Input
+ type="select"
+ value={value as string}
+ onChange={event => {
+ updateValue(index, event.target.value);
+ }}
+ >
+ {item.options.map((option, i) => {
+ return (
+ <option value={option.value} key={i}>
+ {option.icon}
+ {t(option.label)}
+ </option>
+ );
+ })}
+ </Input>
+ </FormGroup>
+ );
+ }
+ })}
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={close}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button
+ color="primary"
+ disabled={testErrorInfo(inputError)}
+ onClick={() => {
+ if (validateAll()) {
+ onConfirm();
+ }
+ }}
+ >
+ {t('operationDialog.confirm')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (step === 'process') {
+ body = (
+ <ModalBody>
+ {props.processPrompt?.() ?? <DefaultProcessPrompt />}
+ </ModalBody>
+ );
+ } 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 = <p className="text-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === 'string')
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <ModalBody>{content}</ModalBody>
+ <ModalFooter>
+ <Button color="primary" onClick={close}>
+ {t('operationDialog.ok')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+
+ const title = typeof props.title === 'string' ? t(props.title) : props.title;
+
+ return (
+ <Modal isOpen={props.open} toggle={close}>
+ <ModalHeader
+ className={
+ props.titleColor != null
+ ? 'text-' +
+ (props.titleColor === 'create'
+ ? 'success'
+ : props.titleColor === 'dangerous'
+ ? 'danger'
+ : props.titleColor)
+ : undefined
+ }
+ >
+ {title}
+ </ModalHeader>
+ {body}
+ </Modal>
+ );
+};
+
+export default OperationDialog;
diff --git a/Timeline/ClientApp/src/common/SearchInput.tsx b/Timeline/ClientApp/src/common/SearchInput.tsx
new file mode 100644
index 00000000..46fb00d1
--- /dev/null
+++ b/Timeline/ClientApp/src/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<SearchInputProps> = (props) => {
+ const { onChange, onButtonClick } = props;
+
+ const { t } = useTranslation();
+
+ const onInputChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>): void => {
+ onChange(event.currentTarget.value);
+ },
+ [onChange]
+ );
+
+ const onInputKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>): void => {
+ if (event.key === 'Enter') {
+ onButtonClick();
+ }
+ },
+ [onButtonClick]
+ );
+
+ return (
+ <div className={clsx('form-inline my-2', props.className)}>
+ <Input
+ className="mr-sm-2"
+ value={props.value}
+ onChange={onInputChange}
+ onKeyPress={onInputKeyPress}
+ placeholder={props.placeholder}
+ />
+ <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
+ {props.additionalButton}
+ </div>
+ <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
+ {props.loading ? (
+ <Spinner />
+ ) : (
+ <Button outline color="primary" onClick={props.onButtonClick}>
+ {props.buttonText ?? t('search')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default SearchInput;
diff --git a/Timeline/ClientApp/src/common/TimelineLogo.tsx b/Timeline/ClientApp/src/common/TimelineLogo.tsx
new file mode 100644
index 00000000..1ec62021
--- /dev/null
+++ b/Timeline/ClientApp/src/common/TimelineLogo.tsx
@@ -0,0 +1,26 @@
+import React, { SVGAttributes } from 'react';
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = props => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? 'currentcolor';
+ return (
+ <svg
+ className={props.className}
+ viewBox="0 0 100 100"
+ fill="none"
+ strokeWidth="12"
+ stroke={coercedColor}
+ {...forwardProps}
+ >
+ <line x1="50" y1="0" x2="50" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" y1="75" x2="50" y2="100" />
+ </svg>
+ );
+};
+
+export default TimelineLogo;
diff --git a/Timeline/ClientApp/src/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/common/UserTimelineLogo.tsx
new file mode 100644
index 00000000..3b721985
--- /dev/null
+++ b/Timeline/ClientApp/src/common/UserTimelineLogo.tsx
@@ -0,0 +1,26 @@
+import React, { SVGAttributes } from 'react';
+
+export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const UserTimelineLogo: React.FC<UserTimelineLogoProps> = props => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? 'currentcolor';
+
+ return (
+ <svg viewBox="0 0 100 100" {...forwardProps}>
+ <g fill="none" stroke={coercedColor} strokeWidth="12">
+ <line x1="50" x2="50" y1="0" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" x2="50" y1="75" y2="100" />
+ </g>
+ <g fill={color}>
+ <circle cx="85" cy="75" r="10" />
+ <path d="m70,100c0,0 15,-30 30,0.25" />
+ </g>
+ </svg>
+ );
+};
+
+export default UserTimelineLogo;
diff --git a/Timeline/ClientApp/src/common/alert-service.ts b/Timeline/ClientApp/src/common/alert-service.ts
new file mode 100644
index 00000000..305752eb
--- /dev/null
+++ b/Timeline/ClientApp/src/common/alert-service.ts
@@ -0,0 +1,59 @@
+import pull from 'lodash/pull';
+
+export interface AlertInfo {
+ type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
+ message: string;
+}
+
+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/common/alert.scss b/Timeline/ClientApp/src/common/alert.scss
new file mode 100644
index 00000000..09877727
--- /dev/null
+++ b/Timeline/ClientApp/src/common/alert.scss
@@ -0,0 +1,20 @@
+.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/common/common.scss b/Timeline/ClientApp/src/common/common.scss
new file mode 100644
index 00000000..5998c086
--- /dev/null
+++ b/Timeline/ClientApp/src/common/common.scss
@@ -0,0 +1,38 @@
+.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/config.ts b/Timeline/ClientApp/src/config.ts
new file mode 100644
index 00000000..2f088a10
--- /dev/null
+++ b/Timeline/ClientApp/src/config.ts
@@ -0,0 +1,12 @@
+export const apiBaseUrl = (function () {
+ if (process.env.TIMELINE_USEDEVAPI) {
+ console.log('process.env.TIMELINE_USEDEVAPI is set, use dev api server!');
+ return 'http://localhost:5000';
+ } else if (process.env.NODE_ENV === 'production') {
+ console.log('Production mode!');
+ return 'https://api.crupest.xyz';
+ } else {
+ console.log('Development mode!');
+ return 'http://localhost:5000';
+ }
+})();
diff --git a/Timeline/ClientApp/src/data/base64.ts b/Timeline/ClientApp/src/data/base64.ts
new file mode 100644
index 00000000..6d846527
--- /dev/null
+++ b/Timeline/ClientApp/src/data/base64.ts
@@ -0,0 +1,9 @@
+export function base64(blob: Blob): Promise<string> {
+ return new Promise<string>(resolve => {
+ const reader = new FileReader();
+ reader.onload = function() {
+ resolve((reader.result as string).replace(/^data:.+;base64,/, ''));
+ };
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/Timeline/ClientApp/src/data/common.ts b/Timeline/ClientApp/src/data/common.ts
new file mode 100644
index 00000000..61db8bd2
--- /dev/null
+++ b/Timeline/ClientApp/src/data/common.ts
@@ -0,0 +1,20 @@
+import { AxiosError } from 'axios';
+
+export function extractStatusCode(error: AxiosError): number | null {
+ const code = error.response && error.response.status;
+ if (typeof code === 'number') {
+ return code;
+ } else {
+ return null;
+ }
+}
+
+export function extractErrorCode(error: AxiosError): number | null {
+ const code =
+ error.response && error.response.data && error.response.data.code;
+ if (typeof code === 'number') {
+ return code;
+ } else {
+ return null;
+ }
+}
diff --git a/Timeline/ClientApp/src/data/timeline.ts b/Timeline/ClientApp/src/data/timeline.ts
new file mode 100644
index 00000000..bc5e1658
--- /dev/null
+++ b/Timeline/ClientApp/src/data/timeline.ts
@@ -0,0 +1,344 @@
+import axios from 'axios';
+import XRegExp from 'xregexp';
+
+import { base64 } from './base64';
+import { apiBaseUrl } from '../config';
+import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user';
+
+export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const;
+
+export type TimelineVisibility = typeof kTimelineVisibilities[number];
+
+export const timelineVisibilityTooltipTranslationMap: Record<
+ TimelineVisibility,
+ string
+> = {
+ Public: 'timeline.visibilityTooltip.public',
+ Register: 'timeline.visibilityTooltip.register',
+ Private: 'timeline.visibilityTooltip.private',
+};
+
+export interface TimelineInfo {
+ name: string;
+ description: string;
+ owner: User;
+ visibility: TimelineVisibility;
+ members: User[];
+ _links: {
+ posts: string;
+ };
+}
+
+export interface TimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface TimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+export type TimelinePostContent =
+ | TimelinePostTextContent
+ | TimelinePostImageContent;
+
+export interface TimelinePostInfo {
+ id: number;
+ content: TimelinePostContent;
+ time: Date;
+ author: User;
+}
+
+export interface CreatePostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface CreatePostRequestImageContent {
+ type: 'image';
+ data: Blob;
+}
+
+export type CreatePostRequestContent =
+ | CreatePostRequestTextContent
+ | CreatePostRequestImageContent;
+
+export interface CreatePostRequest {
+ content: CreatePostRequestContent;
+ time?: Date;
+}
+
+// TODO: Remove in the future
+export interface TimelineChangePropertyRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export interface PersonalTimelineChangePropertyRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export interface OrdinaryTimelineChangePropertyRequest {
+ // not supported by server now
+ // name?: string;
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+//-------------------- begin: internal model --------------------
+
+interface RawTimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawTimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+type RawTimelinePostContent =
+ | RawTimelinePostTextContent
+ | RawTimelinePostImageContent;
+
+interface RawTimelinePostInfo {
+ id: number;
+ content: RawTimelinePostContent;
+ time: string;
+ author: User;
+}
+
+interface RawCreatePostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawCreatePostRequestImageContent {
+ type: 'text';
+ data: string;
+}
+
+type RawCreatePostRequestContent =
+ | RawCreatePostRequestTextContent
+ | RawCreatePostRequestImageContent;
+
+interface RawCreatePostRequest {
+ content: RawCreatePostRequestContent;
+ time?: string;
+}
+
+//-------------------- end: internal model --------------------
+
+function processRawTimelinePostInfo(
+ raw: RawTimelinePostInfo,
+ token?: string
+): TimelinePostInfo {
+ return {
+ ...raw,
+ content: (() => {
+ if (raw.content.type === 'image' && token != null) {
+ return {
+ ...raw.content,
+ url: raw.content.url + '?token=' + token,
+ };
+ }
+ return raw.content;
+ })(),
+ time: new Date(raw.time),
+ };
+}
+
+type TimelineUrlResolver = (name: string) => string;
+
+export class TimelineServiceTemplate<
+ TTimeline extends TimelineInfo,
+ TChangePropertyRequest
+> {
+ private checkUser(): UserWithToken {
+ const user = getCurrentUser();
+ if (user == null) {
+ throw new Error('You must login to perform the operation.');
+ }
+ return user;
+ }
+
+ constructor(private urlResolver: TimelineUrlResolver) {}
+
+ changeProperty(
+ name: string,
+ req: TChangePropertyRequest
+ ): Promise<TTimeline> {
+ const user = this.checkUser();
+
+ return axios
+ .patch<TTimeline>(`${this.urlResolver(name)}?token=${user.token}`, req)
+ .then((res) => res.data);
+ }
+
+ fetch(name: string): Promise<TTimeline> {
+ return axios
+ .get<TTimeline>(`${this.urlResolver(name)}`)
+ .then((res) => res.data);
+ }
+
+ fetchPosts(name: string): Promise<TimelinePostInfo[]> {
+ const token = getCurrentUser()?.token;
+ return axios
+ .get<RawTimelinePostInfo[]>(
+ token == null
+ ? `${this.urlResolver(name)}/posts`
+ : `${this.urlResolver(name)}/posts?token=${token}`
+ )
+ .then((res) => res.data.map((p) => processRawTimelinePostInfo(p, token)));
+ }
+
+ createPost(
+ name: string,
+ request: CreatePostRequest
+ ): Promise<TimelinePostInfo> {
+ const user = this.checkUser();
+
+ const rawReq: Promise<RawCreatePostRequest> = new Promise<
+ RawCreatePostRequestContent
+ >((resolve) => {
+ if (request.content.type === 'image') {
+ base64(request.content.data).then((d) =>
+ resolve({
+ ...request.content,
+ data: d,
+ } as RawCreatePostRequestImageContent)
+ );
+ } else {
+ resolve(request.content);
+ }
+ }).then((content) => {
+ const rawReq: RawCreatePostRequest = {
+ content,
+ };
+ if (request.time != null) {
+ rawReq.time = request.time.toISOString();
+ }
+ return rawReq;
+ });
+
+ return rawReq
+ .then((req) =>
+ axios.post<RawTimelinePostInfo>(
+ `${this.urlResolver(name)}/posts?token=${user.token}`,
+ req
+ )
+ )
+ .then((res) => processRawTimelinePostInfo(res.data, user.token));
+ }
+
+ deletePost(name: string, id: number): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.delete(
+ `${this.urlResolver(name)}/posts/${id}?token=${user.token}`
+ );
+ }
+
+ addMember(name: string, username: string): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.put(
+ `${this.urlResolver(name)}/members/${username}?token=${user.token}`
+ );
+ }
+
+ removeMember(name: string, username: string): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.delete(
+ `${this.urlResolver(name)}/members/${username}?token=${user.token}`
+ );
+ }
+
+ isMemberOf(username: string, timeline: TTimeline): boolean {
+ return timeline.members.findIndex((m) => m.username == username) >= 0;
+ }
+
+ hasReadPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): 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: TTimeline
+ ): 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: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return user != null && user.username == timeline.owner.username;
+ }
+
+ hasModifyPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline,
+ post: TimelinePostInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ user.username === post.author.username)
+ );
+ }
+}
+
+export type PersonalTimelineService = TimelineServiceTemplate<
+ TimelineInfo,
+ PersonalTimelineChangePropertyRequest
+>;
+
+export const personalTimelineService: PersonalTimelineService = new TimelineServiceTemplate<
+ TimelineInfo,
+ PersonalTimelineChangePropertyRequest
+>((name) => `${apiBaseUrl}/timelines/@${name}`);
+
+export type OrdinaryTimelineService = TimelineServiceTemplate<
+ TimelineInfo,
+ OrdinaryTimelineChangePropertyRequest
+>;
+
+export const ordinaryTimelineService: OrdinaryTimelineService = new TimelineServiceTemplate<
+ TimelineInfo,
+ TimelineChangePropertyRequest
+>((name) => `${apiBaseUrl}/timelines/${name}`);
+
+const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u');
+
+export function validateTimelineName(name: string): boolean {
+ return timelineNameReg.test(name);
+}
diff --git a/Timeline/ClientApp/src/data/user.ts b/Timeline/ClientApp/src/data/user.ts
new file mode 100644
index 00000000..755aecf6
--- /dev/null
+++ b/Timeline/ClientApp/src/data/user.ts
@@ -0,0 +1,215 @@
+import axios, { AxiosError } from 'axios';
+import { useState, useEffect } from 'react';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { apiBaseUrl } from '../config';
+import { pushAlert } from '../common/alert-service';
+import { i18nPromise } from '../i18n';
+
+export interface UserAuthInfo {
+ username: string;
+ administrator: boolean;
+}
+
+export interface User {
+ username: string;
+ administrator: boolean;
+ nickname: string;
+ _links: {
+ avatar: string;
+ timeline: string;
+ };
+}
+
+export interface UserWithToken extends User {
+ token: string;
+}
+
+interface CreateTokenRequest {
+ username: string;
+ password: string;
+}
+
+interface CreateTokenResponse {
+ token: string;
+ user: User;
+}
+
+interface VerifyTokenRequest {
+ token: string;
+}
+
+interface VerifyTokenResponse {
+ user: User;
+}
+
+export type LoginCredentials = CreateTokenRequest;
+
+const userSubject = new BehaviorSubject<UserWithToken | null | undefined>(
+ undefined
+);
+
+export const user$: Observable<UserWithToken | null | undefined> = userSubject;
+
+export function getCurrentUser(): UserWithToken | null | undefined {
+ return userSubject.value;
+}
+
+const kCreateTokenUrl = '/token/create';
+const kVerifyTokenUrl = '/token/verify';
+const createTokenUrl = apiBaseUrl + kCreateTokenUrl;
+const verifyTokenUrl = apiBaseUrl + kVerifyTokenUrl;
+
+function verifyToken(token: string): Promise<User> {
+ return axios
+ .post<VerifyTokenResponse>(verifyTokenUrl, {
+ token: token
+ } as VerifyTokenRequest)
+ .then(res => res.data.user);
+}
+
+const TOKEN_STORAGE_KEY = 'token';
+
+export function checkUserLoginState(): Promise<UserWithToken | null> {
+ if (getCurrentUser() !== undefined)
+ throw new Error("Already checked user. Can't check twice.");
+
+ const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY);
+ if (savedToken) {
+ return verifyToken(savedToken)
+ .then(
+ u => {
+ const user: UserWithToken = {
+ ...u,
+ token: savedToken
+ };
+ i18nPromise.then(t => {
+ pushAlert({
+ type: 'success',
+ message: t('user.welcomeBack')
+ });
+ });
+ return user;
+ },
+ (e: AxiosError) => {
+ if (e.response != null) {
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+ i18nPromise.then(t => {
+ pushAlert({
+ type: 'danger',
+ message: t('user.verifyTokenFailed')
+ });
+ });
+ } else {
+ i18nPromise.then(t => {
+ pushAlert({
+ type: 'danger',
+ message: t('user.verifyTokenFailedNetwork')
+ });
+ });
+ }
+
+ return null;
+ }
+ )
+ .then(u => {
+ userSubject.next(u);
+ return u;
+ });
+ }
+ userSubject.next(null);
+ return Promise.resolve(null);
+}
+
+export class BadCredentialError {
+ constructor(public innerError: Error) {}
+
+ message = 'login.badCredential';
+}
+
+export function userLogin(
+ credentials: LoginCredentials,
+ rememberMe: boolean
+): Promise<UserWithToken> {
+ if (getCurrentUser()) {
+ throw new Error('Already login.');
+ }
+ return axios
+ .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 })
+ .catch(e => {
+ const error = e as AxiosError;
+ if (error.response?.data?.code === 11010101) {
+ throw new BadCredentialError(e);
+ }
+ throw e;
+ })
+ .then(res => {
+ const body = res.data;
+ const token = body.token;
+ if (rememberMe) {
+ window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
+ }
+ const user = {
+ ...body.user,
+ token
+ };
+ userSubject.next(user);
+ return user;
+ });
+}
+
+export function userLogout(): void {
+ if (getCurrentUser() === undefined) {
+ throw new Error('Please check user first.');
+ }
+ if (getCurrentUser() === null) {
+ throw new Error('No login.');
+ }
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+ userSubject.next(null);
+}
+
+export function useOptionalUser(): UserWithToken | null | undefined {
+ const [user, setUser] = useState<UserWithToken | null | undefined>(
+ userSubject.value
+ );
+ useEffect(() => {
+ const sub = user$.subscribe(u => setUser(u));
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUser(): UserWithToken | null {
+ const [user, setUser] = useState<UserWithToken | null>(() => {
+ const initUser = userSubject.value;
+ if (initUser === undefined) {
+ throw new Error(
+ "This is a logic error in user module. Current user can't be undefined in useUser."
+ );
+ }
+ return initUser;
+ });
+ useEffect(() => {
+ const sub = user$.subscribe(u => {
+ if (u === undefined) {
+ throw new Error(
+ "This is a logic error in user module. User emitted can't be undefined later."
+ );
+ }
+ setUser(u);
+ });
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function fetchUser(username: string): Promise<User> {
+ return axios
+ .get<User>(`${apiBaseUrl}/users/${username}`)
+ .then(res => res.data);
+}
diff --git a/Timeline/ClientApp/src/helper.ts b/Timeline/ClientApp/src/helper.ts
new file mode 100644
index 00000000..904dcbbe
--- /dev/null
+++ b/Timeline/ClientApp/src/helper.ts
@@ -0,0 +1,35 @@
+//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter
+export function updateQueryString(
+ key: string,
+ value?: string | null,
+ url?: string
+): string {
+ if (!url) url = window.location.href;
+ const re = new RegExp('([?&])' + key + '=.*?(&|#|$)(.*)', 'gi');
+ let hash;
+
+ if (re.test(url)) {
+ if (typeof value !== 'undefined' && value !== null) {
+ return url.replace(re, '$1' + key + '=' + value + '$2$3');
+ } else {
+ hash = url.split('#');
+ url = hash[0].replace(re, '$1$3').replace(/(&|\?)$/, '');
+ if (typeof hash[1] !== 'undefined' && hash[1] !== null) {
+ url += '#' + hash[1];
+ }
+ return url;
+ }
+ } else {
+ if (typeof value !== 'undefined' && value !== null) {
+ const separator = url.includes('?') ? '&' : '?';
+ hash = url.split('#');
+ url = hash[0] + separator + key + '=' + value;
+ if (typeof hash[1] !== 'undefined' && hash[1] !== null) {
+ url += '#' + hash[1];
+ }
+ return url;
+ } else {
+ return url;
+ }
+ }
+}
diff --git a/Timeline/ClientApp/src/home/Home.tsx b/Timeline/ClientApp/src/home/Home.tsx
new file mode 100644
index 00000000..25398a0a
--- /dev/null
+++ b/Timeline/ClientApp/src/home/Home.tsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import { useHistory } from 'react-router';
+import { Row, Container, Button, Col } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import axios from 'axios';
+
+import { apiBaseUrl } from '../config';
+import { useUser } from '../data/user';
+import { TimelineInfo } from '../data/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<string>('');
+
+ 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);
+ axios
+ .get<TimelineInfo[]>(`${apiBaseUrl}/timelines?visibility=public`)
+ .then((res) => {
+ if (subscribe) {
+ setPublicTimelines(res.data);
+ }
+ });
+ } else {
+ setPublicTimelines(undefined);
+ axios
+ .get<TimelineInfo[]>(
+ `${apiBaseUrl}/timelines?relate=${user.username}&relateType=own`
+ )
+ .then((res) => {
+ if (subscribe) {
+ setOwnTimelines(res.data);
+ }
+ });
+ axios
+ .get<TimelineInfo[]>(
+ `${apiBaseUrl}/timelines?relate=${user.username}&relateType=join`
+ )
+ .then((res) => {
+ if (subscribe) {
+ setJoinTimelines(res.data);
+ }
+ });
+ }
+ 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 (
+ <>
+ <AppBar />
+ <Container fluid style={{ marginTop: '56px' }}>
+ <Row>
+ <Col>
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={goto}
+ buttonText={t('home.go')}
+ placeholder="@crupest"
+ additionalButton={
+ user != null && (
+ <Button color="success" outline onClick={openCreateDialog}>
+ {t('home.createButton')}
+ </Button>
+ )
+ }
+ />
+ </Col>
+ </Row>
+ {(() => {
+ if (user == null) {
+ return (
+ <TimelineBoardAreaWithoutUser publicTimelines={publicTimelines} />
+ );
+ } else {
+ return (
+ <TimelineBoardAreaWithUser
+ ownTimelines={ownTimelines}
+ joinTimelines={joinTimelines}
+ />
+ );
+ }
+ })()}
+ </Container>
+ <footer className="text-right">
+ <a
+ className="mx-3 text-muted"
+ href="http://beian.miit.gov.cn/"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <small>鄂ICP备18030913号-1</small>
+ </a>
+ <a
+ className="mx-3 text-muted"
+ href="http://www.beian.gov.cn/"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <small className="white-space-no-wrap">公安备案 42112102000124</small>
+ </a>
+ </footer>
+ {dialog === 'create' && <TimelineCreateDialog open close={closeDialog} />}
+ </>
+ );
+};
+
+export default Home;
diff --git a/Timeline/ClientApp/src/home/TimelineBoard.tsx b/Timeline/ClientApp/src/home/TimelineBoard.tsx
new file mode 100644
index 00000000..38a5fcf8
--- /dev/null
+++ b/Timeline/ClientApp/src/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<TimelineBoardProps> = props => {
+ const { title, timelines, className } = props;
+
+ return (
+ <div className={clsx('timeline-board', className)}>
+ {title != null && <h3 className="text-center">{title}</h3>}
+ {(() => {
+ if (timelines == null) {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Spinner color="primary" />
+ </div>
+ );
+ } else {
+ return timelines.map(timeline => {
+ const { name } = timeline;
+ const isPersonal = name.startsWith('@');
+ const url = isPersonal
+ ? `/users/${timeline.owner.username}`
+ : `/timelines/${name}`;
+ return (
+ <div key={name} className="timeline-item">
+ {isPersonal ? (
+ <UserTimelineLogo className="icon" />
+ ) : (
+ <TimelineLogo className="icon" />
+ )}
+ <Link to={url}>{name}</Link>
+ </div>
+ );
+ });
+ }
+ })()}
+ </div>
+ );
+};
+
+export default TimelineBoard;
diff --git a/Timeline/ClientApp/src/home/TimelineBoardAreaWithUser.tsx b/Timeline/ClientApp/src/home/TimelineBoardAreaWithUser.tsx
new file mode 100644
index 00000000..a8603b9e
--- /dev/null
+++ b/Timeline/ClientApp/src/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<TimelineBoardAreaWithUserProps> = (
+ props
+) => {
+ const { t } = useTranslation();
+
+ return (
+ <Row className="my-2 justify-content-center">
+ <Col sm="6" lg="5" className="py-2">
+ <TimelineBoard
+ title={t('home.ownTimeline')}
+ timelines={props.ownTimelines}
+ />
+ </Col>
+ <Col sm="6" lg="5" className="py-2">
+ <TimelineBoard
+ title={t('home.joinTimeline')}
+ timelines={props.joinTimelines}
+ />
+ </Col>
+ </Row>
+ );
+};
+
+export default TimelineBoardAreaWithUser;
diff --git a/Timeline/ClientApp/src/home/TimelineBoardAreaWithoutUser.tsx b/Timeline/ClientApp/src/home/TimelineBoardAreaWithoutUser.tsx
new file mode 100644
index 00000000..dc05ff09
--- /dev/null
+++ b/Timeline/ClientApp/src/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<TimelineBoardAreaWithoutUserProps> = (
+ props
+) => {
+ const { publicTimelines } = props;
+
+ return (
+ <Row className="my-2 justify-content-center">
+ <Col sm="8" lg="6">
+ <TimelineBoard timelines={publicTimelines} />
+ </Col>
+ </Row>
+ );
+};
+
+export default TimelineBoardAreaWithoutUser;
diff --git a/Timeline/ClientApp/src/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/home/TimelineCreateDialog.tsx
new file mode 100644
index 00000000..30d29bc8
--- /dev/null
+++ b/Timeline/ClientApp/src/home/TimelineCreateDialog.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { useHistory } from 'react-router';
+import axios from 'axios';
+
+import { apiBaseUrl } from '../config';
+import { useUser } from '../data/user';
+import { validateTimelineName } from '../data/timeline';
+
+import OperationDialog from '../common/OperationDialog';
+
+interface TimelineCreateDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = props => {
+ const history = useHistory();
+ const user = useUser()!;
+
+ let nameSaved: string;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ titleColor="success"
+ title="home.createDialog.title"
+ inputScheme={[
+ {
+ type: 'text',
+ label: 'home.createDialog.name',
+ helperText: 'home.createDialog.nameFormat',
+ validator: name => {
+ 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 axios.post(`${apiBaseUrl}/timelines?token=${user.token}`, {
+ name
+ });
+ }}
+ onSuccessAndClose={() => {
+ history.push(`timelines/${nameSaved}`);
+ }}
+ failurePrompt={e => (e as object).toString()}
+ />
+ );
+};
+
+export default TimelineCreateDialog;
diff --git a/Timeline/ClientApp/src/home/home.scss b/Timeline/ClientApp/src/home/home.scss
new file mode 100644
index 00000000..8e40e597
--- /dev/null
+++ b/Timeline/ClientApp/src/home/home.scss
@@ -0,0 +1,21 @@
+.timeline-item-icon {
+ width: 1em;
+ height: 1em;
+}
+
+.timeline-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/i18n.ts b/Timeline/ClientApp/src/i18n.ts
new file mode 100644
index 00000000..9f3a0801
--- /dev/null
+++ b/Timeline/ClientApp/src/i18n.ts
@@ -0,0 +1,80 @@
+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 {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ callback(new Error(message), false as any);
+ }
+
+ 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') {
+ 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: 'en',
+ 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'],
+ () => {
+ i18n.reloadResources();
+ }
+ );
+}
+
+export default i18n;
diff --git a/Timeline/ClientApp/src/index.scss b/Timeline/ClientApp/src/index.scss
new file mode 100644
index 00000000..9f3d3444
--- /dev/null
+++ b/Timeline/ClientApp/src/index.scss
@@ -0,0 +1,89 @@
+@import '~bootstrap/scss/bootstrap';
+$fa-font-path: '~@fortawesome/fontawesome-free/webfonts';
+@import '~@fortawesome/fontawesome-free/scss/fontawesome';
+@import '~@fortawesome/fontawesome-free/scss/solid';
+@import '~@fortawesome/fontawesome-free/scss/brands';
+
+@import './common/common.scss';
+@import './common/alert.scss';
+@import './home/home.scss';
+@import './about/about.scss';
+@import './timeline/timeline.scss';
+@import './timeline/timeline-ui.scss';
+@import './user/user-page.scss';
+
+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: 20px;
+}
+
+.large-icon {
+ font-size: 26px;
+}
+
+textarea {
+ resize: none;
+}
+
+.white-space-no-wrap {
+ white-space: nowrap;
+}
+
+.cru-card {
+ @extend .shadow;
+ @extend .border;
+ @extend .border-primary;
+ @extend .rounded;
+ @extend .bg-light;
+}
diff --git a/Timeline/ClientApp/src/index.tsx b/Timeline/ClientApp/src/index.tsx
new file mode 100644
index 00000000..0c66dda0
--- /dev/null
+++ b/Timeline/ClientApp/src/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.scss';
+
+import './i18n';
+
+import App from './App';
+
+ReactDOM.render(<App />, document.getElementById('app'));
diff --git a/Timeline/ClientApp/src/locales/en/translation.ts b/Timeline/ClientApp/src/locales/en/translation.ts
new file mode 100644
index 00000000..86fd6d52
--- /dev/null
+++ b/Timeline/ClientApp/src/locales/en/translation.ts
@@ -0,0 +1,172 @@
+import TranslationResource from '../scheme';
+
+const translation: TranslationResource = {
+ welcome: 'Welcome!',
+ search: 'Search',
+ 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}}</1>, please input its name below and click confirm button.',
+ notMatch: 'Name does not match.',
+ },
+ },
+ 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.',
+ },
+ },
+ 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</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, 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.',
+ frontendRepo: 'Frontend GitHub Repo',
+ backendRepo: 'Backend 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/locales/scheme.ts b/Timeline/ClientApp/src/locales/scheme.ts
new file mode 100644
index 00000000..6517e7b7
--- /dev/null
+++ b/Timeline/ClientApp/src/locales/scheme.ts
@@ -0,0 +1,158 @@
+export default interface TranslationResource {
+ welcome: string;
+ search: string;
+ chooseImage: string;
+ loadImageError: 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;
+ };
+ };
+ 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;
+ };
+ };
+ about: {
+ author: {
+ title: string;
+ fullname: string;
+ nickname: string;
+ introduction: string;
+ introductionContent: string;
+ links: string;
+ };
+ site: {
+ title: string;
+ content: string;
+ frontendRepo: string;
+ backendRepo: string;
+ };
+ credits: {
+ title: string;
+ content: string;
+ frontend: string;
+ backend: string;
+ };
+ };
+ admin: {
+ title: string;
+ };
+}
diff --git a/Timeline/ClientApp/src/locales/zh/translation.ts b/Timeline/ClientApp/src/locales/zh/translation.ts
new file mode 100644
index 00000000..259cfd23
--- /dev/null
+++ b/Timeline/ClientApp/src/locales/zh/translation.ts
@@ -0,0 +1,169 @@
+import TranslationResource from '../scheme';
+
+const translation: TranslationResource = {
+ welcome: '欢迎!',
+ search: '搜索',
+ 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}}</1>,请在下面输入它的名字并点击确认。',
+ notMatch: '名字不匹配',
+ },
+ },
+ 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: '两次输入的密码不一致',
+ },
+ },
+ about: {
+ author: {
+ title: '网站作者',
+ fullname: '姓名:',
+ nickname: '昵称:',
+ introduction: '简介:',
+ introductionContent: '一个基于巧合编程的代码爱好者。',
+ links: '链接:',
+ },
+ site: {
+ title: '网站信息',
+ content:
+ '这个网站的名字叫 <1>Timeline</1>,是一个以<3>时间线</3>为核心概念的 Web App . 它的前端和后端都是由<5>我</5>开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。',
+ frontendRepo: '前端 GitHub 仓库',
+ backendRepo: '后端 GitHub 仓库',
+ },
+ credits: {
+ title: '鸣谢',
+ content:
+ 'Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。',
+ frontend: '前端:',
+ backend: '后端:',
+ },
+ },
+ admin: {
+ title: '管理',
+ },
+};
+
+export default translation;
diff --git a/Timeline/ClientApp/src/settings/Settings.tsx b/Timeline/ClientApp/src/settings/Settings.tsx
new file mode 100644
index 00000000..b3996fdc
--- /dev/null
+++ b/Timeline/ClientApp/src/settings/Settings.tsx
@@ -0,0 +1,200 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import axios, { AxiosError } from 'axios';
+import { Container, Row, Col, Input } from 'reactstrap';
+
+import { apiBaseUrl } from '../config';
+
+import { useUser, userLogout } from '../data/user';
+
+import AppBar from '../common/AppBar';
+import OperationDialog, {
+ OperationInputErrorInfo,
+} from '../common/OperationDialog';
+
+interface ChangePasswordDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+async function changePassword(
+ oldPassword: string,
+ newPassword: string,
+ token: string
+): Promise<void> {
+ const url = `${apiBaseUrl}/userop/changepassword?token=${token}`;
+ try {
+ await axios.post(url, {
+ oldPassword,
+ newPassword,
+ });
+ } catch (e) {
+ const error = e as AxiosError;
+ if (
+ error.response &&
+ error.response.status === 400 &&
+ error.response.data &&
+ error.response.data.message
+ ) {
+ throw error.response.data.message;
+ }
+ throw e;
+ }
+}
+
+const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
+ const user = useUser()!;
+ const history = useHistory();
+ const { t } = useTranslation();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ open={props.open}
+ title={t('settings.dialogChangePassword.title')}
+ titleColor="dangerous"
+ inputPrompt={t('settings.dialogChangePassword.prompt')}
+ inputScheme={[
+ {
+ type: 'text',
+ label: t('settings.dialogChangePassword.inputOldPassword'),
+ password: true,
+ validator: (v) =>
+ 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 changePassword(
+ oldPassword as string,
+ newPassword as string,
+ user.token
+ );
+ userLogout();
+ setRedirect(true);
+ }}
+ close={() => {
+ props.close();
+ if (redirect) {
+ history.push('/login');
+ }
+ }}
+ />
+ );
+};
+
+const Settings: React.FC = (_) => {
+ const { i18n, t } = useTranslation();
+ const user = useUser();
+ const history = useHistory();
+
+ const [dialog, setDialog] = useState<null | 'changepassword'>(null);
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <>
+ <AppBar />
+ <Container fluid style={{ marginTop: '56px' }}>
+ {user ? (
+ <>
+ <Row className="border-bottom p-3">
+ <Col className="col-12">
+ <h5
+ onClick={() => {
+ history.push(`/users/${user.username}`);
+ }}
+ >
+ {t('settings.gotoSelf')}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3">
+ <Col className="col-12">
+ <h5
+ className="text-danger"
+ onClick={() => setDialog('changepassword')}
+ >
+ {t('settings.changePassword')}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3">
+ <Col className="col-12">
+ <h5
+ className="text-danger"
+ onClick={() => {
+ userLogout();
+ history.push('/');
+ }}
+ >
+ {t('settings.logout')}
+ </h5>
+ </Col>
+ </Row>
+ </>
+ ) : null}
+ <Row className="align-items-center border-bottom p-3">
+ <Col className="col-12 col-sm">
+ <h5>{t('settings.languagePrimary')}</h5>
+ <p>{t('settings.languageSecondary')}</p>
+ </Col>
+ <Col className="col-auto ml-auto">
+ <Input
+ type="select"
+ value={language}
+ onChange={(e) => {
+ i18n.changeLanguage(e.target.value);
+ }}
+ >
+ <option value="zh">中文</option>
+ <option value="en">English</option>
+ </Input>
+ </Col>
+ </Row>
+ {dialog === 'changepassword' ? (
+ <ChangePasswordDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ ) : null}
+ </Container>
+ </>
+ );
+};
+
+export default Settings;
diff --git a/Timeline/ClientApp/src/timeline/Timeline.tsx b/Timeline/ClientApp/src/timeline/Timeline.tsx
new file mode 100644
index 00000000..defca4c3
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/Timeline.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import clsx from 'clsx';
+import { Container } from 'reactstrap';
+
+import { TimelinePostInfo } from '../data/timeline';
+import { useUser } from '../data/user';
+import { useAvatarVersion } from '../user/api';
+
+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;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const user = useUser();
+ const avatarVersion = useAvatarVersion();
+
+ const { posts, onDelete } = 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 (
+ <div className={clsx('pr-2', props.className)}>
+ <Container fluid className="d-flex flex-column">
+ {(() => {
+ const length = posts.length;
+ return posts.map((post, i) => {
+ const av: number | undefined =
+ user != null && user.username === post.author.username
+ ? avatarVersion
+ : undefined;
+
+ return (
+ <TimelineItem
+ post={post}
+ key={post.id}
+ current={length - 1 === i}
+ showDeleteButton={indexShowDeleteButton === i}
+ toggleMore={onToggleDelete[i]}
+ onDelete={onItemDelete[i]}
+ onClick={onItemClick}
+ avatarVersion={av}
+ />
+ );
+ });
+ })()}
+ </Container>
+ </div>
+ );
+};
+
+export default Timeline;
diff --git a/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..2b682a6b
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import axios from 'axios';
+import { useHistory } from 'react-router';
+import { Trans } from 'react-i18next';
+
+import { apiBaseUrl } from '../config';
+import { useUser } from '../data/user';
+import OperationDialog from '../common/OperationDialog';
+
+interface TimelineDeleteDialog {
+ open: boolean;
+ name: string;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = props => {
+ const user = useUser()!;
+ const history = useHistory();
+
+ const { name } = props;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="timeline.deleteDialog.title"
+ titleColor="danger"
+ inputPrompt={() => {
+ return (
+ <Trans i18nKey="timeline.deleteDialog.inputPrompt">
+ 0<code className="mx-2">{{ name }}</code>2
+ </Trans>
+ );
+ }}
+ inputScheme={[
+ {
+ type: 'text',
+ validator: value => {
+ if (value !== name) {
+ return 'timeline.deleteDialog.notMatch';
+ } else {
+ return null;
+ }
+ }
+ }
+ ]}
+ onProcess={() => {
+ return axios.delete(
+ `${apiBaseUrl}/timelines/${name}?token=${user.token}`
+ );
+ }}
+ onSuccessAndClose={() => {
+ history.replace('/');
+ }}
+ />
+ );
+};
+
+export default TimelineDeleteDialog;
diff --git a/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..2ce7c378
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,112 @@
+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 {
+ timelineVisibilityTooltipTranslationMap,
+ TimelineInfo,
+} from '../data/timeline';
+import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
+
+export type OrdinaryTimelineManageItem = 'delete';
+
+export type TimelineInfoCardProps = TimelineCardComponentProps<
+ TimelineInfo,
+ OrdinaryTimelineManageItem
+>;
+
+const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+
+ const { t } = useTranslation();
+
+ const containerRef = React.useRef<HTMLDivElement>(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<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+ const onManageProperty = React.useCallback(
+ (): void => onManage!('property'),
+ [onManage]
+ );
+ const onManageDelete = React.useCallback((): void => onManage!('delete'), [
+ onManage,
+ ]);
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx('rounded border p-2 bg-light', props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <h3 className="text-primary mx-3 d-inline-block align-middle">
+ {props.timeline.name}
+ </h3>
+ <div className="d-inline-block align-middle">
+ <img
+ src={props.timeline.owner._links.avatar}
+ onLoad={notifyHeight}
+ className="avatar small rounded-circle"
+ />
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {props.onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t('timeline.manage')}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={onManageProperty}>
+ {t('timeline.manageItem.property')}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t('timeline.manageItem.member')}
+ </DropdownItem>
+ <DropdownItem divider />
+ <DropdownItem className="text-danger" onClick={onManageDelete}>
+ {t('timeline.manageItem.delete')}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t('timeline.memberButton')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default TimelineInfoCard;
diff --git a/Timeline/ClientApp/src/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/timeline/TimelineItem.tsx
new file mode 100644
index 00000000..402d51d9
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelineItem.tsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import clsx from 'clsx';
+import { Row, Col } from 'reactstrap';
+import { Link } from 'react-router-dom';
+
+import { TimelinePostInfo } from '../data/timeline';
+import { useAvatarUrlWithGivenVersion } from '../user/api';
+
+export interface TimelineItemProps {
+ post: TimelinePostInfo;
+ showDeleteButton?: boolean;
+ current?: boolean;
+ toggleMore?: () => void;
+ onDelete?: () => void;
+ onClick?: () => void;
+ avatarVersion?: number;
+}
+
+const TimelineItem: React.FC<TimelineItemProps> = (props) => {
+ const { i18n } = useTranslation();
+
+ const current = props.current === true;
+
+ const { toggleMore: toggleDelete } = props;
+
+ const avatarUrl = useAvatarUrlWithGivenVersion(
+ props.avatarVersion,
+ props.post.author._links.avatar
+ );
+
+ const onOpenMore = React.useMemo<
+ React.MouseEventHandler<HTMLElement> | undefined
+ >(() => {
+ if (toggleDelete == null) {
+ return undefined;
+ } else {
+ return (e) => {
+ toggleDelete();
+ e.stopPropagation();
+ };
+ }
+ }, [toggleDelete]);
+
+ return (
+ <Row
+ className={clsx('position-relative flex-nowrap', current && 'current')}
+ onClick={props.onClick}
+ >
+ <Col className="timeline-line-area">
+ <div className="timeline-line start"></div>
+ <div className="timeline-line-node-container">
+ <div className="timeline-line-node"></div>
+ </div>
+ <div className="timeline-line end"></div>
+ {current && <div className="timeline-line current-end" />}
+ </Col>
+ <Col className="timeline-pt-start">
+ <Row className="flex-nowrap">
+ <div className="col-auto flex-shrink-1 px-0">
+ <Row className="ml-n3 mr-0 align-items-center">
+ <span className="ml-3 text-primary white-space-no-wrap">
+ {props.post.time.toLocaleString(i18n.languages)}
+ </span>
+ <small className="text-dark ml-3">
+ {props.post.author.nickname}
+ </small>
+ </Row>
+ </div>
+ {props.toggleMore != null ? (
+ <div className="col-auto px-2 d-flex justify-content-center align-items-center">
+ <i
+ className="fas fa-chevron-circle-down text-info icon-button"
+ onClick={onOpenMore}
+ />
+ </div>
+ ) : null}
+ </Row>
+ <p className="row d-block timeline-content">
+ <Link
+ className="float-right float-sm-left mx-2"
+ to={'/users/' + props.post.author.username}
+ >
+ <img src={avatarUrl} className="avatar rounded" />
+ </Link>
+ {(() => {
+ const { content } = props.post;
+ if (content.type === 'text') {
+ return content.text;
+ } else {
+ return (
+ <img src={content.url} className="timeline-content-image" />
+ );
+ }
+ })()}
+ </p>
+ </Col>
+ {props.showDeleteButton ? (
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
+ onClick={props.toggleMore}
+ >
+ <i
+ className="fas fa-trash text-danger large-icon"
+ onClick={props.onDelete}
+ />
+ </div>
+ ) : undefined}
+ </Row>
+ );
+};
+
+export default TimelineItem;
diff --git a/Timeline/ClientApp/src/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/timeline/TimelineMember.tsx
new file mode 100644
index 00000000..eac8d417
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelineMember.tsx
@@ -0,0 +1,194 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { User } from '../data/user';
+
+import SearchInput from '../common/SearchInput';
+import {
+ Container,
+ ListGroup,
+ ListGroupItem,
+ Modal,
+ Row,
+ Col,
+ Button
+} from 'reactstrap';
+
+export interface TimelineMemberCallbacks {
+ onCheckUser: (username: string) => Promise<User | null>;
+ onAddUser: (user: User) => Promise<void>;
+ onRemoveUser: (username: string) => void;
+}
+
+export interface TimelineMemberProps {
+ members: User[];
+ edit: TimelineMemberCallbacks | null | undefined;
+}
+
+const TimelineMember: React.FC<TimelineMemberProps> = props => {
+ const { t } = useTranslation();
+
+ const [userSearchText, setUserSearchText] = useState<string>('');
+ const [userSearchState, setUserSearchState] = useState<
+ | {
+ type: 'user';
+ data: User;
+ }
+ | { type: 'error'; data: string }
+ | { type: 'loading' }
+ | { type: 'init' }
+ >({ type: 'init' });
+
+ const members = props.members;
+
+ return (
+ <Container className="px-4">
+ <ListGroup className="my-3">
+ {members.map((member, index) => (
+ <ListGroupItem key={member.username} className="container">
+ <Row>
+ <Col className="col-auto">
+ <img src={member._links.avatar} className="avatar small" />
+ </Col>
+ <Col>
+ <Row>{member.nickname}</Row>
+ <Row>
+ <small>{'@' + member.username}</small>
+ </Row>
+ </Col>
+ {(() => {
+ if (index === 0) {
+ return null;
+ }
+ const onRemove = props.edit?.onRemoveUser;
+ if (onRemove == null) {
+ return null;
+ }
+ return (
+ <Button
+ className="align-self-center"
+ color="danger"
+ onClick={() => {
+ onRemove(member.username);
+ }}
+ >
+ {t('timeline.member.remove')}
+ </Button>
+ );
+ })()}
+ </Row>
+ </ListGroupItem>
+ ))}
+ </ListGroup>
+ {(() => {
+ const edit = props.edit;
+ if (edit != null) {
+ return (
+ <>
+ <SearchInput
+ value={userSearchText}
+ onChange={v => {
+ 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.toString()
+ });
+ }
+ );
+ }}
+ />
+ {(() => {
+ if (userSearchState.type === 'user') {
+ const u = userSearchState.data;
+ const addable =
+ members.findIndex(m => m.username === u.username) === -1;
+ return (
+ <>
+ {!addable ? (
+ <p>{t('timeline.member.alreadyMember')}</p>
+ ) : null}
+ <Container className="mb-3">
+ <Row>
+ <Col className="col-auto">
+ <img
+ src={u._links.avatar}
+ className="avatar small"
+ />
+ </Col>
+ <Col>
+ <Row>{u.nickname}</Row>
+ <Row>
+ <small>{'@' + u.username}</small>
+ </Row>
+ </Col>
+ <Button
+ color="primary"
+ className="align-self-center"
+ disabled={!addable}
+ onClick={() => {
+ edit.onAddUser(u).then(_ => {
+ setUserSearchText('');
+ setUserSearchState({ type: 'init' });
+ });
+ }}
+ >
+ {t('timeline.member.add')}
+ </Button>
+ </Row>
+ </Container>
+ </>
+ );
+ } else if (userSearchState.type === 'error') {
+ return (
+ <p className="text-danger">{t(userSearchState.data)}</p>
+ );
+ }
+ })()}
+ </>
+ );
+ } else {
+ return null;
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default TimelineMember;
+
+export interface TimelineMemberDialogProps extends TimelineMemberProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = props => {
+ return (
+ <Modal isOpen={props.open} toggle={props.onClose}>
+ <TimelineMember {...props} />
+ </Modal>
+ );
+};
diff --git a/Timeline/ClientApp/src/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/timeline/TimelinePage.tsx
new file mode 100644
index 00000000..5adebe1f
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelinePage.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { useParams } from 'react-router';
+
+import { ordinaryTimelineService } from '../data/timeline';
+
+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<OrdinaryTimelineManageItem | null>(
+ null
+ );
+
+ let dialogElement: React.ReactElement | undefined;
+ if (dialog === 'delete') {
+ dialogElement = (
+ <TimelineDeleteDialog open close={() => setDialog(null)} name={name} />
+ );
+ }
+
+ return (
+ <>
+ <TimelinePageTemplate
+ name={name}
+ UiComponent={TimelinePageUI}
+ onManage={item => setDialog(item)}
+ service={ordinaryTimelineService}
+ notFoundI18nKey="timeline.timelineNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default TimelinePage;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx
new file mode 100644
index 00000000..3660ad78
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx
@@ -0,0 +1,271 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { AxiosError } from 'axios';
+import concat from 'lodash/concat';
+import without from 'lodash/without';
+
+import { ExcludeKey } from '../type-utilities';
+import { useUser, fetchUser } from '../data/user';
+import { pushAlert } from '../common/alert-service';
+import { extractStatusCode, extractErrorCode } from '../data/common';
+import {
+ TimelineServiceTemplate,
+ TimelineInfo,
+ TimelineChangePropertyRequest,
+} from '../data/timeline';
+
+import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline';
+import { TimelineMemberDialog } from './TimelineMember';
+import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
+import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
+import { TimelinePostSendCallback } from './TimelinePostEdit';
+
+export interface TimelinePageTemplateProps<
+ TManageItem,
+ TTimeline extends TimelineInfo
+> {
+ name: string;
+ onManage: (item: TManageItem) => void;
+ service: TimelineServiceTemplate<TTimeline, TimelineChangePropertyRequest>;
+ UiComponent: React.ComponentType<
+ ExcludeKey<
+ TimelinePageTemplateUIProps<TTimeline, TManageItem>,
+ 'CardComponent'
+ >
+ >;
+ dataVersion?: number;
+ notFoundI18nKey: string;
+}
+
+export default function TimelinePageTemplate<
+ TManageItem,
+ TTimeline extends TimelineInfo
+>(
+ props: TimelinePageTemplateProps<TManageItem, TTimeline>
+): React.ReactElement | null {
+ const { t } = useTranslation();
+
+ const { name } = props;
+
+ const user = useUser();
+
+ const [dialog, setDialog] = React.useState<null | 'property' | 'member'>(
+ null
+ );
+ const [timeline, setTimeline] = React.useState<TTimeline | undefined>(
+ undefined
+ );
+ const [posts, setPosts] = React.useState<
+ TimelinePostInfoEx[] | 'forbid' | undefined
+ >(undefined);
+ const [error, setError] = React.useState<string | undefined>(undefined);
+
+ const service = props.service;
+
+ React.useEffect(() => {
+ let subscribe = true;
+ service.fetch(name).then(
+ (ti) => {
+ if (subscribe) {
+ setTimeline(ti);
+ if (!service.hasReadPermission(user, ti)) {
+ setPosts('forbid');
+ } else {
+ service.fetchPosts(name).then(
+ (data) => {
+ if (subscribe) {
+ setPosts(
+ data.map((post) => ({
+ ...post,
+ deletable: service.hasModifyPostPermission(
+ user,
+ ti,
+ post
+ ),
+ }))
+ );
+ }
+ },
+ (error) => {
+ if (subscribe) {
+ setError(error.toString());
+ }
+ }
+ );
+ }
+ }
+ },
+ (error: AxiosError) => {
+ if (subscribe) {
+ if (
+ extractStatusCode(error) === 404 ||
+ extractErrorCode(error) === 11020101
+ ) {
+ setError(t(props.notFoundI18nKey));
+ } else {
+ setError(error.toString());
+ }
+ }
+ }
+ );
+ return () => {
+ subscribe = false;
+ };
+ }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]);
+
+ React.useEffect(() => {
+ if (posts != null) {
+ window.scrollTo(
+ 0,
+ document.body.scrollHeight || document.documentElement.scrollHeight
+ );
+ }
+ }, [posts]);
+
+ const closeDialog = React.useCallback((): void => {
+ setDialog(null);
+ }, []);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ if (dialog === 'property') {
+ dialogElement = (
+ <TimelinePropertyChangeDialog
+ open
+ close={closeDialog}
+ oldInfo={{
+ visibility: timeline!.visibility,
+ description: timeline!.description,
+ }}
+ onProcess={(req) => {
+ return service.changeProperty(name, req).then((newTimeline) => {
+ setTimeline(newTimeline);
+ });
+ }}
+ />
+ );
+ } else if (dialog === 'member') {
+ dialogElement = (
+ <TimelineMemberDialog
+ open
+ onClose={closeDialog}
+ members={[timeline!.owner, ...timeline!.members]}
+ edit={
+ service.hasManagePermission(user, timeline!)
+ ? {
+ onCheckUser: (u) => {
+ return fetchUser(u).catch((e) => {
+ if (
+ extractStatusCode(e) === 404 ||
+ extractErrorCode(e) === 11020101
+ ) {
+ return Promise.resolve(null);
+ } else {
+ return Promise.reject(e);
+ }
+ });
+ },
+ onAddUser: (u) => {
+ return service.addMember(name, u.username).then((_) => {
+ setTimeline({
+ ...timeline!,
+ members: concat(timeline!.members, u),
+ });
+ });
+ },
+ onRemoveUser: (u) => {
+ service.removeMember(name, u).then((_) => {
+ setTimeline({
+ ...timeline!,
+ members: without(
+ timeline!.members,
+ timeline!.members.find((m) => m.username === u)
+ ),
+ });
+ });
+ },
+ }
+ : null
+ }
+ />
+ );
+ }
+
+ const { UiComponent } = props;
+
+ const onDelete: TimelineDeleteCallback = React.useCallback(
+ (index, id) => {
+ service.deletePost(name, id).then(
+ (_) => {
+ 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).then((newPost) => {
+ setPosts((oldPosts) =>
+ concat(oldPosts as TimelinePostInfoEx[], {
+ ...newPost,
+ deletable: true,
+ })
+ );
+ });
+ },
+ [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 (
+ <>
+ <UiComponent
+ error={error}
+ timeline={timeline}
+ posts={posts}
+ onDelete={onDelete}
+ onPost={
+ timeline != null && service.hasPostPermission(user, timeline)
+ ? onPost
+ : undefined
+ }
+ onManage={
+ timeline != null && service.hasManagePermission(user, timeline)
+ ? onManage
+ : undefined
+ }
+ onMember={onMember}
+ />
+ {dialogElement}
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx
new file mode 100644
index 00000000..d96b3260
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { Spinner } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { getAlertHost } from '../common/alert-service';
+
+import Timeline, {
+ TimelinePostInfoEx,
+ TimelineDeleteCallback,
+} from './Timeline';
+import AppBar from '../common/AppBar';
+import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
+import CollapseButton from '../common/CollapseButton';
+
+export interface TimelineCardComponentProps<TTimeline, TManageItems> {
+ timeline: TTimeline;
+ onManage?: (item: TManageItems | 'property') => void;
+ onMember: () => void;
+ className?: string;
+ onHeight?: (height: number) => void;
+}
+
+export interface TimelinePageTemplateUIProps<TTimeline, TManageItems> {
+ avatarKey?: string | number;
+ timeline?: TTimeline;
+ posts?: TimelinePostInfoEx[] | 'forbid';
+ CardComponent: React.ComponentType<
+ TimelineCardComponentProps<TTimeline, TManageItems>
+ >;
+ onMember: () => void;
+ onManage?: (item: TManageItems | 'property') => void;
+ onPost?: TimelinePostSendCallback;
+ onDelete: TimelineDeleteCallback;
+ error?: string;
+}
+
+export default function TimelinePageTemplateUI<TTimeline, TEditItems>(
+ props: TimelinePageTemplateUIProps<TTimeline, TEditItems>
+): React.ReactElement | null {
+ const { t } = useTranslation();
+
+ const bottomSpaceRef = React.useRef<HTMLDivElement | null>(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 [cardHeight, setCardHeight] = React.useState<number>(0);
+
+ const onCardHeightChange = React.useCallback((height: number) => {
+ setCardHeight(height);
+ }, []);
+
+ const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(
+ false
+ );
+ const toggleInfoCardCollapse = React.useCallback((collapse) => {
+ setInfoCardCollapse(collapse);
+ }, []);
+
+ let body: React.ReactElement;
+
+ if (props.error != null) {
+ body = <p className="text-danger">{t(props.error)}</p>;
+ } else {
+ if (props.timeline != null) {
+ let timelineBody: React.ReactElement;
+ if (props.posts != null) {
+ if (props.posts === 'forbid') {
+ timelineBody = (
+ <p className="text-danger">{t('timeline.messageCantSee')}</p>
+ );
+ } else {
+ timelineBody = (
+ <Timeline posts={props.posts} onDelete={props.onDelete} />
+ );
+ if (props.onPost != null) {
+ timelineBody = (
+ <>
+ {timelineBody}
+ <div ref={bottomSpaceRef} className="flex-fix-length" />
+ <TimelinePostEdit
+ onPost={props.onPost}
+ onHeightChange={onPostEditHeightChange}
+ />
+ </>
+ );
+ }
+ }
+ } else {
+ timelineBody = <Spinner />;
+ }
+ const { CardComponent } = props;
+
+ body = (
+ <>
+ <div
+ className="fixed-top mt-appbar info-card-container"
+ data-collapse={infoCardCollapse ? 'true' : 'false'}
+ >
+ <CollapseButton
+ collapse={infoCardCollapse}
+ toggle={toggleInfoCardCollapse}
+ className="float-right m-1 info-card-collapse-button"
+ />
+ <CardComponent
+ timeline={props.timeline}
+ onManage={props.onManage}
+ onMember={props.onMember}
+ onHeight={onCardHeightChange}
+ className="info-card-content"
+ />
+ </div>
+ {timelineBody}
+ </>
+ );
+ } else {
+ body = <Spinner />;
+ }
+ }
+
+ return (
+ <>
+ <AppBar />
+ <div
+ style={{ marginTop: 56 + cardHeight }}
+ className="timeline-page-container"
+ >
+ {body}
+ </div>
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx
new file mode 100644
index 00000000..01a230af
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import { ExcludeKey } from '../type-utilities';
+import { TimelineInfo } from '../data/timeline';
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps
+} from './TimelinePageTemplateUI';
+import TimelineInfoCard, {
+ OrdinaryTimelineManageItem
+} from './TimelineInfoCard';
+
+export type TimelinePageUIProps = ExcludeKey<
+ TimelinePageTemplateUIProps<TimelineInfo, OrdinaryTimelineManageItem>,
+ 'CardComponent'
+>;
+
+const TimelinePageUI: React.FC<TimelinePageUIProps> = props => {
+ return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
+};
+
+export default TimelinePageUI;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx
new file mode 100644
index 00000000..fe1fda9b
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx
@@ -0,0 +1,205 @@
+import React from 'react';
+import clsx from 'clsx';
+import { Container, Button, Spinner, Row, Col } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { pushAlert } from '../common/alert-service';
+import { CreatePostRequest } from '../data/timeline';
+
+import FileInput from '../common/FileInput';
+
+interface TimelinePostEditImageProps {
+ onSelect: (blob: Blob | null) => void;
+}
+
+const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
+ const { onSelect } = props;
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setFileUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }
+ }, [file]);
+
+ const onInputChange: React.ChangeEventHandler<HTMLInputElement> = 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 (
+ <>
+ <FileInput
+ labelText={t('chooseImage')}
+ onChange={onInputChange}
+ accept="image/*"
+ className="mx-3 my-1"
+ />
+ {fileUrl && error == null && (
+ <img
+ src={fileUrl}
+ className="timeline-post-edit-image"
+ onLoad={onImgLoad}
+ onError={onImgError}
+ />
+ )}
+ {error != null && <div className="text-danger">{t(error)}</div>}
+ </>
+ );
+};
+
+export type TimelinePostSendCallback = (
+ content: CreatePostRequest
+) => Promise<void>;
+
+export interface TimelinePostEditProps {
+ className?: string;
+ onPost: TimelinePostSendCallback;
+ onHeightChange?: (height: number) => void;
+}
+
+const TimelinePostEdit: React.FC<TimelinePostEditProps> = (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<string>('');
+ const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
+
+ const canSend = kind === 'text' || (kind === 'image' && imageBlob != null);
+
+ React.useEffect(() => {
+ if (props.onHeightChange) {
+ props.onHeightChange(
+ document.getElementById('timeline-post-edit-area')!.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: CreatePostRequest =
+ kind === 'text'
+ ? {
+ content: {
+ type: 'text',
+ text: text,
+ },
+ }
+ : {
+ content: {
+ type: 'image',
+ data: imageBlob!,
+ },
+ };
+
+ onPost(req).then(
+ (_) => {
+ if (kind === 'text') {
+ setText('');
+ }
+ setState('input');
+ setKind('text');
+ },
+ (_) => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.sendPostFailed'),
+ });
+ setState('input');
+ }
+ );
+ }, [onPost, kind, text, imageBlob, t]);
+
+ const onImageSelect = React.useCallback((blob: Blob | null) => {
+ setImageBlob(blob);
+ }, []);
+
+ return (
+ <Container
+ id="timeline-post-edit-area"
+ fluid
+ className="fixed-bottom bg-light"
+ >
+ <Row>
+ <Col className="px-0">
+ {kind === 'text' ? (
+ <textarea
+ className="w-100 h-100"
+ value={text}
+ disabled={state === 'process'}
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setText(event.currentTarget.value);
+ }}
+ />
+ ) : (
+ <TimelinePostEditImage onSelect={onImageSelect} />
+ )}
+ </Col>
+ <Col sm="col-auto align-self-end m-1">
+ {(() => {
+ if (state === 'input') {
+ return (
+ <>
+ <i
+ className={clsx(
+ 'fas d-block text-center large-icon mt-1 mb-2',
+ kind === 'text' ? 'fa-image' : 'fa-font'
+ )}
+ onClick={toggleKind}
+ />
+ <Button color="primary" onClick={onSend} disabled={!canSend}>
+ {t('timeline.send')}
+ </Button>
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+ })()}
+ </Col>
+ </Row>
+ </Container>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..9ab725a6
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+
+import {
+ TimelineVisibility,
+ kTimelineVisibilities,
+ PersonalTimelineChangePropertyRequest
+} from '../data/timeline';
+
+import OperationDialog, {
+ OperationSelectInputInfoOption
+} from '../common/OperationDialog';
+
+export interface TimelinePropertyInfo {
+ visibility: TimelineVisibility;
+ description: string;
+}
+
+export interface TimelinePropertyChangeDialogProps {
+ open: boolean;
+ close: () => void;
+ oldInfo: TimelinePropertyInfo;
+ onProcess: (request: PersonalTimelineChangePropertyRequest) => Promise<void>;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: 'timeline.visibility.private',
+ Public: 'timeline.visibility.public',
+ Register: 'timeline.visibility.register'
+};
+
+const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = props => {
+ return (
+ <OperationDialog
+ title={'timeline.dialogChangeProperty.title'}
+ titleColor="default"
+ inputScheme={[
+ {
+ type: 'select',
+ label: 'timeline.dialogChangeProperty.visibility',
+ options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
+ v => ({
+ label: labelMap[v],
+ value: v
+ })
+ ),
+ initValue: props.oldInfo.visibility
+ },
+ {
+ type: 'text',
+ label: 'timeline.dialogChangeProperty.description',
+ initValue: props.oldInfo.description
+ }
+ ]}
+ open={props.open}
+ close={props.close}
+ onProcess={([newVisibility, newDescription]) => {
+ const req: PersonalTimelineChangePropertyRequest = {};
+ if (newVisibility !== props.oldInfo.visibility) {
+ req.visibility = newVisibility as TimelineVisibility;
+ }
+ if (newDescription !== props.oldInfo.description) {
+ req.description = newDescription as string;
+ }
+ return props.onProcess(req);
+ }}
+ />
+ );
+};
+
+export default TimelinePropertyChangeDialog;
diff --git a/Timeline/ClientApp/src/timeline/timeline-ui.scss b/Timeline/ClientApp/src/timeline/timeline-ui.scss
new file mode 100644
index 00000000..ef35ff55
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/timeline-ui.scss
@@ -0,0 +1,27 @@
+/*
+.info-card-container {
+}
+
+.info-card-container[data-collapse='true'] {
+}
+*/
+
+.info-card-container .info-card-collapse-button {
+ z-index: 1;
+ position: relative;
+}
+
+.info-card-container .info-card-content {
+ width: 100%;
+ position: absolute;
+ transform-origin: right top;
+ transition: transform 0.5s;
+}
+
+.info-card-container[data-collapse='true'] .info-card-content {
+ transform: scale(0);
+}
+
+.timeline-page-container {
+ transition: margin-top 0.5s;
+}
diff --git a/Timeline/ClientApp/src/timeline/timeline.scss b/Timeline/ClientApp/src/timeline/timeline.scss
new file mode 100644
index 00000000..e82b376b
--- /dev/null
+++ b/Timeline/ClientApp/src/timeline/timeline.scss
@@ -0,0 +1,119 @@
+@use 'sass:color';
+
+$timeline-line-width: 7px;
+$timeline-line-node-radius: 18px;
+$timeline-line-color: $primary;
+$timeline-line-color-current: #36c2e6;
+
+.timeline-line-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 0 0 auto;
+ width: 60px;
+}
+
+.timeline-line {
+ width: $timeline-line-width;
+ background: $timeline-line-color;
+}
+
+.timeline-line.start {
+ height: 20px;
+ flex: 0 0 auto;
+}
+
+.timeline-pt-start {
+ padding-top: 18px;
+}
+
+.timeline-line.end {
+ flex: 1 1 auto;
+}
+
+@keyframes timeline-line-node-noncurrent {
+ from {
+ background: $timeline-line-color;
+ }
+ to {
+ background: color.adjust($timeline-line-color, $lightness: +10%);
+ box-shadow: 0 0 20px 3px
+ color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1);
+ }
+}
+@keyframes timeline-line-node-current {
+ from {
+ background: $timeline-line-color-current;
+ }
+ to {
+ background: color.adjust($timeline-line-color-current, $lightness: +10%);
+ box-shadow: 0 0 20px 3px
+ color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1);
+ }
+}
+
+.timeline-line-node-container {
+ flex: 0 0 auto;
+ position: relative;
+ width: $timeline-line-node-radius;
+ height: $timeline-line-node-radius;
+}
+
+.timeline-line-node {
+ width: $timeline-line-node-radius + 2;
+ height: $timeline-line-node-radius + 2;
+ position: absolute;
+ left: -1px;
+ top: -1px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ z-index: 1;
+ animation: 1s infinite alternate;
+ animation-name: timeline-line-node-noncurrent;
+}
+
+.timeline-line.current-end {
+ height: 20px;
+ flex: 0 0 auto;
+ background: linear-gradient($timeline-line-color-current, transparent);
+}
+
+.current {
+ .timeline-line.start {
+ background: linear-gradient(
+ $timeline-line-color,
+ $timeline-line-color-current
+ );
+ }
+ .timeline-line-node {
+ animation-name: timeline-line-node-current;
+ }
+ .timeline-line.end {
+ background: $timeline-line-color-current;
+ }
+}
+
+.timeline-item-delete-button {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+}
+
+.timeline-content {
+ white-space: pre-line;
+}
+
+.timeline-content-image {
+ max-width: 60%;
+ max-height: 200px;
+}
+
+.timeline-post-edit-image {
+ max-width: 100px;
+ max-height: 100px;
+}
+
+.mask {
+ background: change-color($color: white, $alpha: 0.8);
+ z-index: 100;
+}
diff --git a/Timeline/ClientApp/src/type-utilities.ts b/Timeline/ClientApp/src/type-utilities.ts
new file mode 100644
index 00000000..307e5825
--- /dev/null
+++ b/Timeline/ClientApp/src/type-utilities.ts
@@ -0,0 +1 @@
+export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
diff --git a/Timeline/ClientApp/src/typings.d.ts b/Timeline/ClientApp/src/typings.d.ts
new file mode 100644
index 00000000..521d38e4
--- /dev/null
+++ b/Timeline/ClientApp/src/typings.d.ts
@@ -0,0 +1,24 @@
+declare module '*.png' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.jpeg' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.jpg' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.gif' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.svg' {
+ const content: string;
+ export default content;
+}
diff --git a/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..e082d5a0
--- /dev/null
+++ b/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx
@@ -0,0 +1,305 @@
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Modal,
+ ModalHeader,
+ Row,
+ Button,
+ ModalBody,
+ ModalFooter,
+} from 'reactstrap';
+import { AxiosError } from 'axios';
+
+import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper';
+
+export interface ChangeAvatarDialogProps {
+ open: boolean;
+ close: () => void;
+ process: (blob: Blob) => Promise<void>;
+}
+
+const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [clip, setClip] = React.useState<Clip | null>(null);
+ const [
+ cropImgElement,
+ setCropImgElement,
+ ] = React.useState<HTMLImageElement | null>(null);
+ const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
+ const [resultUrl, setResultUrl] = React.useState<string | null>(null);
+
+ const [state, setState] = React.useState<
+ | 'select'
+ | 'crop'
+ | 'processcrop'
+ | 'preview'
+ | 'uploading'
+ | 'success'
+ | 'error'
+ >('select');
+
+ const [message, setMessage] = useState<
+ string | { type: 'custom'; text: string } | null
+ >('userPage.dialogChangeAvatar.prompt.select');
+
+ const trueMessage =
+ message == null
+ ? null
+ : typeof message === 'string'
+ ? t(message)
+ : message.text;
+
+ const closeDialog = props.close;
+
+ const toggle = React.useCallback((): void => {
+ if (!(state === 'uploading')) {
+ closeDialog();
+ }
+ }, [state, closeDialog]);
+
+ useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setClip(null);
+ setFileUrl(url);
+ setState('crop');
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setFileUrl(null);
+ setState('select');
+ }
+ }, [file]);
+
+ React.useEffect(() => {
+ if (resultBlob != null) {
+ const url = URL.createObjectURL(resultBlob);
+ setResultUrl(url);
+ setState('preview');
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setResultUrl(null);
+ }
+ }, [resultBlob]);
+
+ const onSelectFile = React.useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ },
+ []
+ );
+
+ const onCropNext = React.useCallback(() => {
+ if (
+ cropImgElement == null ||
+ clip == null ||
+ clip.width === 0 ||
+ file == null
+ ) {
+ throw new Error('Invalid state.');
+ }
+
+ setState('processcrop');
+ applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+ setResultBlob(b);
+ });
+ }, [cropImgElement, clip, file]);
+
+ const onCropPrevious = React.useCallback(() => {
+ setFile(null);
+ setState('select');
+ }, []);
+
+ const onPreviewPrevious = React.useCallback(() => {
+ setResultBlob(null);
+ setState('crop');
+ }, []);
+
+ const process = props.process;
+
+ const upload = React.useCallback(() => {
+ if (resultBlob == null) {
+ throw new Error('Invalid state.');
+ }
+
+ setState('uploading');
+ process(resultBlob).then(
+ () => {
+ setState('success');
+ },
+ (e: unknown) => {
+ setState('error');
+ setMessage({ type: 'custom', text: (e as AxiosError).message });
+ }
+ );
+ }, [resultBlob, process]);
+
+ const createPreviewRow = (): React.ReactElement => {
+ if (resultUrl == null) {
+ throw new Error('Invalid state.');
+ }
+ return (
+ <Row className="justify-content-center">
+ <img
+ className="change-avatar-img"
+ src={resultUrl}
+ alt={t('userPage.dialogChangeAvatar.previewImgAlt')}
+ />
+ </Row>
+ );
+ };
+
+ return (
+ <Modal isOpen={props.open} toggle={toggle}>
+ <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader>
+ {(() => {
+ if (state === 'select') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row>
+ <Row>
+ <input type="file" accept="image/*" onChange={onSelectFile} />
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'crop') {
+ if (fileUrl == null) {
+ throw new Error('Invalid state.');
+ }
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="justify-content-center">
+ <ImageCropper
+ clip={clip}
+ onChange={setClip}
+ imageUrl={fileUrl}
+ imageElementCallback={setCropImgElement}
+ />
+ </Row>
+ <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onCropPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ <Button
+ color="primary"
+ onClick={onCropNext}
+ disabled={
+ cropImgElement == null || clip == null || clip.width === 0
+ }
+ >
+ {t('operationDialog.nextStep')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'processcrop') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>
+ {t('userPage.dialogChangeAvatar.prompt.processingCrop')}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'preview') {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t('userPage.dialogChangeAvatar.upload')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'uploading') {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row>
+ </ModalBody>
+ <ModalFooter></ModalFooter>
+ </>
+ );
+ } else if (state === 'success') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="p-4 text-success">
+ {t('operationDialog.success')}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="success" onClick={toggle}>
+ {t('operationDialog.ok')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row className="text-danger">{trueMessage}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t('operationDialog.retry')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+ })()}
+ </Modal>
+ );
+};
+
+export default ChangeAvatarDialog;
diff --git a/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..393d88f6
--- /dev/null
+++ b/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import OperationDialog from '../common/OperationDialog';
+
+export interface ChangeNicknameDialogProps {
+ open: boolean;
+ close: () => void;
+ onProcess: (newNickname: string) => Promise<void>;
+}
+
+const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = props => {
+ return (
+ <OperationDialog
+ open={props.open}
+ title="userPage.dialogChangeNickname.title"
+ titleColor="default"
+ inputScheme={[
+ { type: 'text', label: 'userPage.dialogChangeNickname.inputLabel' }
+ ]}
+ onProcess={([newNickname]) => {
+ return props.onProcess(newNickname as string);
+ }}
+ close={props.close}
+ />
+ );
+};
+
+export default ChangeNicknameDialog;
diff --git a/Timeline/ClientApp/src/user/Login.tsx b/Timeline/ClientApp/src/user/Login.tsx
new file mode 100644
index 00000000..fc9364c0
--- /dev/null
+++ b/Timeline/ClientApp/src/user/Login.tsx
@@ -0,0 +1,146 @@
+import React, { Fragment, useState, useEffect } from 'react';
+import { useHistory } from 'react-router';
+import { useTranslation } from 'react-i18next';
+
+import AppBar from '../common/AppBar';
+
+import { userLogin, useUser } from '../data/user';
+import {
+ Label,
+ FormGroup,
+ Input,
+ Form,
+ FormFeedback,
+ Spinner,
+ Button,
+} from 'reactstrap';
+
+const Login: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const [username, setUsername] = useState<string>('');
+ const [usernameDirty, setUsernameDirty] = useState<boolean>(false);
+ const [password, setPassword] = useState<string>('');
+ const [passwordDirty, setPasswordDirty] = useState<boolean>(false);
+ const [rememberMe, setRememberMe] = useState<boolean>(true);
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const user = useUser();
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => history.push('/'), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p className="mt-appbar">{t('login.alreadyLogin')}</p>
+ </>
+ );
+ }
+
+ function onSubmit(event: React.SyntheticEvent): void {
+ if (username === '' || password === '') {
+ setUsernameDirty(true);
+ setPasswordDirty(true);
+ return;
+ }
+
+ setProcess(true);
+ userLogin(
+ {
+ username: username,
+ password: password,
+ },
+ rememberMe
+ ).then(
+ (_) => {
+ if (history.length === 0) {
+ history.push('/');
+ } else {
+ history.goBack();
+ }
+ },
+ (e) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ event.preventDefault();
+ }
+
+ return (
+ <Fragment>
+ <AppBar />
+ <div className="container login-container mt-appbar">
+ <h1>{t('welcome')}</h1>
+ <Form>
+ <FormGroup>
+ <Label for="username">{t('user.username')}</Label>
+ <Input
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ invalid={usernameDirty && username === ''}
+ />
+ {usernameDirty && username === '' && (
+ <FormFeedback>{t('login.emptyUsername')}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup>
+ <Label for="password">{t('user.password')}</Label>
+ <Input
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ invalid={passwordDirty && password === ''}
+ />
+ {passwordDirty && password === '' && (
+ <FormFeedback>{t('login.emptyPassword')}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup check>
+ <Input
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ const v = (e.target as HTMLInputElement).checked;
+ setRememberMe(v);
+ }}
+ />
+ <Label for="remember-me">{t('user.rememberMe')}</Label>
+ </FormGroup>
+ {error ? <p className="text-error">{t(error)}</p> : null}
+ <div>
+ {process ? (
+ <Spinner />
+ ) : (
+ <Button color="primary" onClick={onSubmit}>
+ {t('user.login')}
+ </Button>
+ )}
+ </div>
+ </Form>
+ </div>
+ </Fragment>
+ );
+};
+
+export default Login;
diff --git a/Timeline/ClientApp/src/user/User.tsx b/Timeline/ClientApp/src/user/User.tsx
new file mode 100644
index 00000000..7bdd64b7
--- /dev/null
+++ b/Timeline/ClientApp/src/user/User.tsx
@@ -0,0 +1,70 @@
+import React, { useState } from 'react';
+import { useParams } from 'react-router';
+
+import { useUser } from '../data/user';
+import { changeNickname, changeAvatar } from './api';
+import { personalTimelineService } from '../data/timeline';
+
+import UserPage from './UserPage';
+import ChangeNicknameDialog from './ChangeNicknameDialog';
+import ChangeAvatarDialog from './ChangeAvatarDialog';
+import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
+import { PersonalTimelineManageItem } from './UserInfoCard';
+
+const User: React.FC = (_) => {
+ const { username } = useParams<{ username: string }>();
+
+ const user = useUser();
+
+ const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
+ const [dataKey, setDataKey] = useState<number>(0);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ const closeDialogHandler = (): void => {
+ setDialog(null);
+ };
+
+ if (dialog === 'nickname') {
+ dialogElement = (
+ <ChangeNicknameDialog
+ open
+ close={closeDialogHandler}
+ onProcess={(newNickname) => {
+ const p = changeNickname(user!.token, username, newNickname);
+ return p.then((_) => {
+ setDataKey(dataKey + 1);
+ });
+ }}
+ />
+ );
+ } else if (dialog === 'avatar') {
+ dialogElement = (
+ <ChangeAvatarDialog
+ open
+ close={closeDialogHandler}
+ process={(file) => changeAvatar(user!.token, username, file, file.type)}
+ />
+ );
+ }
+
+ const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
+ setDialog(item);
+ }, []);
+
+ return (
+ <>
+ <TimelinePageTemplate
+ dataVersion={dataKey}
+ name={username}
+ UiComponent={UserPage}
+ onManage={onManage}
+ service={personalTimelineService}
+ notFoundI18nKey="timeline.userNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default User;
diff --git a/Timeline/ClientApp/src/user/UserInfoCard.tsx b/Timeline/ClientApp/src/user/UserInfoCard.tsx
new file mode 100644
index 00000000..280cddce
--- /dev/null
+++ b/Timeline/ClientApp/src/user/UserInfoCard.tsx
@@ -0,0 +1,126 @@
+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 {
+ TimelineInfo,
+ timelineVisibilityTooltipTranslationMap,
+} from '../data/timeline';
+import { useAvatarVersion, useAvatarUrlWithGivenVersion } from './api';
+import { useUser } from '../data/user';
+
+import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI';
+
+export type PersonalTimelineManageItem = 'avatar' | 'nickname';
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ TimelineInfo,
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+ const { t } = useTranslation();
+ const user = useUser();
+
+ const avatarVersion = useAvatarVersion();
+ const avatarUrl = useAvatarUrlWithGivenVersion(
+ user != null && user.username === props.timeline.owner.username
+ ? avatarVersion
+ : undefined,
+ props.timeline.owner._links.avatar
+ );
+
+ const containerRef = React.useRef<HTMLDivElement>(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<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+ const onManageProperty = React.useCallback(
+ (): void => onManage!('property'),
+ [onManage]
+ );
+ const onManageAvatar = React.useCallback((): void => onManage!('avatar'), [
+ onManage,
+ ]);
+ const onManageNickname = React.useCallback(
+ (): void => onManage!('nickname'),
+ [onManage]
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx('rounded border bg-light p-2', props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <img
+ src={avatarUrl}
+ onLoad={notifyHeight}
+ className="avatar large mr-2 mb-2 rounded-circle float-left"
+ />
+ <div>
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {props.onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t('timeline.manage')}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={onManageNickname}>
+ {t('timeline.manageItem.nickname')}
+ </DropdownItem>
+ <DropdownItem onClick={onManageAvatar}>
+ {t('timeline.manageItem.avatar')}
+ </DropdownItem>
+ <DropdownItem onClick={onManageProperty}>
+ {t('timeline.manageItem.property')}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t('timeline.manageItem.member')}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t('timeline.memberButton')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default UserInfoCard;
diff --git a/Timeline/ClientApp/src/user/UserPage.tsx b/Timeline/ClientApp/src/user/UserPage.tsx
new file mode 100644
index 00000000..723415c7
--- /dev/null
+++ b/Timeline/ClientApp/src/user/UserPage.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { ExcludeKey } from '../type-utilities';
+import { TimelineInfo } from '../data/timeline';
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps
+} from '../timeline/TimelinePageTemplateUI';
+import UserInfoCard, { PersonalTimelineManageItem } from './UserInfoCard';
+
+export type UserPageProps = ExcludeKey<
+ TimelinePageTemplateUIProps<TimelineInfo, PersonalTimelineManageItem>,
+ 'CardComponent'
+>;
+
+const UserPage: React.FC<UserPageProps> = props => {
+ return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
+};
+
+export default UserPage;
diff --git a/Timeline/ClientApp/src/user/api.ts b/Timeline/ClientApp/src/user/api.ts
new file mode 100644
index 00000000..184c011f
--- /dev/null
+++ b/Timeline/ClientApp/src/user/api.ts
@@ -0,0 +1,78 @@
+import React from 'react';
+import axios from 'axios';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { apiBaseUrl } from '../config';
+import { User } from '../data/user';
+import { updateQueryString } from '../helper';
+
+export function changeNickname(
+ token: string,
+ username: string,
+ newNickname: string
+): Promise<User> {
+ return axios
+ .patch<User>(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ nickname: newNickname,
+ })
+ .then((res) => res.data);
+}
+
+const avatarVersionSubject = new BehaviorSubject<number | undefined>(undefined);
+
+export function changeAvatar(
+ token: string,
+ username: string,
+ data: Blob,
+ type: string
+): Promise<void> {
+ return axios
+ .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
+ headers: {
+ 'Content-Type': type,
+ },
+ })
+ .then(() => {
+ avatarVersionSubject.next((avatarVersionSubject.value ?? 0) + 1);
+ });
+}
+
+export const avatarVersion$: Observable<
+ number | undefined
+> = avatarVersionSubject;
+
+export function useAvatarVersion(): number | undefined {
+ const [version, setVersion] = React.useState<number | undefined>();
+ React.useEffect(() => {
+ const subscription = avatarVersion$.subscribe((v) => setVersion(v));
+ return () => subscription.unsubscribe();
+ }, []);
+ return version;
+}
+
+export function useOptionalVersionedAvatarUrl(
+ url: string | undefined
+): string | undefined {
+ const avatarVersion = useAvatarVersion();
+ return React.useMemo(
+ () =>
+ url == null
+ ? undefined
+ : updateQueryString(
+ 'v',
+ avatarVersion == null ? null : avatarVersion + '',
+ url
+ ),
+ [avatarVersion, url]
+ );
+}
+
+export function useAvatarUrlWithGivenVersion(
+ version: number | null | undefined,
+ url: string
+): string {
+ return React.useMemo(
+ () => updateQueryString('v', version == null ? null : version + '', url),
+ [version, url]
+ );
+}
diff --git a/Timeline/ClientApp/src/user/user-page.scss b/Timeline/ClientApp/src/user/user-page.scss
new file mode 100644
index 00000000..a102fbe6
--- /dev/null
+++ b/Timeline/ClientApp/src/user/user-page.scss
@@ -0,0 +1,13 @@
+.login-container {
+ max-width: 600px;
+}
+
+.change-avatar-cropper-row {
+ max-height: 400px;
+}
+
+.change-avatar-img {
+ min-width: 50%;
+ max-width: 100%;
+ max-height: 400px;
+}
diff --git a/Timeline/ClientApp/tsconfig-for-webpack-config.json b/Timeline/ClientApp/tsconfig-for-webpack-config.json
new file mode 100644
index 00000000..bb6f2a7a
--- /dev/null
+++ b/Timeline/ClientApp/tsconfig-for-webpack-config.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es5",
+ "esModuleInterop": true
+ },
+ "include": [
+ "webpack.common.ts",
+ "webpack.config.dev.ts",
+ "webpack.config.prod.ts",
+ "webpack.config.prod.dev.ts"
+ ]
+} \ No newline at end of file
diff --git a/Timeline/ClientApp/tsconfig.json b/Timeline/ClientApp/tsconfig.json
new file mode 100644
index 00000000..9a057241
--- /dev/null
+++ b/Timeline/ClientApp/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/Timeline/ClientApp/webpack.common.ts b/Timeline/ClientApp/webpack.common.ts
new file mode 100644
index 00000000..9e7da028
--- /dev/null
+++ b/Timeline/ClientApp/webpack.common.ts
@@ -0,0 +1,59 @@
+import webpack from 'webpack';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import autoprefixer from 'autoprefixer';
+
+export const commonRules: webpack.RuleSetRule[] = [
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader']
+ },
+ {
+ test: /\.(scss)$/,
+ use: [
+ 'style-loader',
+ 'css-loader',
+ {
+ loader: 'postcss-loader',
+ options: {
+ plugins: function() {
+ return [autoprefixer];
+ }
+ }
+ },
+ 'sass-loader'
+ ]
+ },
+ {
+ test: /\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot)$/i,
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ limit: 8192
+ }
+ },
+ {
+ loader: 'image-webpack-loader'
+ }
+ ]
+ }
+];
+
+export const htmlCommonConfig: HtmlWebpackPlugin.Options = {
+ inject: false,
+ template: require('html-webpack-template'),
+
+ appMountId: 'app',
+ mobile: true,
+
+ headHtmlSnippet: `
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+ <link rel="manifest" href="/site.webmanifest">
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
+ <meta name="msapplication-TileColor" content="#2d89ef">
+ <meta name="theme-color" content="#ffffff">
+ `,
+ title: 'Timeline'
+};
diff --git a/Timeline/ClientApp/webpack.config.dev.ts b/Timeline/ClientApp/webpack.config.dev.ts
new file mode 100644
index 00000000..869b963b
--- /dev/null
+++ b/Timeline/ClientApp/webpack.config.dev.ts
@@ -0,0 +1,68 @@
+import path from 'path';
+import webpack from 'webpack';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
+
+import { commonRules, htmlCommonConfig } from './webpack.common';
+
+const config: webpack.Configuration = {
+ entry: ['react-hot-loader/patch', './src/index.tsx'],
+ mode: 'development',
+ devtool: 'eval-source-map',
+ module: {
+ rules: [
+ ...commonRules,
+ {
+ test: /\.(js|jsx|ts|tsx)$/,
+ exclude: /node_modules/,
+ loader: 'babel-loader',
+ options: {
+ plugins: ['react-hot-loader/babel']
+ }
+ }
+ ]
+ },
+ resolve: {
+ alias: {
+ 'react-dom': '@hot-loader/react-dom'
+ },
+ extensions: ['*', '.js', '.jsx', '.ts', '.tsx']
+ },
+ optimization: {
+ runtimeChunk: 'single',
+ splitChunks: {
+ chunks: 'all',
+ cacheGroups: {
+ vendor: {
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendors',
+ chunks: 'all'
+ }
+ }
+ }
+ },
+ output: {
+ path: path.resolve(__dirname, 'dist/'),
+ filename: '[name].[hash].js',
+ chunkFilename: '[name].[hash].js',
+ publicPath: '/'
+ },
+ devServer: {
+ contentBase: path.join(__dirname, 'public/'),
+ host: '0.0.0.0',
+ port: 3000,
+ publicPath: 'http://localhost:3000/',
+ historyApiFallback: true,
+ hotOnly: true
+ },
+ plugins: [
+ new HtmlWebpackPlugin({
+ ...htmlCommonConfig,
+ devServer: 'http://localhost:3000'
+ }),
+ new ForkTsCheckerWebpackPlugin(),
+ new webpack.HotModuleReplacementPlugin()
+ ]
+};
+
+export default config;
diff --git a/Timeline/ClientApp/webpack.config.prod.dev.ts b/Timeline/ClientApp/webpack.config.prod.dev.ts
new file mode 100644
index 00000000..8acc2355
--- /dev/null
+++ b/Timeline/ClientApp/webpack.config.prod.dev.ts
@@ -0,0 +1,15 @@
+import webpack from 'webpack';
+
+import baseConfig from './webpack.config.prod';
+
+const config: webpack.Configuration = {
+ ...baseConfig,
+ plugins: [
+ ...baseConfig.plugins,
+ new webpack.DefinePlugin({
+ 'process.env.TIMELINE_USEDEVAPI': true,
+ }),
+ ],
+};
+
+export default config;
diff --git a/Timeline/ClientApp/webpack.config.prod.ts b/Timeline/ClientApp/webpack.config.prod.ts
new file mode 100644
index 00000000..77ee7966
--- /dev/null
+++ b/Timeline/ClientApp/webpack.config.prod.ts
@@ -0,0 +1,59 @@
+import path from 'path';
+import webpack from 'webpack';
+import { CleanWebpackPlugin } from 'clean-webpack-plugin';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
+import CopyPlugin from 'copy-webpack-plugin';
+
+import { commonRules, htmlCommonConfig } from './webpack.common';
+
+const config: webpack.Configuration = {
+ entry: ['./src/index.tsx'],
+ mode: 'production',
+ devtool: 'source-map',
+ module: {
+ rules: [
+ ...commonRules,
+ {
+ test: /\.(js|jsx|ts|tsx)$/,
+ exclude: /node_modules/,
+ loader: 'babel-loader'
+ }
+ ]
+ },
+ resolve: {
+ extensions: ['*', '.js', '.jsx', '.ts', '.tsx']
+ },
+ optimization: {
+ runtimeChunk: 'single',
+ splitChunks: {
+ chunks: 'all',
+ cacheGroups: {
+ vendor: {
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendors',
+ chunks: 'all'
+ }
+ }
+ }
+ },
+ output: {
+ path: path.resolve(__dirname, 'dist/'),
+ filename: '[name].[hash].js',
+ chunkFilename: '[name].[hash].js',
+ publicPath: '/'
+ },
+ plugins: [
+ new CleanWebpackPlugin(),
+ new HtmlWebpackPlugin(htmlCommonConfig),
+ new ForkTsCheckerWebpackPlugin(),
+ new CopyPlugin([
+ {
+ from: path.resolve(__dirname, 'public/'),
+ to: path.resolve(__dirname, 'dist/')
+ }
+ ])
+ ]
+};
+
+export default config;
diff --git a/Timeline/Routes/ApiRoutePrefixConvention.cs b/Timeline/Routes/ApiRoutePrefixConvention.cs
new file mode 100644
index 00000000..2653c2ca
--- /dev/null
+++ b/Timeline/Routes/ApiRoutePrefixConvention.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Mvc.Routing;
+using System.Linq;
+
+namespace Timeline.Routes
+{
+ public static class MvcOptionsExtensions
+ {
+ public static void UseApiRoutePrefix(this MvcOptions opts, IRouteTemplateProvider routeAttribute)
+ {
+ opts.Conventions.Add(new ApiRoutePrefixConvention(routeAttribute));
+ }
+
+ public static void UseApiRoutePrefix(this MvcOptions opts, string prefix)
+ {
+ opts.UseApiRoutePrefix(new RouteAttribute(prefix));
+ }
+ }
+
+ public class ApiRoutePrefixConvention : IApplicationModelConvention
+ {
+ private readonly AttributeRouteModel _routePrefix;
+
+ public ApiRoutePrefixConvention(IRouteTemplateProvider route)
+ {
+ _routePrefix = new AttributeRouteModel(route);
+ }
+
+ public void Apply(ApplicationModel application)
+ {
+ foreach (var selector in application.Controllers.Where(c => c.RouteValues.TryGetValue("area", out var value) && value == "api").SelectMany(c => c.Selectors))
+ {
+ if (selector.AttributeRouteModel != null)
+ {
+ selector.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(_routePrefix, selector.AttributeRouteModel);
+ }
+ else
+ {
+ selector.AttributeRouteModel = _routePrefix;
+ }
+ }
+ }
+ }
+}
diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs
index f5220446..8da09686 100644
--- a/Timeline/Startup.cs
+++ b/Timeline/Startup.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -16,6 +17,7 @@ using Timeline.Entities;
using Timeline.Formatters;
using Timeline.Helpers;
using Timeline.Models.Converters;
+using Timeline.Routes;
using Timeline.Services;
namespace Timeline
@@ -44,6 +46,7 @@ namespace Timeline
services.AddControllers(setup =>
{
setup.InputFormatters.Add(new StringInputFormatter());
+ setup.UseApiRoutePrefix("api");
})
.AddJsonOptions(options =>
{
@@ -61,29 +64,6 @@ namespace Timeline
.AddScheme<MyAuthenticationOptions, MyAuthenticationHandler>(AuthenticationConstants.Scheme, AuthenticationConstants.DisplayName, o => { });
services.AddAuthorization();
-
- if (Environment.IsDevelopment())
- {
- services.AddCors(setup =>
- {
- setup.AddDefaultPolicy(builder =>
- {
- builder.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin();
- });
- });
- }
- else
- {
- var corsConfig = Configuration.GetSection("Cors").Get<string[]>();
- services.AddCors(setup =>
- {
- setup.AddDefaultPolicy(builder =>
- {
- builder.AllowAnyHeader().AllowAnyMethod().WithOrigins(corsConfig);
- });
- });
- }
-
services.AddScoped<IPathProvider, PathProvider>();
services.AddAutoMapper(GetType().Assembly);
@@ -113,6 +93,11 @@ namespace Timeline
var pathProvider = services.GetRequiredService<IPathProvider>();
options.UseSqlite($"Data Source={pathProvider.GetDatabaseFilePath()}");
});
+
+ services.AddSpaStaticFiles(config =>
+ {
+ config.RootPath = "ClientApp/dist";
+ });
}
@@ -126,7 +111,7 @@ namespace Timeline
app.UseRouting();
- app.UseCors();
+ app.UseSpaStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
@@ -135,6 +120,18 @@ namespace Timeline
{
endpoints.MapControllers();
});
+
+ app.UseSpa(spa =>
+ {
+ spa.Options.SourcePath = "ClientApp";
+
+ if (Environment.IsDevelopment())
+ {
+ // TODO! I'll waiting for aspnetcore to support custom package manager and port.
+ // It is already in master branch code but not published.
+ spa.UseReactDevelopmentServer(npmScript: "start");
+ }
+ });
}
}
}
diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj
index 739f79dd..6c61ac80 100644
--- a/Timeline/Timeline.csproj
+++ b/Timeline/Timeline.csproj
@@ -4,8 +4,16 @@
<UserSecretsId>1f6fb74d-4277-4bc0-aeea-b1fc5ffb0b43</UserSecretsId>
<Authors>crupest</Authors>
+ <IsPackable>false</IsPackable>
+
<LangVersion>8.0</LangVersion>
<Nullable>enable</Nullable>
+
+ <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
+ <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
+ <SpaRoot>ClientApp\</SpaRoot>
+
+ <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
@@ -17,25 +25,63 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="9.0.0" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="7.0.0" />
- <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8">
+ <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.1.4" />
+ <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="3.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.2" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="3.1.2" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.2" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.2">
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.4" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="3.1.4" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.4" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.0-unstable0934" />
- <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.6.0" />
+ <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.6.0" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Timeline.ErrorCodes\Timeline.ErrorCodes.csproj" />
</ItemGroup>
<ItemGroup>
- <ProjectReference Include="..\Timeline.ErrorCodes\Timeline.ErrorCodes.csproj" />
+ <!-- Don't publish the SPA source files, but do show them in the project files list -->
+ <Content Remove="$(SpaRoot)**" />
+ <None Remove="$(SpaRoot)**" />
+ <None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup>
+ <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
+ <!-- Ensure Node.js is installed -->
+ <Exec Command="node --version" ContinueOnError="true">
+ <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
+ </Exec>
+ <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
+ <Exec Command="yarn --version" ContinueOnError="true">
+ <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
+ </Exec>
+ <Error Condition="'$(ErrorCode)' != '0'" Text="Yarn is required to build and run this project. To continue, please install yarn from https://yarnpkg.com/, and then restart your command prompt or IDE." />
+ <Message Importance="high" Text="Restoring dependencies using 'yarn'. This may take several minutes..." />
+ <Exec WorkingDirectory="$(SpaRoot)" Command="yarn" />
+ </Target>
+
+ <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
+ <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
+ <Exec WorkingDirectory="$(SpaRoot)" Command="yarn" />
+ <Exec WorkingDirectory="$(SpaRoot)" Command="yarn run build" />
+
+ <!-- Include the newly-built files in the publish output -->
+ <ItemGroup>
+ <DistFiles Include="$(SpaRoot)dist\**" />
+ <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
+ <RelativePath>%(DistFiles.Identity)</RelativePath>
+ <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
+ <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+ </ResolvedFileToPublish>
+ </ItemGroup>
+ </Target>
+
<ItemGroup>
<Compile Update="Resources\Authentication\AuthHandler.Designer.cs">
<DesignTime>True</DesignTime>
@@ -43,9 +89,9 @@
<DependentUpon>AuthHandler.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Controllers\ControllerAuthExtensions.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>ControllerAuthExtensions.resx</DependentUpon>
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>ControllerAuthExtensions.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Controllers\TimelineController.Designer.cs">
<DesignTime>True</DesignTime>
@@ -73,9 +119,9 @@
<DependentUpon>Filters.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Helper\DataCacheHelper.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>DataCacheHelper.resx</DependentUpon>
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>DataCacheHelper.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Messages.Designer.cs">
<DesignTime>True</DesignTime>
@@ -103,9 +149,9 @@
<DependentUpon>Validator.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Services\DataManager.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>DataManager.resx</DependentUpon>
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>DataManager.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Services\Exception.Designer.cs">
<DesignTime>True</DesignTime>
@@ -113,9 +159,9 @@
<DependentUpon>Exception.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Services\TimelineService.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>TimelineService.resx</DependentUpon>
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>TimelineService.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Services\UserAvatarService.Designer.cs">
<DesignTime>True</DesignTime>
@@ -128,9 +174,9 @@
<DependentUpon>UserService.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Services\UserTokenService.Designer.cs">
- <DesignTime>True</DesignTime>
- <AutoGen>True</AutoGen>
- <DependentUpon>UserTokenService.resx</DependentUpon>
+ <DesignTime>True</DesignTime>
+ <AutoGen>True</AutoGen>
+ <DependentUpon>UserTokenService.resx</DependentUpon>
</Compile>
</ItemGroup>
@@ -140,8 +186,8 @@
<LastGenOutput>AuthHandler.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Controllers\ControllerAuthExtensions.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>ControllerAuthExtensions.Designer.cs</LastGenOutput>
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>ControllerAuthExtensions.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Controllers\TimelineController.resx">
<Generator>ResXFileCodeGenerator</Generator>
@@ -165,8 +211,8 @@
<LastGenOutput>Filters.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Helper\DataCacheHelper.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>DataCacheHelper.Designer.cs</LastGenOutput>
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>DataCacheHelper.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Messages.resx">
<Generator>ResXFileCodeGenerator</Generator>
@@ -189,16 +235,16 @@
<LastGenOutput>Validator.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Services\DataManager.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>DataManager.Designer.cs</LastGenOutput>
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>DataManager.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Services\Exception.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Exception.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Services\TimelineService.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>TimelineService.Designer.cs</LastGenOutput>
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>TimelineService.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Services\UserAvatarService.resx">
<Generator>ResXFileCodeGenerator</Generator>
@@ -209,8 +255,8 @@
<LastGenOutput>UserService.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Services\UserTokenService.resx">
- <Generator>ResXFileCodeGenerator</Generator>
- <LastGenOutput>UserTokenService.Designer.cs</LastGenOutput>
+ <Generator>ResXFileCodeGenerator</Generator>
+ <LastGenOutput>UserTokenService.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>