diff options
Diffstat (limited to 'Timeline/ClientApp')
95 files changed, 0 insertions, 7172 deletions
diff --git a/Timeline/ClientApp b/Timeline/ClientApp new file mode 160000 +Subproject d8ab637b8ecf81967d01dc566da6648077bd903 diff --git a/Timeline/ClientApp/.babelrc b/Timeline/ClientApp/.babelrc deleted file mode 100644 index 78d36a3d..00000000 --- a/Timeline/ClientApp/.babelrc +++ /dev/null @@ -1,28 +0,0 @@ -{
- "presets": [
- "@babel/env",
- "@babel/preset-react",
- "@babel/preset-typescript"
- ],
- "plugins": [
- "@babel/plugin-syntax-dynamic-import",
- "@babel/plugin-proposal-class-properties",
- "@babel/plugin-proposal-optional-chaining",
- "@babel/plugin-proposal-nullish-coalescing-operator",
- [
- "@babel/plugin-proposal-decorators",
- {
- "decoratorsBeforeExport": true
- }
- ],
- [
- "babel-plugin-transform-builtin-extend",
- {
- "globals": [
- "Error",
- "Array"
- ]
- }
- ]
- ]
-}
\ No newline at end of file diff --git a/Timeline/ClientApp/.editorconfig b/Timeline/ClientApp/.editorconfig deleted file mode 100644 index 54d8316a..00000000 --- a/Timeline/ClientApp/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -root = true
-
-[*.ts]
-tab_width = 2
-
-[*.tsx]
-tab_width = 2
-
-[*.css]
-tab_width = 2
-
-[*.sass]
-tab_width = 2
diff --git a/Timeline/ClientApp/.eslintignore b/Timeline/ClientApp/.eslintignore deleted file mode 100644 index 371b5fcb..00000000 --- a/Timeline/ClientApp/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -.yarn
-node_modules
-dist
-webpack.*.js
-.eslintrc.js
diff --git a/Timeline/ClientApp/.eslintrc.js b/Timeline/ClientApp/.eslintrc.js deleted file mode 100644 index 5232e5d8..00000000 --- a/Timeline/ClientApp/.eslintrc.js +++ /dev/null @@ -1,48 +0,0 @@ -module.exports = { - env: { - browser: true, - es6: true, - }, - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:@typescript-eslint/eslint-recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', - 'prettier', - 'prettier/react', - 'prettier/@typescript-eslint', - 'plugin:react-hooks/recommended', - ], - globals: { - Atomics: 'readonly', - SharedArrayBuffer: 'readonly', - }, - parser: '@typescript-eslint/parser', - parserOptions: { - project: ['./src/app/tsconfig.json', './src/sw/tsconfig.json'], - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 2018, - sourceType: 'module', - }, - plugins: ['react', '@typescript-eslint', 'react-hooks'], - settings: { - react: { - version: 'detect', - }, - }, - rules: { - 'react/prop-types': 'off', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - '@typescript-eslint/explicit-function-return-type': [ - 'warn', - { - allowExpressions: true, - allowTypedFunctionExpressions: true, - allowHigherOrderFunctions: true, - }, - ], - }, -}; diff --git a/Timeline/ClientApp/.gitattributes b/Timeline/ClientApp/.gitattributes deleted file mode 100644 index c1aa21ac..00000000 --- a/Timeline/ClientApp/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -.yarn/** linguist-vendored
\ No newline at end of file diff --git a/Timeline/ClientApp/.gitignore b/Timeline/ClientApp/.gitignore deleted file mode 100644 index a1707cf2..00000000 --- a/Timeline/ClientApp/.gitignore +++ /dev/null @@ -1,30 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules - -.yarn/* -!.yarn/releases -!.yarn/plugins -.pnp.* - -# 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 deleted file mode 100644 index e871b731..00000000 --- a/Timeline/ClientApp/.prettierrc.yaml +++ /dev/null @@ -1 +0,0 @@ -singleQuote: true
diff --git a/Timeline/ClientApp/.yarnrc.yml b/Timeline/ClientApp/.yarnrc.yml deleted file mode 100644 index 1fe52728..00000000 --- a/Timeline/ClientApp/.yarnrc.yml +++ /dev/null @@ -1,7 +0,0 @@ -plugins:
- - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.js
- spec: "@yarnpkg/plugin-interactive-tools"
- - path: .yarn/plugins/@yarnpkg/plugin-typescript.js
- spec: "@yarnpkg/plugin-typescript"
-
-yarnPath: .yarn/releases/yarn-berry.js
diff --git a/Timeline/ClientApp/LICENSE b/Timeline/ClientApp/LICENSE deleted file mode 100644 index c5dd66a5..00000000 --- a/Timeline/ClientApp/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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/package.json b/Timeline/ClientApp/package.json deleted file mode 100644 index fa9cca09..00000000 --- a/Timeline/ClientApp/package.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "name": "timeline", - "version": "0.1.0", - "private": true, - "homepage": "https://crupest.xyz", - "dependencies": { - "axios": "^0.19.2", - "bootstrap": "^4.5.0", - "bootstrap-icons": "^1.0.0-alpha4", - "classnames": "^2.2.6", - "clsx": "^1.1.1", - "core-js": "^3.6.5", - "i18next": "^19.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-inlinesvg": "^1.2.0", - "react-responsive": "^8.1.0", - "react-router": "^5.2.0", - "react-router-dom": "^5.2.0", - "reactstrap": "^8.4.1", - "regenerator-runtime": "^0.13.5", - "rxjs": "^6.5.5", - "workbox-precaching": "^5.1.3", - "workbox-routing": "^5.1.3", - "workbox-strategies": "^5.1.3", - "workbox-window": "^5.1.3", - "xregexp": "^4.3.0" - }, - "scripts": { - "start": "webpack-dev-server --config ./webpack.config.dev.js", - "build": "webpack --config ./webpack.config.prod.js", - "install-and-start": "yarn && webpack-dev-server --config ./webpack.config.dev.js", - "lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "devDependencies": { - "@babel/core": "^7.10.2", - "@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.8.3", - "@babel/preset-env": "^7.10.2", - "@babel/preset-react": "^7.10.1", - "@babel/preset-typescript": "^7.10.1", - "@hot-loader/react-dom": "^16.13.0", - "@types/classnames": "^2.2.10", - "@types/lodash": "^4.14.155", - "@types/node": "^14.0.11", - "@types/react": "^16.9.35", - "@types/react-dom": "^16.9.8", - "@types/react-responsive": "^8.0.2", - "@types/react-router": "^5.1.7", - "@types/react-router-dom": "^5.1.5", - "@types/reactstrap": "^8.4.2", - "@types/webpack-env": "^1.15.2", - "@types/xregexp": "^4.3.0", - "@typescript-eslint/eslint-plugin": "^3.1.0", - "@typescript-eslint/parser": "^3.1.0", - "autoprefixer": "^9.8.0", - "babel-loader": "^8.1.0", - "babel-plugin-transform-builtin-extend": "^1.1.2", - "clean-webpack-plugin": "^3.0.0", - "copy-webpack-plugin": "^6.0.2", - "css-loader": "^3.5.3", - "eslint": "^7.2.0", - "eslint-config-prettier": "^6.11.0", - "eslint-plugin-prettier": "^3.1.3", - "eslint-plugin-react": "^7.20.0", - "eslint-plugin-react-hooks": "^4.0.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", - "pnp-webpack-plugin": "^1.6.4", - "postcss-loader": "^3.0.0", - "prettier": "^2.0.5", - "sass": "^1.26.8", - "sass-loader": "^8.0.2", - "style-loader": "^1.2.1", - "typescript": "^3.9.5", - "url-loader": "^4.1.0", - "webpack": "^4.43.0", - "webpack-cli": "^3.3.11", - "webpack-dev-server": "^3.11.0", - "workbox-webpack-plugin": "^5.1.3" - } -} diff --git a/Timeline/ClientApp/public/android-chrome-192x192.png b/Timeline/ClientApp/public/android-chrome-192x192.png Binary files differdeleted file mode 100644 index da9b6b81..00000000 --- a/Timeline/ClientApp/public/android-chrome-192x192.png +++ /dev/null diff --git a/Timeline/ClientApp/public/android-chrome-512x512.png b/Timeline/ClientApp/public/android-chrome-512x512.png Binary files differdeleted file mode 100644 index fa84e055..00000000 --- a/Timeline/ClientApp/public/android-chrome-512x512.png +++ /dev/null diff --git a/Timeline/ClientApp/public/apple-touch-icon.png b/Timeline/ClientApp/public/apple-touch-icon.png Binary files differdeleted file mode 100644 index d5a3fb45..00000000 --- a/Timeline/ClientApp/public/apple-touch-icon.png +++ /dev/null diff --git a/Timeline/ClientApp/public/browserconfig.xml b/Timeline/ClientApp/public/browserconfig.xml deleted file mode 100644 index a47e5a5b..00000000 --- a/Timeline/ClientApp/public/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ -<?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 Binary files differdeleted file mode 100644 index 6c978995..00000000 --- a/Timeline/ClientApp/public/favicon-16x16.png +++ /dev/null diff --git a/Timeline/ClientApp/public/favicon-32x32.png b/Timeline/ClientApp/public/favicon-32x32.png Binary files differdeleted file mode 100644 index bbde902f..00000000 --- a/Timeline/ClientApp/public/favicon-32x32.png +++ /dev/null diff --git a/Timeline/ClientApp/public/favicon.ico b/Timeline/ClientApp/public/favicon.ico Binary files differdeleted file mode 100644 index d4cd3db6..00000000 --- a/Timeline/ClientApp/public/favicon.ico +++ /dev/null diff --git a/Timeline/ClientApp/public/mstile-144x144.png b/Timeline/ClientApp/public/mstile-144x144.png Binary files differdeleted file mode 100644 index 61eaaf43..00000000 --- a/Timeline/ClientApp/public/mstile-144x144.png +++ /dev/null diff --git a/Timeline/ClientApp/public/mstile-150x150.png b/Timeline/ClientApp/public/mstile-150x150.png Binary files differdeleted file mode 100644 index 85fa83ee..00000000 --- a/Timeline/ClientApp/public/mstile-150x150.png +++ /dev/null diff --git a/Timeline/ClientApp/public/mstile-310x150.png b/Timeline/ClientApp/public/mstile-310x150.png Binary files differdeleted file mode 100644 index 41889953..00000000 --- a/Timeline/ClientApp/public/mstile-310x150.png +++ /dev/null diff --git a/Timeline/ClientApp/public/mstile-310x310.png b/Timeline/ClientApp/public/mstile-310x310.png Binary files differdeleted file mode 100644 index cddce02e..00000000 --- a/Timeline/ClientApp/public/mstile-310x310.png +++ /dev/null diff --git a/Timeline/ClientApp/public/mstile-70x70.png b/Timeline/ClientApp/public/mstile-70x70.png Binary files differdeleted file mode 100644 index 52f59d43..00000000 --- a/Timeline/ClientApp/public/mstile-70x70.png +++ /dev/null diff --git a/Timeline/ClientApp/public/safari-pinned-tab.svg b/Timeline/ClientApp/public/safari-pinned-tab.svg deleted file mode 100644 index 8886ebcc..00000000 --- a/Timeline/ClientApp/public/safari-pinned-tab.svg +++ /dev/null @@ -1,25 +0,0 @@ -<?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 deleted file mode 100644 index 6746fe59..00000000 --- a/Timeline/ClientApp/public/site.webmanifest +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$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/App.tsx b/Timeline/ClientApp/src/app/App.tsx deleted file mode 100644 index 83ea4ea5..00000000 --- a/Timeline/ClientApp/src/app/App.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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(() => { - void checkUserLoginState(); - }, []); - - let body; - if (user === undefined) { - body = <LoadingPage />; - } else { - body = ( - <Router> - <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> - </Router> - ); - } - - return ( - <React.Suspense fallback={<LoadingPage />}> - {body} - <AlertHost /> - </React.Suspense> - ); -}; - -export default hot(App); diff --git a/Timeline/ClientApp/src/app/about/About.tsx b/Timeline/ClientApp/src/app/about/About.tsx deleted file mode 100644 index 841ec6fe..00000000 --- a/Timeline/ClientApp/src/app/about/About.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React from 'react'; -import { useTranslation, Trans } from 'react-i18next'; - -import authorAvatarUrl from './author-avatar.png'; -import githubLogoUrl from './github.png'; - -import AppBar from '../common/AppBar'; - -const frontendCredits: { - name: string; - url: string; -}[] = [ - { - name: 'reactjs', - url: 'https://reactjs.org', - }, - { - name: 'typescript', - url: 'https://www.typescriptlang.org', - }, - { - name: 'bootstrap', - url: 'https://getbootstrap.com', - }, - { - name: 'reactstrap', - url: 'https://reactstrap.github.io', - }, - { - name: 'babeljs', - url: 'https://babeljs.io', - }, - { - name: 'webpack', - url: 'https://webpack.js.org', - }, - { - name: 'sass', - url: 'https://sass-lang.com', - }, - { - name: 'eslint', - url: 'https://eslint.org', - }, - { - name: 'prettier', - url: 'https://prettier.io', - }, - { - name: 'pepjs', - url: 'https://github.com/jquery/PEP', - }, - { - name: 'react-inlinesvg', - url: 'https://github.com/gilbarbara/react-inlinesvg', - }, -]; - -const backendCredits: { - name: string; - url: string; -}[] = [ - { - name: 'ASP.NET Core', - url: 'https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core', - }, - { name: 'sqlite', url: 'https://sqlite.org' }, - { - name: 'ImageSharp', - url: 'https://github.com/SixLabors/ImageSharp', - }, -]; - -const About: React.FC = () => { - const { t } = useTranslation(); - - return ( - <> - <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" - > - <img - src={githubLogoUrl} - className="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" - target="_blank" - rel="noopener noreferrer" - > - {t('about.site.repo')} - </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.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.name} - </a> - </li> - ); - })} - <li>...</li> - </ul> - </div> - </div> - </> - ); -}; - -export default About; diff --git a/Timeline/ClientApp/src/app/about/about.sass b/Timeline/ClientApp/src/app/about/about.sass deleted file mode 100644 index 3b5840cd..00000000 --- a/Timeline/ClientApp/src/app/about/about.sass +++ /dev/null @@ -1,4 +0,0 @@ -.about-link-icon - @extend .mx-2 - width: 1.2em - height: 1.2em diff --git a/Timeline/ClientApp/src/app/about/author-avatar.png b/Timeline/ClientApp/src/app/about/author-avatar.png Binary files differdeleted file mode 100644 index d890d8d0..00000000 --- a/Timeline/ClientApp/src/app/about/author-avatar.png +++ /dev/null diff --git a/Timeline/ClientApp/src/app/about/github.png b/Timeline/ClientApp/src/app/about/github.png Binary files differdeleted file mode 100644 index ea6ff545..00000000 --- a/Timeline/ClientApp/src/app/about/github.png +++ /dev/null diff --git a/Timeline/ClientApp/src/app/admin/Admin.tsx b/Timeline/ClientApp/src/app/admin/Admin.tsx deleted file mode 100644 index 9e7d592f..00000000 --- a/Timeline/ClientApp/src/app/admin/Admin.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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/app/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx deleted file mode 100644 index ba14fc84..00000000 --- a/Timeline/ClientApp/src/app/admin/UserAdmin.tsx +++ /dev/null @@ -1,463 +0,0 @@ -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<User[]>(`${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: TDelete; 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; - void fetchUserList(props.user.token).then((us) => { - if (subscribe) { - setUsers(us); - } - }); - return () => { - subscribe = false; - }; - }, [props.user]); - - let dialogNode: React.ReactNode; - if (dialog) - switch (dialog.type) { - case 'create': - dialogNode = ( - <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(); - const findedUser = users.find( - (u) => u.username === dialog.username - ); - if (findedUser) findedUser.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(); - const findedUser = users.find( - (u) => u.username === dialog.username - ); - if (findedUser) findedUser.administrator = newPermission; - return users; - }); - }} - /> - ); - break; - } - } - - if (users) { - const userComponents = users.map((user) => { - return ( - <UserItem - key={user.username} - user={user} - onContextMenu={(item) => { - setDialog( - item === kChangePermission - ? { - type: kChangePermission, - username: user.username, - newPermission: !user.administrator, - } - : { - type: item, - username: user.username, - } - ); - }} - /> - ); - }); - - 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/app/common.ts b/Timeline/ClientApp/src/app/common.ts deleted file mode 100644 index 56fdbc0e..00000000 --- a/Timeline/ClientApp/src/app/common.ts +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Observable, Subject } from 'rxjs'; - -// This error is thrown when ui goes wrong with bad logic. -// Such as a variable should not be null, but it does. -// This error should never occur. If it does, it indicates there is some logic bug in codes. -export class UiLogicError extends Error {} - -export function useEventEmiiter(): [() => Observable<null>, () => void] { - const ref = React.useRef<Subject<null> | null>(null); - - return React.useMemo(() => { - const getter = (): Subject<null> => { - if (ref.current == null) { - ref.current = new Subject<null>(); - } - return ref.current; - }; - const trigger = (): void => { - getter().next(null); - }; - return [getter, trigger]; - }, []); -} - -export function useValueEventEmiiter<T>(): [ - () => Observable<T>, - (value: T) => void -] { - const ref = React.useRef<Subject<T> | null>(null); - - return React.useMemo(() => { - const getter = (): Subject<T> => { - if (ref.current == null) { - ref.current = new Subject<T>(); - } - return ref.current; - }; - const trigger = (value: T): void => { - getter().next(value); - }; - return [getter, trigger]; - }, []); -} diff --git a/Timeline/ClientApp/src/app/common/AlertHost.tsx b/Timeline/ClientApp/src/app/common/AlertHost.tsx deleted file mode 100644 index 23b6c5f4..00000000 --- a/Timeline/ClientApp/src/app/common/AlertHost.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useCallback } from 'react'; -import { Alert } from 'reactstrap'; -import without from 'lodash/without'; -import concat from 'lodash/concat'; - -import { - alertService, - AlertInfoEx, - kAlertHostId, - AlertInfo, -} from './alert-service'; -import { useTranslation } from 'react-i18next'; - -interface AutoCloseAlertProps { - alert: AlertInfo; - close: () => void; -} - -export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { - const { alert } = props; - const { dismissTime } = alert; - - const { t } = useTranslation(); - - React.useEffect(() => { - const tag = - dismissTime === 'never' - ? null - : typeof dismissTime === 'number' - ? window.setTimeout(props.close, dismissTime) - : window.setTimeout(props.close, 5000); - return () => { - if (tag != null) { - window.clearTimeout(tag); - } - }; - }, [dismissTime, props.close]); - - return ( - <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}> - {(() => { - const { message } = alert; - if (typeof message === 'function') { - const Message = message; - return <Message />; - } else if (typeof message === 'object' && message.type === 'i18n') { - return t(message.key); - } else return 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/app/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx deleted file mode 100644 index f75fe08f..00000000 --- a/Timeline/ClientApp/src/app/common/AppBar.tsx +++ /dev/null @@ -1,107 +0,0 @@ -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/app/common/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx deleted file mode 100644 index 20da7b71..00000000 --- a/Timeline/ClientApp/src/app/common/FileInput.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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/app/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/common/ImageCropper.tsx deleted file mode 100644 index 7cb8d3cf..00000000 --- a/Timeline/ClientApp/src/app/common/ImageCropper.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import * as React from 'react'; -import clsx from 'clsx'; - -import { UiLogicError } from '../common'; - -export interface Clip { - left: number; - top: number; - width: number; -} - -interface NormailizedClip extends Clip { - height: number; -} - -interface ImageInfo { - width: number; - height: number; - landscape: boolean; - ratio: number; - maxClipWidth: number; - maxClipHeight: number; -} - -interface ImageCropperSavedState { - clip: NormailizedClip; - x: number; - y: number; - pointerId: number; -} - -export interface ImageCropperProps { - clip: Clip | null; - imageUrl: string; - onChange: (clip: Clip) => void; - imageElementCallback?: (element: HTMLImageElement | null) => void; - className?: string; -} - -const ImageCropper = (props: ImageCropperProps): React.ReactElement => { - const { clip, imageUrl, onChange, imageElementCallback, className } = props; - - const [oldState, setOldState] = React.useState<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 imgElementRef = React.useRef<HTMLImageElement | null>(null); - - const onImageRef = React.useCallback( - (e: HTMLImageElement | null) => { - imgElementRef.current = e; - if (imageElementCallback != null && e == null) { - imageElementCallback(null); - } - }, - [imageElementCallback] - ); - - const onImageLoad = React.useCallback( - (e: React.SyntheticEvent<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 { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError('Image element is null.'); - - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.y / imgElement.height, - }; - - const newRatio = { - x: oldClip.left + moveRatio.x, - y: oldClip.top + moveRatio.y, - }; - if (newRatio.x < 0) { - newRatio.x = 0; - } else if (newRatio.x > 1 - oldClip.width) { - newRatio.x = 1 - oldClip.width; - } - if (newRatio.y < 0) { - newRatio.y = 0; - } else if (newRatio.y > 1 - oldClip.height) { - newRatio.y = 1 - oldClip.height; - } - - onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); - }, - [oldState, onChange] - ); - - const onHandlerPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; - - const oldClip = oldState.clip; - - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; - - const ratio = imageInfo == null ? 1 : imageInfo.ratio; - - const { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError('Image element is null.'); - - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.x / imgElement.width / ratio, - }; - - const newRatio = { - x: oldClip.width + moveRatio.x, - y: oldClip.height + moveRatio.y, - }; - - const maxRatio = { - x: Math.min(1 - oldClip.left, newRatio.x), - y: Math.min(1 - oldClip.top, newRatio.y), - }; - - const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); - - let newWidth; - if (newRatio.x < 0) { - newWidth = 0; - } else if (newRatio.x > maxWidthRatio) { - newWidth = maxWidthRatio; - } else { - newWidth = newRatio.x; - } - - onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); - }, - [imageInfo, oldState, onChange] - ); - - const toPercentage = (n: number): string => `${n}%`; - - // fuck!!! I just can't find a better way to implement this in pure css - const containerStyle: React.CSSProperties = (() => { - if (imageInfo == null) { - return { width: '100%', paddingTop: '100%', height: 0 }; - } else { - if (imageInfo.ratio > 1) { - return { - width: toPercentage(100 / imageInfo.ratio), - paddingTop: '100%', - height: 0, - }; - } else { - return { - width: '100%', - paddingTop: toPercentage(100 * imageInfo.ratio), - height: 0, - }; - } - } - })(); - - return ( - <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: toPercentage(c.left * 100), - top: toPercentage(c.top * 100), - width: toPercentage(c.width * 100), - height: toPercentage(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/app/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/common/LoadingPage.tsx deleted file mode 100644 index 81bc74cf..00000000 --- a/Timeline/ClientApp/src/app/common/LoadingPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx deleted file mode 100644 index 30db4053..00000000 --- a/Timeline/ClientApp/src/app/common/OperationDialog.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Spinner, - Container, - ModalBody, - Label, - Input, - FormGroup, - FormFeedback, - ModalFooter, - Button, - Modal, - ModalHeader, - FormText, -} from 'reactstrap'; - -import { UiLogicError } from '../common'; - -const DefaultProcessPrompt: React.FC = (_) => { - return ( - <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 UiLogicError('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: unknown) => { - setStep({ - type: 'success', - data: d, - }); - }, - (e: unknown) => { - setStep({ - type: 'failure', - data: e, - }); - } - ); - }; - - let body: React.ReactNode; - if (step === 'input') { - let inputPrompt = - typeof props.inputPrompt === 'function' - ? props.inputPrompt() - : props.inputPrompt; - inputPrompt = <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) { - newInputError[+index] = error as OperationInputOptionalError; - } - } - return newInputError; - } - }; - - const validateAll = (): boolean => { - let newInputError = inputError; - for (let i = 0; i < inputScheme.length; i++) { - const item = inputScheme[i]; - if (item.type === 'text') { - newInputError = calculateError( - newInputError, - i, - item.validator?.(values[i] as string, values) - ); - } - } - const result = !testErrorInfo(newInputError); - setInputError(newInputError); - return result; - }; - - body = ( - <> - <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/app/common/SearchInput.tsx b/Timeline/ClientApp/src/app/common/SearchInput.tsx deleted file mode 100644 index 50c252fa..00000000 --- a/Timeline/ClientApp/src/app/common/SearchInput.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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/app/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx deleted file mode 100644 index 8dd9e97b..00000000 --- a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/app/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx deleted file mode 100644 index 58a429d8..00000000 --- a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/app/common/alert-service.ts b/Timeline/ClientApp/src/app/common/alert-service.ts deleted file mode 100644 index 79eecc82..00000000 --- a/Timeline/ClientApp/src/app/common/alert-service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import pull from 'lodash/pull'; - -export interface AlertInfo { - type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; - message: string | React.FC<unknown> | { type: 'i18n'; key: string }; - dismissTime?: number | 'never'; -} - -export interface AlertInfoEx extends AlertInfo { - id: number; -} - -export type AlertConsumer = (alerts: AlertInfoEx) => void; - -export class AlertService { - private consumers: AlertConsumer[] = []; - private savedAlerts: AlertInfoEx[] = []; - private currentId = 1; - - private produce(alert: AlertInfoEx): void { - for (const consumer of this.consumers) { - consumer(alert); - } - } - - registerConsumer(consumer: AlertConsumer): void { - this.consumers.push(consumer); - if (this.savedAlerts.length !== 0) { - for (const alert of this.savedAlerts) { - this.produce(alert); - } - this.savedAlerts = []; - } - } - - unregisterConsumer(consumer: AlertConsumer): void { - pull(this.consumers, consumer); - } - - push(alert: AlertInfo): void { - const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ }; - if (this.consumers.length === 0) { - this.savedAlerts.push(newAlert); - } else { - this.produce(newAlert); - } - } -} - -export const alertService = new AlertService(); - -export function pushAlert(alert: AlertInfo): void { - alertService.push(alert); -} - -export const kAlertHostId = 'alert-host'; - -export function getAlertHost(): HTMLElement | null { - return document.getElementById(kAlertHostId); -} diff --git a/Timeline/ClientApp/src/app/common/alert.sass b/Timeline/ClientApp/src/app/common/alert.sass deleted file mode 100644 index 5b6e65c2..00000000 --- a/Timeline/ClientApp/src/app/common/alert.sass +++ /dev/null @@ -1,15 +0,0 @@ -.alert-container - position: fixed - z-index: $zindex-popover - -@include media-breakpoint-up(sm) - .alert-container - bottom: 0 - right: 0 - -@include media-breakpoint-down(sm) - .alert-container - bottom: 0 - right: 0 - left: 0 - text-align: center diff --git a/Timeline/ClientApp/src/app/common/common.sass b/Timeline/ClientApp/src/app/common/common.sass deleted file mode 100644 index 15d34d7c..00000000 --- a/Timeline/ClientApp/src/app/common/common.sass +++ /dev/null @@ -1,33 +0,0 @@ -.image-cropper-container - position: relative - box-sizing: border-box - user-select: none - -.image-cropper-container img - position: absolute - left: 0 - top: 0 - width: 100% - height: 100% - -.image-cropper-mask-container - position: absolute - left: 0 - top: 0 - right: 0 - bottom: 0 - overflow: hidden - -.image-cropper-mask - position: absolute - box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%) - touch-action: none - -.image-cropper-handler - position: absolute - width: 26px - height: 26px - border: black solid 2px - border-radius: 50% - background: white - touch-action: none diff --git a/Timeline/ClientApp/src/app/config.ts b/Timeline/ClientApp/src/app/config.ts deleted file mode 100644 index 081a2cf6..00000000 --- a/Timeline/ClientApp/src/app/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const apiBaseUrl = '/api'; diff --git a/Timeline/ClientApp/src/app/data/base64.ts b/Timeline/ClientApp/src/app/data/base64.ts deleted file mode 100644 index 7f7c6fcc..00000000 --- a/Timeline/ClientApp/src/app/data/base64.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts deleted file mode 100644 index 1cdf93f1..00000000 --- a/Timeline/ClientApp/src/app/data/common.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 interface CommonErrorResponse { - code: number; - message: string; -} - -export function extractErrorCode(error: AxiosError): number | null { - const { response } = error as AxiosError<CommonErrorResponse>; - const code = response && response.data && response.data.code; - if (typeof code === 'number') { - return code; - } else { - return null; - } -} diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts deleted file mode 100644 index a8875701..00000000 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ /dev/null @@ -1,346 +0,0 @@ -import axios from 'axios'; -import XRegExp from 'xregexp'; - -import { base64 } from './base64'; -import { apiBaseUrl } from '../config'; -import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user'; -import { UiLogicError } from '../common'; - -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 { - uniqueId: string; - 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 UiLogicError('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') { - void 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/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts deleted file mode 100644 index 8f787478..00000000 --- a/Timeline/ClientApp/src/app/data/user.ts +++ /dev/null @@ -1,224 +0,0 @@ -import axios, { AxiosError } from 'axios'; -import { useState, useEffect } from 'react'; -import { BehaviorSubject, Observable } from 'rxjs'; - -import { apiBaseUrl } from '../config'; -import { extractErrorCode } from './common'; -import { pushAlert } from '../common/alert-service'; -import { i18nPromise } from '../i18n'; -import { UiLogicError } from '../common'; - -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 UiLogicError("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, - }; - void i18nPromise.then((t) => { - pushAlert({ - type: 'success', - message: t('user.welcomeBack'), - }); - }); - return user; - }, - (e: AxiosError) => { - if (e.response != null) { - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - void i18nPromise.then((t) => { - pushAlert({ - type: 'danger', - message: t('user.verifyTokenFailed'), - }); - }); - } else { - void 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 UiLogicError('Already login.'); - } - return axios - .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 }) - .catch((e: AxiosError) => { - if (extractErrorCode(e) === 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 UiLogicError('Please check user first.'); - } - if (getCurrentUser() === null) { - throw new UiLogicError('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 UiLogicError( - "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 UiLogicError( - "This is a logic error in user module. User emitted can't be undefined later." - ); - } - setUser(u); - }); - return () => { - sub.unsubscribe(); - }; - }); - return user; -} - -export function useUserLoggedIn(): UserWithToken { - const user = useUser(); - if (user == null) { - throw new UiLogicError('You assert user has logged in but actually not.'); - } - return user; -} - -export function fetchUser(username: string): Promise<User> { - return axios - .get<User>(`${apiBaseUrl}/users/${username}`) - .then((res) => res.data); -} diff --git a/Timeline/ClientApp/src/app/helper.ts b/Timeline/ClientApp/src/app/helper.ts deleted file mode 100644 index b48829b5..00000000 --- a/Timeline/ClientApp/src/app/helper.ts +++ /dev/null @@ -1,35 +0,0 @@ -//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/app/home/Home.tsx b/Timeline/ClientApp/src/app/home/Home.tsx deleted file mode 100644 index 775a1a87..00000000 --- a/Timeline/ClientApp/src/app/home/Home.tsx +++ /dev/null @@ -1,155 +0,0 @@ -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); - void axios - .get<TimelineInfo[]>(`${apiBaseUrl}/timelines?visibility=public`) - .then((res) => { - if (subscribe) { - setPublicTimelines(res.data); - } - }); - } else { - setPublicTimelines(undefined); - void axios - .get<TimelineInfo[]>( - `${apiBaseUrl}/timelines?relate=${user.username}&relateType=own` - ) - .then((res) => { - if (subscribe) { - setOwnTimelines(res.data); - } - }); - void 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/app/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx deleted file mode 100644 index ca77ab43..00000000 --- a/Timeline/ClientApp/src/app/home/TimelineBoard.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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-board-item"> - {isPersonal ? ( - <UserTimelineLogo className="icon" /> - ) : ( - <TimelineLogo className="icon" /> - )} - <Link to={url}>{name}</Link> - </div> - ); - }); - } - })()} - </div> - ); -}; - -export default TimelineBoard; diff --git a/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx deleted file mode 100644 index ea1c6beb..00000000 --- a/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithUser.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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/app/home/TimelineBoardAreaWithoutUser.tsx b/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx deleted file mode 100644 index c79558b6..00000000 --- a/Timeline/ClientApp/src/app/home/TimelineBoardAreaWithoutUser.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/app/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx deleted file mode 100644 index 925c6c76..00000000 --- a/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { useHistory } from 'react-router'; -import axios from 'axios'; - -import { apiBaseUrl } from '../config'; -import { useUserLoggedIn } 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 = useUserLoggedIn(); - - 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 string}`} - /> - ); -}; - -export default TimelineCreateDialog; diff --git a/Timeline/ClientApp/src/app/home/home.sass b/Timeline/ClientApp/src/app/home/home.sass deleted file mode 100644 index f5d6ffc3..00000000 --- a/Timeline/ClientApp/src/app/home/home.sass +++ /dev/null @@ -1,13 +0,0 @@ -.timeline-board-item - font-size: 1.1em - @extend .my-2 - .icon - height: 1.3em - @extend .mr-2 - -.timeline-board - @extend .cru-card - @extend .d-flex - @extend .flex-column - @extend .p-3 - min-height: 200px diff --git a/Timeline/ClientApp/src/app/i18n.ts b/Timeline/ClientApp/src/app/i18n.ts deleted file mode 100644 index bda33fe8..00000000 --- a/Timeline/ClientApp/src/app/i18n.ts +++ /dev/null @@ -1,79 +0,0 @@ -import i18n, { BackendModule, ResourceKey } from 'i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import { initReactI18next } from 'react-i18next'; - -const backend: BackendModule = { - type: 'backend', - async read(language, namespace, callback) { - function error(message: string): void { - callback(new Error(message), false); - } - - function success(result: ResourceKey): void { - callback(null, result); - } - - if (namespace !== 'translation') { - error("Namespace must be 'translation'."); - } - - if (language === 'en') { - const res = ( - await import( - /* webpackChunkName: "locales-en" */ './locales/en/translation' - ) - ).default; - success(res); - } else if (language === 'zh-cn' || language === 'zh') { - const res = ( - await import( - /* webpackChunkName: "locales-zh" */ './locales/zh/translation' - ) - ).default; - success(res); - } else { - error(`Language ${language} is not supported.`); - } - }, - init() {}, // eslint-disable-line @typescript-eslint/no-empty-function - create() {}, // eslint-disable-line @typescript-eslint/no-empty-function -}; - -export const i18nPromise = i18n - .use(LanguageDetector) - .use(backend) - .use(initReactI18next) // bind react-i18next to the instance - .init({ - fallbackLng: false, - lowerCaseLng: true, - - debug: process.env.NODE_ENV === 'development', - - interpolation: { - escapeValue: false, // not needed for react!! - }, - - // react i18next special options (optional) - // override if needed - omit if ok with defaults - /* - react: { - bindI18n: 'languageChanged', - bindI18nStore: '', - transEmptyNodeValue: '', - transSupportBasicHtmlNodes: true, - transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'], - useSuspense: true, - } - */ - }); - -if (module.hot) { - module.hot.accept( - ['./locales/en/translation', './locales/zh/translation'], - () => { - void i18n.reloadResources(); - } - ); -} - -export default i18n; diff --git a/Timeline/ClientApp/src/app/index.sass b/Timeline/ClientApp/src/app/index.sass deleted file mode 100644 index ef0b03ba..00000000 --- a/Timeline/ClientApp/src/app/index.sass +++ /dev/null @@ -1,82 +0,0 @@ -@import '~bootstrap/scss/bootstrap' - -@import './common/common' -@import './common/alert' -@import './home/home' -@import './about/about' -@import './timeline/timeline' -@import './timeline/timeline-ui' -@import './user/user-page' - -body - margin: 0 - -#app - display: flex - flex-direction: column - -small - line-height: 1.2 - -.width-1px - width: 1px - -.flex-fix-length - flex-grow: 0 - flex-shrink: 0 - -.position-lt - left: 0 - top: 0 - -.position-rb - right: 0 - bottom: 0 - -.app-bar - z-index: 1035 - -.avatar - width: 60px - -.avatar.large - width: 100px - -.avatar.small - width: 40px - -.mt-appbar - margin-top: 56px - -.icon-button - font-size: 1.4em - -.large-icon-button - font-size: 1.6em - -.cursor-pointer - cursor: pointer - -textarea - resize: none - -.white-space-no-wrap - white-space: nowrap - -.cru-card - @extend .shadow - @extend .border - @extend .border-primary - @extend .rounded - @extend .bg-light - -.full-viewport-center-child - position: fixed - width: 100vw - height: 100vh - display: flex - justify-content: center - align-items: center - -.text-orange - color: $orange diff --git a/Timeline/ClientApp/src/app/index.tsx b/Timeline/ClientApp/src/app/index.tsx deleted file mode 100644 index 086bd6e8..00000000 --- a/Timeline/ClientApp/src/app/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import 'regenerator-runtime'; -import 'core-js/modules/es.promise'; -import 'core-js/modules/es.array.iterator'; -import 'pepjs'; - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import './index.sass'; - -import './i18n'; - -import App from './App'; - -ReactDOM.render(<App />, document.getElementById('app')); diff --git a/Timeline/ClientApp/src/app/locales/en/translation.ts b/Timeline/ClientApp/src/app/locales/en/translation.ts deleted file mode 100644 index 6bac41f9..00000000 --- a/Timeline/ClientApp/src/app/locales/en/translation.ts +++ /dev/null @@ -1,194 +0,0 @@ -import TranslationResource from '../scheme'; - -const translation: TranslationResource = { - welcome: 'Welcome!', - search: 'Search', - serviceWorker: { - availableOffline: - 'Timeline is now cached in your computer and you can use it offline. 🎉🎉🎉', - upgradePrompt: 'App is getting a new version!', - upgradeNow: 'Update Now', - upgradeSuccess: - 'Congratulations! App update succeeded! Still you can use it offline. 🎉🎉🎉', - externalActivatedPrompt: - 'A new version of app is activated. Please refresh the page. Or it may be broken.', - reloadNow: 'Refresh Now', - }, - nav: { - settings: 'Settings', - login: 'Login', - about: 'About', - }, - chooseImage: 'Choose a image', - loadImageError: 'Failed to load image.', - home: { - go: 'Go!', - allTimeline: 'All Timelines', - joinTimeline: 'Joined Timelines', - ownTimeline: 'Owned Timelines', - createButton: 'Create Timeline', - createDialog: { - title: 'Create Timeline!', - name: 'Name', - nameFormat: - 'Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.', - badFormat: 'Bad format.', - noEmpty: 'Empty is not allowed.', - tooLong: 'Too long.', - }, - }, - operationDialog: { - retry: 'Retry', - nextStep: 'Next', - previousStep: 'Previous', - confirm: 'Confirm', - cancel: 'Cancel', - ok: 'OK!', - processing: 'Processing...', - success: 'Success!', - error: 'An error occured.', - }, - timeline: { - messageCantSee: 'Sorry, you are not allowed to see this timeline.😅', - userNotExist: 'The user does not exist!', - timelineNotExist: 'The timeline does not exist!', - manage: 'Manage', - memberButton: 'Member', - send: 'Send', - deletePostFailed: 'Failed to delete post.', - sendPostFailed: 'Failed to send post.', - visibility: { - public: 'public to everyone', - register: 'only registed people can see', - private: 'only members can see', - }, - visibilityTooltip: { - public: - 'Everyone including those without accounts can see content of the timeline.', - register: - 'Only those who have an account and logined can see content of the timeline.', - private: 'Only members of this timeline can see content of the timeline.', - }, - dialogChangeProperty: { - title: 'Change Timeline Properties', - visibility: 'Visibility', - description: 'Description', - }, - member: { - alreadyMember: 'The user is already a member.', - add: 'Add', - remove: 'Remove', - }, - manageItem: { - nickname: 'Nickname', - avatar: 'Avatar', - property: 'Timeline Property', - member: 'Timeline Member', - delete: 'Delete Timeline', - }, - deleteDialog: { - title: 'Delete Timeline', - inputPrompt: - 'This is a dangerous action. If you are sure to delete timeline<1>{{name}}</1>, please input its name below and click confirm button.', - notMatch: 'Name does not match.', - }, - post: { - deleteDialog: { - title: 'Confirm Delete', - prompt: - 'Are you sure to delete the post? This operation is not recoverable.', - }, - }, - }, - user: { - username: 'username', - password: 'password', - login: 'login', - rememberMe: 'Remember Me', - welcomeBack: 'Welcome back!', - verifyTokenFailed: 'User login info is expired. Please login again!', - verifyTokenFailedNetwork: - 'Verifying user login info failed. Please check your network and refresh page!', - }, - login: { - emptyUsername: "Username can't be empty.", - emptyPassword: "Password can't be empty.", - badCredential: 'Username or password is invalid.', - alreadyLogin: 'Already login! Redirect to home page in 3s!', - }, - userPage: { - dialogChangeNickname: { - title: 'Change Nickname', - inputLabel: 'New nickname', - }, - dialogChangeAvatar: { - title: 'Change Avatar', - previewImgAlt: 'preview', - prompt: { - select: 'Please select a picture.', - crop: 'Please crop the picture.', - processingCrop: 'Cropping picture...', - uploading: 'Uploading...', - preview: 'Please preview avatar', - }, - upload: 'upload', - }, - }, - settings: { - subheaders: { - account: 'Account', - customization: 'Customization', - }, - languagePrimary: 'Choose display language.', - languageSecondary: - 'You language preference will be saved locally. Next time you visit this page, last language option will be used.', - changePassword: "Change account's password.", - logout: 'Log out this account.', - gotoSelf: - 'Click here to go to timeline of myself to change nickname and avatar.', - dialogChangePassword: { - title: 'Change Password', - prompt: - 'You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.', - inputOldPassword: 'Old password', - inputNewPassword: 'New password', - inputRetypeNewPassword: 'Retype new password', - errorEmptyOldPassword: "Old password can't be empty.", - errorEmptyNewPassword: "New password can't be empty.", - errorRetypeNotMatch: 'Password retyped does not match.', - }, - dialogConfirmLogout: { - title: 'Confirm Logout', - prompt: - 'Are you sure to log out? All cached data in the browser will be deleted.', - }, - }, - about: { - author: { - title: 'Site Developer', - fullname: 'Fullname: ', - nickname: 'Nickname: ', - introduction: 'Introduction: ', - introductionContent: 'A programmer coding based on coincidence', - links: 'Links: ', - }, - site: { - title: 'Site Information', - content: - 'The name of this site is <1>Timeline</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.', - repo: 'GitHub Repo', - }, - credits: { - title: 'Credits', - content: - 'Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.', - frontend: 'Frontend: ', - backend: 'Backend: ', - }, - }, - admin: { - title: 'admin', - }, -}; - -export default translation; diff --git a/Timeline/ClientApp/src/app/locales/scheme.ts b/Timeline/ClientApp/src/app/locales/scheme.ts deleted file mode 100644 index 13d555d5..00000000 --- a/Timeline/ClientApp/src/app/locales/scheme.ts +++ /dev/null @@ -1,175 +0,0 @@ -export default interface TranslationResource { - welcome: string; - search: string; - chooseImage: string; - loadImageError: string; - serviceWorker: { - availableOffline: string; - upgradePrompt: string; - upgradeNow: string; - upgradeSuccess: string; - externalActivatedPrompt: string; - reloadNow: string; - }; - nav: { - settings: string; - login: string; - about: string; - }; - home: { - go: string; - allTimeline: string; - joinTimeline: string; - ownTimeline: string; - createButton: string; - createDialog: { - title: string; - name: string; - nameFormat: string; - badFormat: string; - noEmpty: string; - tooLong: string; - }; - }; - operationDialog: { - retry: string; - nextStep: string; - previousStep: string; - confirm: string; - cancel: string; - ok: string; - processing: string; - success: string; - error: string; - }; - timeline: { - messageCantSee: string; - userNotExist: string; - timelineNotExist: string; - manage: string; - memberButton: string; - send: string; - deletePostFailed: string; - sendPostFailed: string; - visibility: { - public: string; - register: string; - private: string; - }; - visibilityTooltip: { - public: string; - register: string; - private: string; - }; - dialogChangeProperty: { - title: string; - visibility: string; - description: string; - }; - member: { - alreadyMember: string; - add: string; - remove: string; - }; - manageItem: { - nickname: string; - avatar: string; - property: string; - member: string; - delete: string; - }; - deleteDialog: { - title: string; - inputPrompt: string; - notMatch: string; - }; - post: { - deleteDialog: { - title: string; - prompt: string; - }; - }; - }; - user: { - username: string; - password: string; - login: string; - rememberMe: string; - welcomeBack: string; - verifyTokenFailed: string; - verifyTokenFailedNetwork: string; - }; - login: { - emptyUsername: string; - emptyPassword: string; - badCredential: string; - alreadyLogin: string; - }; - userPage: { - dialogChangeNickname: { - title: string; - inputLabel: string; - }; - dialogChangeAvatar: { - title: string; - previewImgAlt: string; - prompt: { - select: string; - crop: string; - processingCrop: string; - preview: string; - uploading: string; - }; - upload: string; - }; - }; - settings: { - subheaders: { - account: string; - customization: string; - }; - languagePrimary: string; - languageSecondary: string; - changePassword: string; - logout: string; - gotoSelf: string; - dialogChangePassword: { - title: string; - prompt: string; - inputOldPassword: string; - inputNewPassword: string; - inputRetypeNewPassword: string; - errorEmptyOldPassword: string; - errorEmptyNewPassword: string; - errorRetypeNotMatch: string; - }; - dialogConfirmLogout: { - title: string; - prompt: string; - }; - }; - about: { - author: { - title: string; - fullname: string; - nickname: string; - introduction: string; - introductionContent: string; - links: string; - }; - site: { - title: string; - content: string; - repo: string; - }; - credits: { - title: string; - content: string; - frontend: string; - backend: string; - }; - }; - admin: { - title: string; - }; -} diff --git a/Timeline/ClientApp/src/app/locales/zh/translation.ts b/Timeline/ClientApp/src/app/locales/zh/translation.ts deleted file mode 100644 index fba06c7f..00000000 --- a/Timeline/ClientApp/src/app/locales/zh/translation.ts +++ /dev/null @@ -1,187 +0,0 @@ -import TranslationResource from '../scheme'; - -const translation: TranslationResource = { - welcome: '欢迎!', - search: '搜索', - serviceWorker: { - availableOffline: 'Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉', - upgradePrompt: 'App 有新版本!', - upgradeNow: '现在升级', - upgradeSuccess: 'App 升级成功,当然,你仍可以离线使用它。 🎉🎉🎉', - externalActivatedPrompt: - '一个新的 App 版本已经激活,请刷新页面使用,否则页面可能会出现故障。', - reloadNow: '立刻刷新', - }, - nav: { - settings: '设置', - login: '登陆', - about: '关于', - }, - chooseImage: '选择一个图片', - loadImageError: '加载图片失败', - home: { - go: '冲!', - allTimeline: '所有的时间线', - joinTimeline: '加入的时间线', - ownTimeline: '拥有的时间线', - createButton: '创建时间线', - createDialog: { - title: '创建时间线!', - name: '名字', - nameFormat: - '名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.', - badFormat: '格式错误', - noEmpty: '不能为空', - tooLong: '太长了', - }, - }, - operationDialog: { - retry: '重试', - nextStep: '下一步', - previousStep: '上一步', - confirm: '确定', - cancel: '取消', - ok: '好的!', - processing: '处理中...', - success: '成功!', - error: '出错啦!', - }, - timeline: { - messageCantSee: '不好意思,你没有权限查看这个时间线。😅', - userNotExist: '该用户不存在!', - timelineNotExist: '该时间线不存在!', - manage: '管理', - memberButton: '成员', - send: '发送', - deletePostFailed: '删除消息失败。', - sendPostFailed: '发送消息失败。', - visibility: { - public: '对所有人公开', - register: '仅注册可见', - private: '仅成员可见', - }, - visibilityTooltip: { - public: '所有人都可以看到这个时间线的内容,包括没有注册的人。', - register: '只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。', - private: '只有这个时间线的成员可以看到这个时间线的内容。', - }, - dialogChangeProperty: { - title: '修改时间线属性', - visibility: '可见性', - description: '描述', - }, - member: { - alreadyMember: '该用户已经是一个成员。', - add: '添加', - remove: '移除', - }, - manageItem: { - nickname: '昵称', - avatar: '头像', - property: '时间线属性', - member: '时间线成员', - delete: '删除时间线', - }, - deleteDialog: { - title: '删除时间线', - inputPrompt: - '这是一个危险的操作。如果您确认要删除时间线<1>{{name}}</1>,请在下面输入它的名字并点击确认。', - notMatch: '名字不匹配', - }, - post: { - deleteDialog: { - title: '确认删除', - prompt: '确定删除这个消息?这个操作不可撤销。', - }, - }, - }, - user: { - username: '用户名', - password: '密码', - login: '登录', - rememberMe: '记住我', - welcomeBack: '欢迎回来!', - verifyTokenFailed: '用户登录信息已过期,请重新登陆!', - verifyTokenFailedNetwork: - '验证用户登录信息失败,请检查网络连接并刷新页面!', - }, - login: { - emptyUsername: '用户名不能为空。', - emptyPassword: '密码不能为空。', - badCredential: '用户名或密码错误。', - alreadyLogin: '已经登陆,三秒后导航到首页!', - }, - userPage: { - dialogChangeNickname: { - title: '更改昵称', - inputLabel: '新昵称', - }, - dialogChangeAvatar: { - title: '修改头像', - previewImgAlt: '预览', - prompt: { - select: '请选择一个图片', - crop: '请裁剪图片', - processingCrop: '正在裁剪图片', - uploading: '正在上传', - preview: '请预览图片', - }, - upload: '上传', - }, - }, - settings: { - subheaders: { - account: '账户', - customization: '个性化', - }, - languagePrimary: '选择显示的语言。', - languageSecondary: - '您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。', - changePassword: '更改账号的密码。', - logout: '注销此账号。', - gotoSelf: '点击前往个人时间线修改昵称和头像!', - dialogChangePassword: { - title: '修改密码', - prompt: - '您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。', - inputOldPassword: '旧密码', - inputNewPassword: '新密码', - inputRetypeNewPassword: '再次输入新密码', - errorEmptyOldPassword: '旧密码不能为空。', - errorEmptyNewPassword: '新密码不能为空', - errorRetypeNotMatch: '两次输入的密码不一致', - }, - dialogConfirmLogout: { - title: '确定注销', - prompt: '您确定注销此账号?这将删除所有已经缓存在浏览器的数据。', - }, - }, - about: { - author: { - title: '网站作者', - fullname: '姓名:', - nickname: '昵称:', - introduction: '简介:', - introductionContent: '一个基于巧合编程的代码爱好者。', - links: '链接:', - }, - site: { - title: '网站信息', - content: - '这个网站的名字叫 <1>Timeline</1>,是一个以<3>时间线</3>为核心概念的 Web App . 它的前端和后端都是由<5>我</5>开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。', - repo: 'GitHub 仓库', - }, - credits: { - title: '鸣谢', - content: - 'Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。', - frontend: '前端:', - backend: '后端:', - }, - }, - admin: { - title: '管理', - }, -}; - -export default translation; diff --git a/Timeline/ClientApp/src/app/service-worker.tsx b/Timeline/ClientApp/src/app/service-worker.tsx deleted file mode 100644 index 0dbab480..00000000 --- a/Timeline/ClientApp/src/app/service-worker.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import { Button } from 'reactstrap'; -import { useTranslation } from 'react-i18next'; - -import { pushAlert } from './common/alert-service'; - -if ('serviceWorker' in navigator) { - let isThisTriggerUpgrade = false; - - const upgradeSuccessLocalStorageKey = 'TIMELINE_UPGRADE_SUCCESS'; - - if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) { - pushAlert({ - message: { - type: 'i18n', - key: 'serviceWorker.upgradeSuccess', - }, - type: 'success', - }); - window.localStorage.removeItem(upgradeSuccessLocalStorageKey); - } - - void import('workbox-window').then(({ Workbox, messageSW }) => { - const wb = new Workbox('/sw.js'); - let registration: ServiceWorkerRegistration | undefined; - - // externalactivated is not usable but I still use its name. - wb.addEventListener('controlling', () => { - const upgradeReload = (): void => { - window.localStorage.setItem(upgradeSuccessLocalStorageKey, 'true'); - window.location.reload(); - }; - - if (isThisTriggerUpgrade) { - upgradeReload(); - } else { - const Message: React.FC = () => { - const { t } = useTranslation(); - return ( - <> - {t('serviceWorker.externalActivatedPrompt')} - <Button color="success" size="sm" onClick={upgradeReload} outline> - {t('serviceWorker.reloadNow')} - </Button> - </> - ); - }; - - pushAlert({ - message: Message, - dismissTime: 'never', - type: 'warning', - }); - } - }); - - wb.addEventListener('activated', (event) => { - if (!event.isUpdate) { - pushAlert({ - message: { - type: 'i18n', - key: 'serviceWorker.availableOffline', - }, - type: 'success', - }); - } - }); - - const showSkipWaitingPrompt = (): void => { - const upgrade = (): void => { - isThisTriggerUpgrade = true; - if (registration && registration.waiting) { - // Send a message to the waiting service worker, - // instructing it to activate. - // Note: for this to work, you have to add a message - // listener in your service worker. See below. - void messageSW(registration.waiting, { type: 'SKIP_WAITING' }); - } - }; - - const UpgradeMessage: React.FC = () => { - const { t } = useTranslation(); - return ( - <> - {t('serviceWorker.upgradePrompt')} - <Button color="success" size="sm" onClick={upgrade} outline> - {t('serviceWorker.upgradeNow')} - </Button> - </> - ); - }; - - pushAlert({ - message: UpgradeMessage, - dismissTime: 'never', - type: 'success', - }); - }; - - // Add an event listener to detect when the registered - // service worker has installed but is waiting to activate. - wb.addEventListener('waiting', showSkipWaitingPrompt); - wb.addEventListener('externalwaiting', showSkipWaitingPrompt); - - void wb.register().then((reg) => { - registration = reg; - }); - }); -} diff --git a/Timeline/ClientApp/src/app/settings/Settings.tsx b/Timeline/ClientApp/src/app/settings/Settings.tsx deleted file mode 100644 index 96a3fab4..00000000 --- a/Timeline/ClientApp/src/app/settings/Settings.tsx +++ /dev/null @@ -1,253 +0,0 @@ -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, - Modal, - ModalHeader, - ModalBody, - ModalFooter, - Button, -} from 'reactstrap'; - -import { apiBaseUrl } from '../config'; - -import { useUser, userLogout, useUserLoggedIn } from '../data/user'; - -import AppBar from '../common/AppBar'; -import OperationDialog, { - OperationInputErrorInfo, -} from '../common/OperationDialog'; -import { CommonErrorResponse } from '../data/common'; - -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<CommonErrorResponse>; - 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 = useUserLoggedIn(); - 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 ConfirmLogoutDialog: React.FC<{ - toggle: () => void; - onConfirm: () => void; -}> = ({ toggle, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal isOpen centered> - <ModalHeader className="text-danger"> - {t('settings.dialogConfirmLogout.title')} - </ModalHeader> - <ModalBody>{t('settings.dialogConfirmLogout.prompt')}</ModalBody> - <ModalFooter> - <Button color="secondary" onClick={toggle}> - {t('operationDialog.cancel')} - </Button> - <Button color="danger" onClick={onConfirm}> - {t('operationDialog.confirm')} - </Button> - </ModalFooter> - </Modal> - ); -}; - -const Settings: React.FC = (_) => { - const { i18n, t } = useTranslation(); - const user = useUser(); - const history = useHistory(); - - const [dialog, setDialog] = useState<null | 'changepassword' | 'logout'>( - null - ); - - const language = i18n.language.slice(0, 2); - - return ( - <> - <AppBar /> - <Container fluid className="mt-appbar"> - {user ? ( - <> - <Row className="border-bottom p-3 cursor-pointer"> - <Col xs="12"> - <h5 - onClick={() => { - history.push(`/users/${user.username}`); - }} - > - {t('settings.gotoSelf')} - </h5> - </Col> - </Row> - <Row className="border-bottom p-3 cursor-pointer"> - <Col xs="12"> - <h5 - className="text-danger" - onClick={() => setDialog('changepassword')} - > - {t('settings.changePassword')} - </h5> - </Col> - </Row> - <Row className="border-bottom p-3 cursor-pointer"> - <Col xs="12"> - <h5 - className="text-danger" - onClick={() => { - setDialog('logout'); - }} - > - {t('settings.logout')} - </h5> - </Col> - </Row> - </> - ) : null} - <Row className="align-items-center border-bottom p-3"> - <Col xs="12" sm="auto"> - <h5>{t('settings.languagePrimary')}</h5> - <p>{t('settings.languageSecondary')}</p> - </Col> - <Col xs="auto" className="ml-auto"> - <Input - type="select" - value={language} - onChange={(e) => { - void i18n.changeLanguage(e.target.value); - }} - > - <option value="zh">中文</option> - <option value="en">English</option> - </Input> - </Col> - </Row> - {(() => { - switch (dialog) { - case 'changepassword': - return ( - <ChangePasswordDialog - open - close={() => { - setDialog(null); - }} - /> - ); - case 'logout': - return ( - <ConfirmLogoutDialog - toggle={() => setDialog(null)} - onConfirm={() => { - userLogout(); - history.push('/'); - }} - /> - ); - default: - return null; - } - })()} - </Container> - </> - ); -}; - -export default Settings; diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx deleted file mode 100644 index 0a68c5db..00000000 --- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; - -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; - onResize?: () => void; - containerRef?: React.Ref<HTMLDivElement>; -} - -const Timeline: React.FC<TimelineProps> = (props) => { - const user = useUser(); - const avatarVersion = useAvatarVersion(); - - const { posts, onDelete, onResize } = props; - - const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState< - number - >(-1); - - const onItemClick = React.useCallback(() => { - setIndexShowDeleteButton(-1); - }, []); - - const onToggleDelete = React.useMemo(() => { - return posts.map((post, i) => { - return post.deletable - ? () => { - setIndexShowDeleteButton((oldIndexShowDeleteButton) => { - return oldIndexShowDeleteButton !== i ? i : -1; - }); - } - : undefined; - }); - }, [posts]); - - const onItemDelete = React.useMemo(() => { - return posts.map((post, i) => { - return () => { - onDelete(i, post.id); - }; - }); - }, [posts, onDelete]); - - return ( - <div - ref={props.containerRef} - className={clsx( - 'container-fluid d-flex flex-column position-relative', - props.className - )} - > - <div className="timeline-enter-animation-mask" /> - {(() => { - const length = posts.length; - return posts.map((post, i) => { - const av: number | undefined = - user != null && user.username === post.author.username - ? avatarVersion - : undefined; - - const toggleMore = onToggleDelete[i]; - - return ( - <TimelineItem - post={post} - key={post.id} - current={length - 1 === i} - more={ - toggleMore - ? { - isOpen: indexShowDeleteButton === i, - toggle: toggleMore, - onDelete: onItemDelete[i], - } - : undefined - } - onClick={onItemClick} - avatarVersion={av} - onResize={onResize} - /> - ); - }); - })()} - </div> - ); -}; - -export default Timeline; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index 5e7c6dd4..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import axios from 'axios'; -import { useHistory } from 'react-router'; -import { Trans } from 'react-i18next'; - -import { apiBaseUrl } from '../config'; -import { useUserLoggedIn } from '../data/user'; -import OperationDialog from '../common/OperationDialog'; - -interface TimelineDeleteDialog { - open: boolean; - name: string; - close: () => void; -} - -const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { - const user = useUserLoggedIn(); - 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/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx deleted file mode 100644 index e4bc07d1..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - 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), - [] - ); - - 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"> - {onManage != null ? ( - <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}> - <DropdownToggle outline color="primary"> - {t('timeline.manage')} - </DropdownToggle> - <DropdownMenu> - <DropdownItem onClick={() => onManage('property')}> - {t('timeline.manageItem.property')} - </DropdownItem> - <DropdownItem onClick={props.onMember}> - {t('timeline.manageItem.member')} - </DropdownItem> - <DropdownItem divider /> - <DropdownItem - className="text-danger" - onClick={() => onManage('delete')} - > - {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/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx deleted file mode 100644 index 43e206f1..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; -import { - Row, - Col, - Modal, - ModalHeader, - ModalBody, - ModalFooter, - Button, -} from 'reactstrap'; -import { Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import Svg from 'react-inlinesvg'; - -import chevronDownIcon from 'bootstrap-icons/icons/chevron-down.svg'; -import trashIcon from 'bootstrap-icons/icons/trash.svg'; - -import { TimelinePostInfo } from '../data/timeline'; -import { useAvatarUrlWithGivenVersion } from '../user/api'; - -const TimelinePostDeleteConfirmDialog: React.FC<{ - toggle: () => void; - onConfirm: () => void; -}> = ({ toggle, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal toggle={toggle} isOpen centered> - <ModalHeader className="text-danger"> - {t('timeline.post.deleteDialog.title')} - </ModalHeader> - <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody> - <ModalFooter> - <Button color="secondary" onClick={toggle}> - {t('operationDialog.cancel')} - </Button> - <Button - color="danger" - onClick={() => { - onConfirm(); - toggle(); - }} - > - {t('operationDialog.confirm')} - </Button> - </ModalFooter> - </Modal> - ); -}; - -export interface TimelineItemProps { - post: TimelinePostInfo; - current?: boolean; - more?: { - isOpen: boolean; - toggle: () => void; - onDelete: () => void; - }; - onClick?: () => void; - avatarVersion?: number; - onResize?: () => void; - className?: string; - style?: React.CSSProperties; -} - -const TimelineItem: React.FC<TimelineItemProps> = (props) => { - const { i18n } = useTranslation(); - - const current = props.current === true; - - const { more, onResize } = props; - - const avatarUrl = useAvatarUrlWithGivenVersion( - props.avatarVersion, - props.post.author._links.avatar - ); - - const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false); - const toggleDeleteDialog = React.useCallback( - () => setDeleteDialog((old) => !old), - [] - ); - - return ( - <Row - className={clsx( - 'position-relative flex-nowrap', - current && 'current', - props.className - )} - onClick={props.onClick} - style={props.style} - > - <Col className="timeline-line-area"> - <div className="timeline-line-segment start"></div> - <div className="timeline-line-node-container"> - <div className="timeline-line-node"></div> - </div> - <div className="timeline-line-segment end"></div> - {current && <div className="timeline-line-segment 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> - {more != null ? ( - <div className="col-auto px-2 d-flex justify-content-center align-items-center"> - <Svg - src={chevronDownIcon} - className="text-info icon-button" - onClick={(e: Event) => { - more.toggle(); - e.stopPropagation(); - }} - /> - </div> - ) : null} - </Row> - <div className="row d-block timeline-content"> - <Link - className="float-right float-sm-left mx-2" - to={'/users/' + props.post.author.username} - > - <img onLoad={onResize} src={avatarUrl} className="avatar rounded" /> - </Link> - {(() => { - const { content } = props.post; - if (content.type === 'text') { - return content.text; - } else { - return ( - <img - onLoad={onResize} - src={content.url} - className="timeline-content-image" - /> - ); - } - })()} - </div> - </Col> - {more != null && more.isOpen ? ( - <> - <div - className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center" - onClick={more.toggle} - > - <Svg - src={trashIcon} - className="text-danger large-icon-button" - onClick={(e: Event) => { - toggleDeleteDialog(); - e.stopPropagation(); - }} - /> - </div> - {deleteDialog ? ( - <TimelinePostDeleteConfirmDialog - toggle={() => { - toggleDeleteDialog(); - more.toggle(); - }} - onConfirm={more.onDelete} - /> - ) : null} - </> - ) : null} - </Row> - ); -}; - -export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx deleted file mode 100644 index f9747b4d..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx +++ /dev/null @@ -1,196 +0,0 @@ -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 as string}`, - }); - } - ); - }} - /> - {(() => { - 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={() => { - void 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/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx deleted file mode 100644 index 900d6e6a..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx deleted file mode 100644 index 38ecd8f9..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx +++ /dev/null @@ -1,275 +0,0 @@ -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'; -import { UiLogicError } from '../common'; - -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 as string}`); - } - } - ); - } - } - }, - (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]); - - const closeDialog = React.useCallback((): void => { - setDialog(null); - }, []); - - let dialogElement: React.ReactElement | undefined; - - if (dialog === 'property') { - if (timeline == null) { - throw new UiLogicError( - 'Timeline is null but attempt to open change property dialog.' - ); - } - - dialogElement = ( - <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') { - if (timeline == null) { - throw new UiLogicError( - 'Timeline is null but attempt to open change property dialog.' - ); - } - - 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) => { - void 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/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx deleted file mode 100644 index 924e7883..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React from 'react'; -import { Spinner } from 'reactstrap'; -import { useTranslation } from 'react-i18next'; -import { fromEvent } from 'rxjs'; -import Svg from 'react-inlinesvg'; - -import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract.svg'; -import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg'; - -import { getAlertHost } from '../common/alert-service'; - -import Timeline, { - TimelinePostInfoEx, - TimelineDeleteCallback, -} from './Timeline'; -import AppBar from '../common/AppBar'; -import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; -import { useEventEmiiter } from '../common'; - -export interface TimelineCardComponentProps<TTimeline, TManageItems> { - timeline: TTimeline; - onManage?: (item: TManageItems | 'property') => void; - onMember: () => void; - className?: string; - onHeight?: (height: number) => void; -} - -export interface TimelinePageTemplateUIProps< - TTimeline extends { uniqueId: string }, - 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 extends { uniqueId: string }, - TEditItems ->( - props: TimelinePageTemplateUIProps<TTimeline, TEditItems> -): React.ReactElement | null { - const { timeline } = props; - - 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 timelineRef = React.useRef<HTMLDivElement | null>(null); - - const [getResizeEvent, triggerResizeEvent] = useEventEmiiter(); - - React.useEffect(() => { - const { current: timelineElement } = timelineRef; - if (timelineElement != null) { - let loadingScrollToBottom = true; - let pinBottom = false; - - const isAtBottom = (): boolean => - window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight; - - const disableLoadingScrollToBottom = (): void => { - loadingScrollToBottom = false; - if (isAtBottom()) pinBottom = true; - }; - - const checkAndScrollToBottom = (): void => { - if (loadingScrollToBottom || pinBottom) { - window.scrollTo(0, document.body.scrollHeight); - } - }; - - const subscriptions = [ - fromEvent(timelineElement, 'wheel').subscribe( - disableLoadingScrollToBottom - ), - fromEvent(timelineElement, 'pointerdown').subscribe( - disableLoadingScrollToBottom - ), - fromEvent(timelineElement, 'keydown').subscribe( - disableLoadingScrollToBottom - ), - fromEvent(window, 'scroll').subscribe(() => { - if (loadingScrollToBottom) return; - - if (isAtBottom()) { - pinBottom = true; - } else { - pinBottom = false; - } - }), - fromEvent(window, 'resize').subscribe(checkAndScrollToBottom), - getResizeEvent().subscribe(checkAndScrollToBottom), - ]; - - return () => { - subscriptions.forEach((s) => s.unsubscribe()); - }; - } - }, [getResizeEvent, triggerResizeEvent, timeline, props.posts]); - - const [cardHeight, setCardHeight] = React.useState<number>(0); - - const genCardCollapseLocalStorageKey = (uniqueId: string): string => - `timeline.${uniqueId}.cardCollapse`; - - const cardCollapseLocalStorageKey = - timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null; - - const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true); - React.useEffect(() => { - if (cardCollapseLocalStorageKey != null) { - const savedCollapse = - window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true'; - setInfoCardCollapse(savedCollapse); - } - }, [cardCollapseLocalStorageKey]); - - let body: React.ReactElement; - - if (props.error != null) { - body = <p className="text-danger">{t(props.error)}</p>; - } else { - if (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 - containerRef={timelineRef} - posts={props.posts} - onDelete={props.onDelete} - onResize={triggerResizeEvent} - /> - ); - if (props.onPost != null) { - timelineBody = ( - <> - {timelineBody} - <div ref={bottomSpaceRef} className="flex-fix-length" /> - <TimelinePostEdit - onPost={props.onPost} - onHeightChange={onPostEditHeightChange} - timelineUniqueId={timeline.uniqueId} - /> - </> - ); - } - } - } else { - timelineBody = ( - <div className="full-viewport-center-child"> - <Spinner color="primary" type="grow" /> - </div> - ); - } - const { CardComponent } = props; - - body = ( - <> - <div - className="fixed-top mt-appbar info-card-container" - data-collapse={infoCardCollapse ? 'true' : 'false'} - > - <Svg - src={ - infoCardCollapse - ? arrowsAngleExpandIcon - : arrowsAngleContractIcon - } - onClick={() => { - const newState = !infoCardCollapse; - setInfoCardCollapse(newState); - window.localStorage.setItem( - genCardCollapseLocalStorageKey(timeline.uniqueId), - newState.toString() - ); - }} - className="float-right m-1 info-card-collapse-button text-primary icon-button" - /> - <CardComponent - timeline={timeline} - onManage={props.onManage} - onMember={props.onMember} - onHeight={setCardHeight} - className="info-card-content" - /> - </div> - {timelineBody} - </> - ); - } else { - body = ( - <div className="full-viewport-center-child"> - <Spinner color="primary" type="grow" /> - </div> - ); - } - } - - return ( - <> - <AppBar /> - <div> - <div - style={{ height: 56 + cardHeight }} - className="timeline-page-top-space flex-fix-length" - /> - {body} - </div> - </> - ); -} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx deleted file mode 100644 index d7e9d81b..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx deleted file mode 100644 index 894d6ad4..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import React from 'react'; -import { Button, Spinner, Row, Col } from 'reactstrap'; -import { useTranslation } from 'react-i18next'; -import Svg from 'react-inlinesvg'; - -import textIcon from 'bootstrap-icons/icons/card-text.svg'; -import imageIcon from 'bootstrap-icons/icons/image.svg'; - -import { pushAlert } from '../common/alert-service'; -import { CreatePostRequest } from '../data/timeline'; - -import FileInput from '../common/FileInput'; -import { UiLogicError } from '../common'; - -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; - timelineUniqueId: string; -} - -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 draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`; - - React.useEffect(() => { - setText(window.localStorage.getItem(draftLocalStorageKey) ?? ''); - }, [draftLocalStorageKey]); - - const canSend = kind === 'text' || (kind === 'image' && imageBlob != null); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const containerRef = React.useRef<HTMLDivElement>(null!); - - const notifyHeightChange = (): void => { - if (props.onHeightChange) { - props.onHeightChange(containerRef.current.clientHeight); - } - }; - - React.useEffect(() => { - if (props.onHeightChange) { - props.onHeightChange(containerRef.current.clientHeight); - } - return () => { - if (props.onHeightChange) { - props.onHeightChange(0); - } - }; - }); - - const toggleKind = React.useCallback(() => { - setKind((oldKind) => (oldKind === 'text' ? 'image' : 'text')); - setImageBlob(null); - }, []); - - const onSend = React.useCallback(() => { - setState('process'); - - const req: CreatePostRequest = (() => { - switch (kind) { - case 'text': - return { - content: { - type: 'text', - text: text, - }, - } as CreatePostRequest; - case 'image': - if (imageBlob == null) { - throw new UiLogicError( - 'Content type is image but image blob is null.' - ); - } - return { - content: { - type: 'image', - data: imageBlob, - }, - } as CreatePostRequest; - default: - throw new UiLogicError('Unknown content type.'); - } - })(); - - onPost(req).then( - (_) => { - if (kind === 'text') { - setText(''); - window.localStorage.removeItem(draftLocalStorageKey); - } - setState('input'); - setKind('text'); - }, - (_) => { - pushAlert({ - type: 'danger', - message: t('timeline.sendPostFailed'), - }); - setState('input'); - } - ); - }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]); - - const onImageSelect = React.useCallback((blob: Blob | null) => { - setImageBlob(blob); - }, []); - - return ( - <div ref={containerRef} className="container-fluid fixed-bottom bg-light"> - <Row> - <Col className="px-1 py-1"> - {kind === 'text' ? ( - <textarea - className="w-100 h-100 timeline-post-edit" - value={text} - disabled={state === 'process'} - onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => { - const value = event.currentTarget.value; - setText(value); - window.localStorage.setItem(draftLocalStorageKey, value); - }} - /> - ) : ( - <TimelinePostEditImage onSelect={onImageSelect} /> - )} - </Col> - <Col sm="col-auto align-self-end m-1"> - {(() => { - if (state === 'input') { - return ( - <> - <div className="d-block text-center mt-1 mb-2"> - <Svg - onLoad={notifyHeightChange} - src={kind === 'text' ? imageIcon : textIcon} - className="icon-button" - onClick={toggleKind} - /> - </div> - <Button color="primary" onClick={onSend} disabled={!canSend}> - {t('timeline.send')} - </Button> - </> - ); - } else { - return <Spinner />; - } - })()} - </Col> - </Row> - </div> - ); -}; - -export default TimelinePostEdit; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index ca1be31c..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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/app/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass deleted file mode 100644 index c3616caf..00000000 --- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass +++ /dev/null @@ -1,18 +0,0 @@ -.info-card-container - .info-card-collapse-button - z-index: 1 - position: relative - - .info-card-content - width: 100% - position: absolute - transform-origin: right top - transition: transform 0.5s - - &[data-collapse='true'] - .info-card-content - transform: scale(0) - -.timeline-page-top-space - transition: height 0.5s - diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass deleted file mode 100644 index 0b0e73b5..00000000 --- a/Timeline/ClientApp/src/app/timeline/timeline.sass +++ /dev/null @@ -1,125 +0,0 @@ -@use 'sass:color' - -@keyframes timeline-enter-animation-mask-animation - to - height: 0 - -.timeline-enter-animation-mask - position: absolute - left: 0 - top: 0 - height: calc(100% + 300px) - width: 100% - background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white) - z-index: 100 - animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable - -$timeline-line-width: 7px -$timeline-line-node-radius: 18px -$timeline-line-color: $primary -$timeline-line-color-current: #36c2e6 - -@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 - &-area - display: flex - flex-direction: column - align-items: center - flex: 0 0 auto - width: 60px - - &-segment - width: $timeline-line-width - background: $timeline-line-color - - &.start - height: 20px - flex: 0 0 auto - - &.end - flex: 1 1 auto - - &.current-end - height: 20px - flex: 0 0 auto - background: linear-gradient($timeline-line-color-current, transparent) - - &-node-container - flex: 0 0 auto - position: relative - width: $timeline-line-node-radius - height: $timeline-line-node-radius - - &-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 - - -.current - .timeline-line - &-segment - - &.start - background: linear-gradient($timeline-line-color, $timeline-line-color-current) - - &.end - background: $timeline-line-color-current - - &-node - animation-name: timeline-line-node-current - -.timeline-pt-start - padding-top: 18px - -.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 - -textarea.timeline-post-edit - @extend .border-primary - @extend .rounded - - &:focus - outline: none - box-shadow: 0 0 5px 0 $primary diff --git a/Timeline/ClientApp/src/app/tsconfig.json b/Timeline/ClientApp/src/app/tsconfig.json deleted file mode 100644 index 14e6327f..00000000 --- a/Timeline/ClientApp/src/app/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ] - }, - "include": [ - "." - ] -} diff --git a/Timeline/ClientApp/src/app/type-utilities.ts b/Timeline/ClientApp/src/app/type-utilities.ts deleted file mode 100644 index 8df9bf0f..00000000 --- a/Timeline/ClientApp/src/app/type-utilities.ts +++ /dev/null @@ -1 +0,0 @@ -export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; diff --git a/Timeline/ClientApp/src/app/typings.d.ts b/Timeline/ClientApp/src/app/typings.d.ts deleted file mode 100644 index 426fb214..00000000 --- a/Timeline/ClientApp/src/app/typings.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx deleted file mode 100644 index a0e56621..00000000 --- a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx +++ /dev/null @@ -1,306 +0,0 @@ -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'; -import { UiLogicError } from '../common'; - -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 UiLogicError(); - } - - setState('processcrop'); - void 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 UiLogicError(); - } - - 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 UiLogicError(); - } - 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 UiLogicError(); - } - 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/app/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx deleted file mode 100644 index dbc216e1..00000000 --- a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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/app/user/Login.tsx b/Timeline/ClientApp/src/app/user/Login.tsx deleted file mode 100644 index f8b3f0e7..00000000 --- a/Timeline/ClientApp/src/app/user/Login.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { Fragment, useState, useEffect } from 'react'; -import { useHistory } from 'react-router'; -import { useTranslation } from 'react-i18next'; -import { AxiosError } from 'axios'; - -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: AxiosError | Error) => { - 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/app/user/User.tsx b/Timeline/ClientApp/src/app/user/User.tsx deleted file mode 100644 index 0e1977b1..00000000 --- a/Timeline/ClientApp/src/app/user/User.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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'; -import { UiLogicError } from '../common'; - -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') { - if (user == null) { - throw new UiLogicError('Change nickname without login.'); - } - - dialogElement = ( - <ChangeNicknameDialog - open - close={closeDialogHandler} - onProcess={(newNickname) => { - const p = changeNickname(user.token, username, newNickname); - return p.then((_) => { - setDataKey(dataKey + 1); - }); - }} - /> - ); - } else if (dialog === 'avatar') { - if (user == null) { - throw new UiLogicError('Change avatar without login.'); - } - - 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/app/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx deleted file mode 100644 index b4924a5d..00000000 --- a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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 - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - 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), - [] - ); - - 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"> - {onManage != null ? ( - <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}> - <DropdownToggle outline color="primary"> - {t('timeline.manage')} - </DropdownToggle> - <DropdownMenu> - <DropdownItem onClick={() => onManage('nickname')}> - {t('timeline.manageItem.nickname')} - </DropdownItem> - <DropdownItem onClick={() => onManage('avatar')}> - {t('timeline.manageItem.avatar')} - </DropdownItem> - <DropdownItem onClick={() => onManage('property')}> - {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/app/user/UserPage.tsx b/Timeline/ClientApp/src/app/user/UserPage.tsx deleted file mode 100644 index 98fd06f8..00000000 --- a/Timeline/ClientApp/src/app/user/UserPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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/app/user/api.ts b/Timeline/ClientApp/src/app/user/api.ts deleted file mode 100644 index a9855eb0..00000000 --- a/Timeline/ClientApp/src/app/user/api.ts +++ /dev/null @@ -1,79 +0,0 @@ -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.toString(), - url - ), - [avatarVersion, url] - ); -} - -export function useAvatarUrlWithGivenVersion( - version: number | null | undefined, - url: string -): string { - return React.useMemo( - () => - updateQueryString('v', version == null ? null : version.toString(), url), - [version, url] - ); -} diff --git a/Timeline/ClientApp/src/app/user/user-page.sass b/Timeline/ClientApp/src/app/user/user-page.sass deleted file mode 100644 index fe3ab70b..00000000 --- a/Timeline/ClientApp/src/app/user/user-page.sass +++ /dev/null @@ -1,11 +0,0 @@ -.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/src/sw/sw.ts b/Timeline/ClientApp/src/sw/sw.ts deleted file mode 100644 index e7558015..00000000 --- a/Timeline/ClientApp/src/sw/sw.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { precacheAndRoute, matchPrecache } from 'workbox-precaching'; -import { setDefaultHandler } from 'workbox-routing'; -import { NetworkOnly } from 'workbox-strategies'; - -declare let self: ServiceWorkerGlobalScope; - -self.addEventListener('message', (event) => { - if (event.data && (event.data as { type: string }).type === 'SKIP_WAITING') { - void self.skipWaiting(); - } -}); - -precacheAndRoute(self.__WB_MANIFEST); - -const networkOnly = new NetworkOnly(); - -setDefaultHandler((options) => { - const { request, url } = options; - if (url && url.pathname.startsWith('/api/')) { - return networkOnly.handle(options); - } - - if (request instanceof Request && request.destination === 'document') - return matchPrecache('/index.html').then((r) => - r == null ? Response.error() : r - ); - else return networkOnly.handle(options); -}); diff --git a/Timeline/ClientApp/src/sw/tsconfig.json b/Timeline/ClientApp/src/sw/tsconfig.json deleted file mode 100644 index aac99e59..00000000 --- a/Timeline/ClientApp/src/sw/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "lib": [ - "esnext", - "webworker" - ] - }, - "include": [ - "." - ] -} diff --git a/Timeline/ClientApp/src/tsconfig.json b/Timeline/ClientApp/src/tsconfig.json deleted file mode 100644 index 320253fa..00000000 --- a/Timeline/ClientApp/src/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "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" - } -} diff --git a/Timeline/ClientApp/webpack.common.js b/Timeline/ClientApp/webpack.common.js deleted file mode 100644 index bed968e1..00000000 --- a/Timeline/ClientApp/webpack.common.js +++ /dev/null @@ -1,60 +0,0 @@ -const autoprefixer = require('autoprefixer');
-const htmlWebpackTemplate = require('html-webpack-template');
-
-const commonRules = [
- {
- test: /\.css$/,
- use: ['style-loader', 'css-loader'],
- },
- {
- test: /\.(scss|sass)$/,
- 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,
- },
- },
- ],
- },
-];
-
-const htmlCommonConfig = {
- inject: false,
- template: htmlWebpackTemplate,
-
- 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',
-};
-
-module.exports = {
- commonRules,
- htmlCommonConfig,
-};
diff --git a/Timeline/ClientApp/webpack.config.dev.js b/Timeline/ClientApp/webpack.config.dev.js deleted file mode 100644 index c2f29700..00000000 --- a/Timeline/ClientApp/webpack.config.dev.js +++ /dev/null @@ -1,78 +0,0 @@ -const path = require('path');
-const webpack = require('webpack');
-const HtmlWebpackPlugin = require('html-webpack-plugin');
-const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
-const PnpWebpackPlugin = require('pnp-webpack-plugin');
-
-const { commonRules, htmlCommonConfig } = require('./webpack.common');
-
-const config = {
- entry: ['react-hot-loader/patch', './src/app/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'],
- plugins: [PnpWebpackPlugin],
- },
- resolveLoader: {
- plugins: [PnpWebpackPlugin.moduleLoader(module)],
- },
- 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.resolve(__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({
- tsconfig: './src/app/tsconfig.json',
- }),
- new ForkTsCheckerWebpackPlugin({
- tsconfig: './src/sw/tsconfig.json',
- }),
- new webpack.HotModuleReplacementPlugin(),
- ],
-};
-
-module.exports = config;
diff --git a/Timeline/ClientApp/webpack.config.prod.js b/Timeline/ClientApp/webpack.config.prod.js deleted file mode 100644 index e4594421..00000000 --- a/Timeline/ClientApp/webpack.config.prod.js +++ /dev/null @@ -1,75 +0,0 @@ -const path = require('path');
-const { CleanWebpackPlugin } = require('clean-webpack-plugin');
-const HtmlWebpackPlugin = require('html-webpack-plugin');
-const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
-const CopyPlugin = require('copy-webpack-plugin');
-const PnpWebpackPlugin = require('pnp-webpack-plugin');
-const WorkboxPlugin = require('workbox-webpack-plugin');
-
-const { commonRules, htmlCommonConfig } = require('./webpack.common');
-
-const config = {
- entry: ['./src/app/index.tsx', './src/app/service-worker.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'],
- plugins: [PnpWebpackPlugin],
- },
- resolveLoader: {
- plugins: [PnpWebpackPlugin.moduleLoader(module)],
- },
- 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({
- tsconfig: './src/app/tsconfig.json',
- }),
- new ForkTsCheckerWebpackPlugin({
- tsconfig: './src/sw/tsconfig.json',
- }),
- new CopyPlugin({
- patterns: [
- {
- from: path.resolve(__dirname, 'public/'),
- to: path.resolve(__dirname, 'dist/'),
- },
- ],
- }),
- new WorkboxPlugin.InjectManifest({
- swSrc: './src/sw/sw.ts',
- maximumFileSizeToCacheInBytes: 15000000,
- }),
- ],
-};
-
-module.exports = config;
|