diff options
Diffstat (limited to 'Timeline/ClientApp')
108 files changed, 0 insertions, 8824 deletions
diff --git a/Timeline/ClientApp/.babelrc b/Timeline/ClientApp/.babelrc deleted file mode 100644 index 092f2f73..00000000 --- a/Timeline/ClientApp/.babelrc +++ /dev/null @@ -1,27 +0,0 @@ -{
- "presets": [
- "@babel/env",
- "@babel/preset-react"
- ],
- "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 779719e0..00000000 --- a/Timeline/ClientApp/.editorconfig +++ /dev/null @@ -1,14 +0,0 @@ -root = true
-end_of_line = lf
-
-[*.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 f29f7466..00000000 --- a/Timeline/ClientApp/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -.yarn
-node_modules
-dist
-webpack.*.js
-.eslintrc.js
-postcss.config.js
diff --git a/Timeline/ClientApp/.eslintrc.js b/Timeline/ClientApp/.eslintrc.js deleted file mode 100644 index 900489ed..00000000 --- a/Timeline/ClientApp/.eslintrc.js +++ /dev/null @@ -1,47 +0,0 @@ -module.exports = {
- env: {
- browser: true,
- es2020: true,
- },
- extends: [
- "eslint:recommended",
- "plugin:react/recommended",
- "plugin:@typescript-eslint/eslint-recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:@typescript-eslint/recommended-requiring-type-checking",
- "plugin:prettier/recommended",
- "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,
- },
- 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 1de0b58f..00000000 --- a/Timeline/ClientApp/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-
-.yarn/*
-!.yarn/cache
-!.yarn/releases
-!.yarn/plugins
-!.yarn/sdks
-!.yarn/versions
-
-# testing
-/coverage
-
-# production
-/build
-
-# misc
-.DS_Store
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
-
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-.vscode/launch.json
-
-/dist
\ No newline at end of file diff --git a/Timeline/ClientApp/.vscode/extensions.json b/Timeline/ClientApp/.vscode/extensions.json deleted file mode 100644 index be640996..00000000 --- a/Timeline/ClientApp/.vscode/extensions.json +++ /dev/null @@ -1,9 +0,0 @@ -{
- "recommendations": [
- "dbaeumer.vscode-eslint",
- "esbenp.prettier-vscode",
- "arcanis.vscode-zipfs",
- "syler.sass-indented",
- "editorconfig.editorconfig"
- ]
-}
diff --git a/Timeline/ClientApp/.vscode/preview.yml b/Timeline/ClientApp/.vscode/preview.yml deleted file mode 100644 index eb5e452c..00000000 --- a/Timeline/ClientApp/.vscode/preview.yml +++ /dev/null @@ -1,10 +0,0 @@ - -# .vscode/preview.yml -autoOpen: false # 打开工作空间时是否自动开启所有应用的预览 -apps: - - port: 3000 # 应用的端口 - run: yarn start:mock # 应用的启动命令 - root: . # 应用的启动目录 - name: timeline # 应用名称 - description: Timeline App # 应用描述 - autoOpen: false # 打开工作空间时是否自动开启预览(优先级高于根级 autoOpen) diff --git a/Timeline/ClientApp/.vscode/settings.json b/Timeline/ClientApp/.vscode/settings.json deleted file mode 100644 index 3db658ba..00000000 --- a/Timeline/ClientApp/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{
- "eslint.nodePath": ".yarn/sdks",
- "prettier.prettierPath": ".yarn/sdks/prettier/index.js",
- "typescript.tsdk": ".yarn/sdks/typescript/lib",
- "typescript.enablePromptUseWorkspaceTsdk": true
-}
diff --git a/Timeline/ClientApp/.yarnrc.yml b/Timeline/ClientApp/.yarnrc.yml deleted file mode 100644 index 2e4e43a7..00000000 --- a/Timeline/ClientApp/.yarnrc.yml +++ /dev/null @@ -1,5 +0,0 @@ -plugins:
- - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
- spec: "@yarnpkg/plugin-interactive-tools"
-
-yarnPath: .yarn/releases/yarn-2.1.1.cjs
diff --git a/Timeline/ClientApp/LICENSE b/Timeline/ClientApp/LICENSE deleted file mode 100644 index 238cd2d9..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 65c5cbe2..00000000 --- a/Timeline/ClientApp/package.json +++ /dev/null @@ -1,111 +0,0 @@ -{
- "name": "timeline",
- "version": "0.1.0",
- "private": true,
- "homepage": "https://crupest.xyz",
- "keywords": [],
- "description": "Timeline app.",
- "dependencies": {
- "axios": "^0.21.0",
- "bootstrap": "^4.5.3",
- "bootstrap-icons": "^1.0.0",
- "classnames": "^2.2.6",
- "clsx": "^1.1.1",
- "core-js": "^3.6.5",
- "i18next": "^19.8.3",
- "i18next-browser-languagedetector": "^6.0.1",
- "localforage": "^1.9.0",
- "lodash": "^4.17.20",
- "pepjs": "^0.5.2",
- "react": "^17.0.1",
- "react-bootstrap": "^1.4.0",
- "react-dom": "^17.0.1",
- "react-hot-loader": "^4.13.0",
- "react-i18next": "^11.7.3",
- "react-inlinesvg": "^2.1.1",
- "react-responsive": "^8.1.0",
- "react-router": "^5.2.0",
- "react-router-bootstrap": "^0.25.0",
- "react-router-dom": "^5.2.0",
- "regenerator-runtime": "^0.13.7",
- "rxjs": "^6.6.3",
- "workbox-precaching": "^5.1.4",
- "workbox-routing": "^5.1.4",
- "workbox-strategies": "^5.1.4",
- "workbox-window": "^5.1.4",
- "xregexp": "^4.3.0"
- },
- "scripts": {
- "start": "webpack-dev-server --config ./webpack.config.dev.js",
- "build": "webpack --config ./webpack.config.prod.js",
- "lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx"
- },
- "browserslist": {
- "production": [
- ">0.2%",
- "not dead",
- "not op_mini all"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
- ]
- },
- "devDependencies": {
- "@babel/core": "^7.12.3",
- "@babel/plugin-proposal-class-properties": "^7.12.1",
- "@babel/plugin-proposal-decorators": "^7.12.1",
- "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1",
- "@babel/plugin-proposal-optional-chaining": "^7.12.1",
- "@babel/plugin-syntax-dynamic-import": "^7.8.3",
- "@babel/preset-env": "^7.12.1",
- "@babel/preset-react": "^7.12.1",
- "@babel/preset-typescript": "^7.12.1",
- "@hot-loader/react-dom": "^17.0.0",
- "@types/classnames": "^2.2.10",
- "@types/lodash": "^4.14.162",
- "@types/node": "^14.14.5",
- "@types/react": "^16.9.53",
- "@types/react-dom": "^16.9.8",
- "@types/react-responsive": "^8.0.2",
- "@types/react-router": "^5.1.8",
- "@types/react-router-bootstrap": "^0.24.5",
- "@types/react-router-dom": "^5.1.6",
- "@types/webpack-env": "^1.15.3",
- "@types/xregexp": "^4.3.0",
- "@typescript-eslint/eslint-plugin": "^4.6.0",
- "@typescript-eslint/parser": "^4.6.0",
- "@yarnpkg/pnpify": "^2.3.3",
- "babel-loader": "^8.1.0",
- "babel-plugin-transform-builtin-extend": "^1.1.2",
- "clean-webpack-plugin": "^3.0.0",
- "copy-webpack-plugin": "^6.2.1",
- "css-loader": "^5.0.0",
- "eslint": "^7.12.1",
- "eslint-config-prettier": "^6.14.0",
- "eslint-plugin-import": "^2.22.1",
- "eslint-plugin-prettier": "^3.1.4",
- "eslint-plugin-react": "^7.21.5",
- "eslint-plugin-react-hooks": "^4.2.0",
- "file-loader": "^6.1.1",
- "html-webpack-plugin": "^4.5.0",
- "http-server": "^0.12.3",
- "mini-css-extract-plugin": "^1.2.0",
- "postcss": "^8.1.4",
- "postcss-loader": "^4.0.4",
- "postcss-preset-env": "^6.7.0",
- "prettier": "^2.1.2",
- "sass": "^1.27.0",
- "sass-loader": "^10.0.4",
- "style-loader": "^2.0.0",
- "ts-loader": "^8.0.7",
- "typescript": "^4.0.5",
- "url-loader": "^4.1.1",
- "webpack": "^5.2.0",
- "webpack-chain": "^6.5.1",
- "webpack-cli": "^4.1.0",
- "webpack-dev-server": "^3.11.0",
- "workbox-webpack-plugin": "^5.1.4"
- }
-}
diff --git a/Timeline/ClientApp/postcss.config.js b/Timeline/ClientApp/postcss.config.js deleted file mode 100644 index 74ee8155..00000000 --- a/Timeline/ClientApp/postcss.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = {
- plugins: [
- [
- "postcss-preset-env",
- {
- // Options
- },
- ],
- ],
-};
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 f2c89409..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 e91f046a..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 74f0901a..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/sandbox.config.json b/Timeline/ClientApp/sandbox.config.json deleted file mode 100644 index 42b540ce..00000000 --- a/Timeline/ClientApp/sandbox.config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "infiniteLoopProtection": true, - "hardReloadOnChange": false, - "view": "browser", - "container": { - "port": 3000, - "startScript": "start:mock" - }, - "port": 3000, - "startScript": "start:mock" -} diff --git a/Timeline/ClientApp/src/app/App.tsx b/Timeline/ClientApp/src/app/App.tsx deleted file mode 100644 index b68eddb6..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 "./views/common/AppBar"; -import LoadingPage from "./views/common/LoadingPage"; -import Home from "./views/home"; -import Login from "./views/login"; -import Settings from "./views/settings"; -import About from "./views/about"; -import User from "./views/user"; -import TimelinePage from "./views/timeline"; -import AlertHost from "./views/common/alert/AlertHost"; - -import { dataStorage } from "./services/common"; -import { userService, useRawUser } from "./services/user"; - -const NoMatch: React.FC = () => { - return ( - <> - <AppBar /> - <div style={{ height: 56 }} /> - <div>Ah-oh, 404!</div> - </> - ); -}; - -const LazyAdmin = React.lazy( - () => import(/* webpackChunkName: "admin" */ "./views/admin/Admin") -); - -const App: React.FC = () => { - const [loading, setLoading] = React.useState<boolean>(true); - - const user = useRawUser(); - - React.useEffect(() => { - void userService.checkLoginState(); - void dataStorage.ready().then(() => setLoading(false)); - }, []); - - if (user === undefined || loading) { - return <LoadingPage />; - } else { - return ( - <React.Suspense fallback={<LoadingPage />}> - <Router> - <AppBar /> - <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> - <AlertHost /> - </Router> - </React.Suspense> - ); - } -}; - -export default hot(App); diff --git a/Timeline/ClientApp/src/app/common.ts b/Timeline/ClientApp/src/app/common.ts deleted file mode 100644 index 0a2d345f..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/http/common.ts b/Timeline/ClientApp/src/app/http/common.ts deleted file mode 100644 index 54203d1a..00000000 --- a/Timeline/ClientApp/src/app/http/common.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AxiosError, AxiosResponse } from "axios"; - -export const apiBaseUrl = "/api"; - -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); - }); -} - -export function extractStatusCode(error: AxiosError): number | null { - if (error.isAxiosError) { - const code = error?.response?.status; - if (typeof code === "number") { - return code; - } - } - return null; -} - -export interface CommonErrorResponse { - code: number; - message: string; -} - -export function extractErrorCode( - error: AxiosError<CommonErrorResponse> -): number | null { - if (error.isAxiosError) { - const code = error.response?.data?.code; - if (typeof code === "number") { - return code; - } - } - return null; -} - -export class HttpNetworkError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpForbiddenError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class NotModified {} - -export interface BlobWithEtag { - data: Blob; - etag: string; -} - -export function extractResponseData<T>(res: AxiosResponse<T>): T { - return res.data; -} - -export function catchIfStatusCodeIs< - TResult, - TErrorHandlerResult extends TResult | PromiseLike<TResult> | null | undefined ->( - statusCode: number, - errorHandler: (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult -): (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult { - return (error: AxiosError<CommonErrorResponse>) => { - if (extractStatusCode(error) == statusCode) { - return errorHandler(error); - } else { - throw error; - } - }; -} - -export function convertToIfStatusCodeIs<NewError>( - statusCode: number, - newErrorType: { - new (innerError: AxiosError): NewError; - } -): (error: AxiosError<CommonErrorResponse>) => never { - return catchIfStatusCodeIs(statusCode, (error) => { - throw new newErrorType(error); - }); -} - -export function catchIfErrorCodeIs< - TResult, - TErrorHandlerResult extends TResult | PromiseLike<TResult> | null | undefined ->( - errorCode: number, - errorHandler: (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult -): (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult { - return (error: AxiosError<CommonErrorResponse>) => { - if (extractErrorCode(error) == errorCode) { - return errorHandler(error); - } else { - throw error; - } - }; -} -export function convertToIfErrorCodeIs<NewError>( - errorCode: number, - newErrorType: { - new (innerError: AxiosError): NewError; - } -): (error: AxiosError<CommonErrorResponse>) => never { - return catchIfErrorCodeIs(errorCode, (error) => { - throw new newErrorType(error); - }); -} - -export function convertToNetworkError( - error: AxiosError<CommonErrorResponse> -): never { - if (error.isAxiosError && error.response == null) { - throw new HttpNetworkError(error); - } else { - throw error; - } -} - -export function convertToForbiddenError( - error: AxiosError<CommonErrorResponse> -): never { - if ( - error.isAxiosError && - error.response != null && - (error.response.status == 401 || error.response.status == 403) - ) { - throw new HttpForbiddenError(error); - } else { - throw error; - } -} - -export function convertToNotModified( - error: AxiosError<CommonErrorResponse> -): NotModified { - if ( - error.isAxiosError && - error.response != null && - error.response.status == 304 - ) { - return new NotModified(); - } else { - throw error; - } -} - -export function convertToBlobWithEtag(res: AxiosResponse<Blob>): BlobWithEtag { - return { - data: res.data, - etag: (res.headers as Record<"etag", string>)["etag"], - }; -} diff --git a/Timeline/ClientApp/src/app/http/timeline.ts b/Timeline/ClientApp/src/app/http/timeline.ts deleted file mode 100644 index eb7d5065..00000000 --- a/Timeline/ClientApp/src/app/http/timeline.ts +++ /dev/null @@ -1,544 +0,0 @@ -import axios, { AxiosError } from "axios"; - -import { updateQueryString, applyQueryParameters } from "../utilities/url"; - -import { - apiBaseUrl, - extractResponseData, - convertToNetworkError, - base64, - convertToIfStatusCodeIs, - convertToIfErrorCodeIs, - BlobWithEtag, - NotModified, - convertToNotModified, - convertToForbiddenError, - convertToBlobWithEtag, -} from "./common"; -import { HttpUser } from "./user"; - -export const kTimelineVisibilities = ["Public", "Register", "Private"] as const; - -export type TimelineVisibility = typeof kTimelineVisibilities[number]; - -export interface HttpTimelineInfo { - uniqueId: string; - name: string; - description: string; - owner: HttpUser; - visibility: TimelineVisibility; - lastModified: Date; - members: HttpUser[]; -} - -export interface HttpTimelineListQuery { - visibility?: TimelineVisibility; - relate?: string; - relateType?: "own" | "join"; -} - -export interface HttpTimelinePostRequest { - name: string; -} - -export interface HttpTimelinePostTextContent { - type: "text"; - text: string; -} - -export interface HttpTimelinePostImageContent { - type: "image"; -} - -export type HttpTimelinePostContent = - | HttpTimelinePostTextContent - | HttpTimelinePostImageContent; - -export interface HttpTimelinePostInfo { - id: number; - content: HttpTimelinePostContent; - time: Date; - lastUpdated: Date; - author: HttpUser; - deleted: false; -} - -export interface HttpTimelineDeletedPostInfo { - id: number; - time: Date; - lastUpdated: Date; - author?: HttpUser; - deleted: true; -} - -export type HttpTimelineGenericPostInfo = - | HttpTimelinePostInfo - | HttpTimelineDeletedPostInfo; - -export interface HttpTimelinePostPostRequestTextContent { - type: "text"; - text: string; -} - -export interface HttpTimelinePostPostRequestImageContent { - type: "image"; - data: Blob; -} - -export type HttpTimelinePostPostRequestContent = - | HttpTimelinePostPostRequestTextContent - | HttpTimelinePostPostRequestImageContent; - -export interface HttpTimelinePostPostRequest { - content: HttpTimelinePostPostRequestContent; - time?: Date; -} - -export interface HttpTimelinePatchRequest { - visibility?: TimelineVisibility; - description?: string; -} - -export class HttpTimelineNotExistError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpTimelinePostNotExistError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpTimelineNameConflictError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -//-------------------- begin: internal model -------------------- - -interface RawTimelineInfo { - uniqueId: string; - name: string; - description: string; - owner: HttpUser; - visibility: TimelineVisibility; - lastModified: string; - members: HttpUser[]; -} - -interface RawTimelinePostTextContent { - type: "text"; - text: string; -} - -interface RawTimelinePostImageContent { - type: "image"; - url: string; -} - -type RawTimelinePostContent = - | RawTimelinePostTextContent - | RawTimelinePostImageContent; - -interface RawTimelinePostInfo { - id: number; - content: RawTimelinePostContent; - time: string; - lastUpdated: string; - author: HttpUser; - deleted: false; -} - -interface RawTimelineDeletedPostInfo { - id: number; - time: string; - lastUpdated: string; - author: HttpUser; - deleted: true; -} - -type RawTimelineGenericPostInfo = - | RawTimelinePostInfo - | RawTimelineDeletedPostInfo; - -interface RawTimelinePostPostRequestTextContent { - type: "text"; - text: string; -} - -interface RawTimelinePostPostRequestImageContent { - type: "image"; - data: string; -} - -type RawTimelinePostPostRequestContent = - | RawTimelinePostPostRequestTextContent - | RawTimelinePostPostRequestImageContent; - -interface RawTimelinePostPostRequest { - content: RawTimelinePostPostRequestContent; - time?: string; -} - -//-------------------- end: internal model -------------------- - -function processRawTimelineInfo(raw: RawTimelineInfo): HttpTimelineInfo { - return { - ...raw, - lastModified: new Date(raw.lastModified), - }; -} - -function processRawTimelinePostInfo( - raw: RawTimelinePostInfo -): HttpTimelinePostInfo; -function processRawTimelinePostInfo( - raw: RawTimelineGenericPostInfo -): HttpTimelineGenericPostInfo; -function processRawTimelinePostInfo( - raw: RawTimelineGenericPostInfo -): HttpTimelineGenericPostInfo { - return { - ...raw, - time: new Date(raw.time), - lastUpdated: new Date(raw.lastUpdated), - }; -} - -export interface IHttpTimelineClient { - listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]>; - getTimeline(timelineName: string): Promise<HttpTimelineInfo>; - getTimeline( - timelineName: string, - query: { - checkUniqueId?: string; - } - ): Promise<HttpTimelineInfo>; - getTimeline( - timelineName: string, - query: { - checkUniqueId?: string; - ifModifiedSince: Date; - } - ): Promise<HttpTimelineInfo | NotModified>; - postTimeline( - req: HttpTimelinePostRequest, - token: string - ): Promise<HttpTimelineInfo>; - patchTimeline( - timelineName: string, - req: HttpTimelinePatchRequest, - token: string - ): Promise<HttpTimelineInfo>; - deleteTimeline(timelineName: string, token: string): Promise<void>; - memberPut( - timelineName: string, - username: string, - token: string - ): Promise<void>; - memberDelete( - timelineName: string, - username: string, - token: string - ): Promise<void>; - listPost( - timelineName: string, - token?: string - ): Promise<HttpTimelinePostInfo[]>; - listPost( - timelineName: string, - token: string | undefined, - query: { - modifiedSince?: Date; - includeDeleted?: false; - } - ): Promise<HttpTimelinePostInfo[]>; - listPost( - timelineName: string, - token: string | undefined, - query: { - modifiedSince?: Date; - includeDeleted: true; - } - ): Promise<HttpTimelineGenericPostInfo[]>; - getPostData( - timelineName: string, - postId: number, - token?: string - ): Promise<BlobWithEtag>; - getPostData( - timelineName: string, - postId: number, - token: string | undefined, - etag: string - ): Promise<BlobWithEtag | NotModified>; - postPost( - timelineName: string, - req: HttpTimelinePostPostRequest, - token: string - ): Promise<HttpTimelinePostInfo>; - deletePost( - timelineName: string, - postId: number, - token: string - ): Promise<void>; -} - -export class HttpTimelineClient implements IHttpTimelineClient { - listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]> { - return axios - .get<RawTimelineInfo[]>( - applyQueryParameters(`${apiBaseUrl}/timelines`, query) - ) - .then(extractResponseData) - .then((list) => list.map(processRawTimelineInfo)) - .catch(convertToNetworkError); - } - - getTimeline(timelineName: string): Promise<HttpTimelineInfo>; - getTimeline( - timelineName: string, - query: { - checkUniqueId?: string; - } - ): Promise<HttpTimelineInfo>; - getTimeline( - timelineName: string, - query: { - checkUniqueId?: string; - ifModifiedSince: Date; - } - ): Promise<HttpTimelineInfo | NotModified>; - getTimeline( - timelineName: string, - query?: { - checkUniqueId?: string; - ifModifiedSince?: Date; - } - ): Promise<HttpTimelineInfo | NotModified> { - return axios - .get<RawTimelineInfo>( - applyQueryParameters(`${apiBaseUrl}/timelines/${timelineName}`, query) - ) - .then((res) => { - if (res.status === 304) { - return new NotModified(); - } else { - return processRawTimelineInfo(res.data); - } - }) - .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError)) - .catch(convertToNetworkError); - } - - postTimeline( - req: HttpTimelinePostRequest, - token: string - ): Promise<HttpTimelineInfo> { - return axios - .post<RawTimelineInfo>(`${apiBaseUrl}/timelines?token=${token}`, req) - .then(extractResponseData) - .then(processRawTimelineInfo) - .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)) - .catch(convertToNetworkError); - } - - patchTimeline( - timelineName: string, - req: HttpTimelinePatchRequest, - token: string - ): Promise<HttpTimelineInfo> { - return axios - .patch<RawTimelineInfo>( - `${apiBaseUrl}/timelines/${timelineName}?token=${token}`, - req - ) - .then(extractResponseData) - .then(processRawTimelineInfo) - .catch(convertToNetworkError); - } - - deleteTimeline(timelineName: string, token: string): Promise<void> { - return axios - .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`) - .catch(convertToNetworkError) - .then(); - } - - memberPut( - timelineName: string, - username: string, - token: string - ): Promise<void> { - return axios - .put( - `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` - ) - .catch(convertToNetworkError) - .then(); - } - - memberDelete( - timelineName: string, - username: string, - token: string - ): Promise<void> { - return axios - .delete( - `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` - ) - .catch(convertToNetworkError) - .then(); - } - - listPost( - timelineName: string, - token?: string - ): Promise<HttpTimelinePostInfo[]>; - listPost( - timelineName: string, - token: string | undefined, - query: { - modifiedSince?: Date; - includeDeleted?: false; - } - ): Promise<HttpTimelinePostInfo[]>; - listPost( - timelineName: string, - token: string | undefined, - query: { - modifiedSince?: Date; - includeDeleted: true; - } - ): Promise<HttpTimelineGenericPostInfo[]>; - listPost( - timelineName: string, - token?: string, - query?: { - modifiedSince?: Date; - includeDeleted?: boolean; - } - ): Promise<HttpTimelineGenericPostInfo[]> { - let url = `${apiBaseUrl}/timelines/${timelineName}/posts`; - url = updateQueryString("token", token, url); - if (query != null) { - if (query.modifiedSince != null) { - url = updateQueryString( - "modifiedSince", - query.modifiedSince.toISOString(), - url - ); - } - if (query.includeDeleted != null) { - url = updateQueryString( - "includeDeleted", - query.includeDeleted ? "true" : "false", - url - ); - } - } - - return axios - .get<RawTimelineGenericPostInfo[]>(url) - .then(extractResponseData) - .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError)) - .catch(convertToForbiddenError) - .catch(convertToNetworkError) - .then((rawPosts) => - rawPosts.map((raw) => processRawTimelinePostInfo(raw)) - ); - } - - getPostData( - timelineName: string, - postId: number, - token: string - ): Promise<BlobWithEtag>; - getPostData( - timelineName: string, - postId: number, - token?: string, - etag?: string - ): Promise<BlobWithEtag | NotModified> { - const headers = - etag != null - ? { - "If-None-Match": etag, - } - : undefined; - - let url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`; - url = updateQueryString("token", token, url); - - return axios - .get(url, { - responseType: "blob", - headers, - }) - .then(convertToBlobWithEtag) - .catch(convertToNotModified) - .catch(convertToIfStatusCodeIs(404, HttpTimelinePostNotExistError)) - .catch(convertToNetworkError); - } - - async postPost( - timelineName: string, - req: HttpTimelinePostPostRequest, - token: string - ): Promise<HttpTimelinePostInfo> { - let content: RawTimelinePostPostRequestContent; - if (req.content.type === "image") { - const base64Data = await base64(req.content.data); - content = { - ...req.content, - data: base64Data, - } as RawTimelinePostPostRequestImageContent; - } else { - content = req.content; - } - const rawReq: RawTimelinePostPostRequest = { - content, - }; - if (req.time != null) { - rawReq.time = req.time.toISOString(); - } - return await axios - .post<RawTimelinePostInfo>( - `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`, - rawReq - ) - .then(extractResponseData) - .catch(convertToNetworkError) - .then((rawPost) => processRawTimelinePostInfo(rawPost)); - } - - deletePost( - timelineName: string, - postId: number, - token: string - ): Promise<void> { - return axios - .delete( - `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}?token=${token}` - ) - .catch(convertToNetworkError) - .then(); - } -} - -let client: IHttpTimelineClient = new HttpTimelineClient(); - -export function getHttpTimelineClient(): IHttpTimelineClient { - return client; -} - -export function setHttpTimelineClient( - newClient: IHttpTimelineClient -): IHttpTimelineClient { - const old = client; - client = newClient; - return old; -} diff --git a/Timeline/ClientApp/src/app/http/token.ts b/Timeline/ClientApp/src/app/http/token.ts deleted file mode 100644 index ae0cf3f6..00000000 --- a/Timeline/ClientApp/src/app/http/token.ts +++ /dev/null @@ -1,72 +0,0 @@ -import axios, { AxiosError } from "axios"; - -import { - apiBaseUrl, - convertToNetworkError, - convertToIfErrorCodeIs, - extractResponseData, -} from "./common"; -import { HttpUser } from "./user"; - -export interface HttpCreateTokenRequest { - username: string; - password: string; - expire: number; -} - -export interface HttpCreateTokenResponse { - token: string; - user: HttpUser; -} - -export interface HttpVerifyTokenRequest { - token: string; -} - -export interface HttpVerifyTokenResponse { - user: HttpUser; -} - -export class HttpCreateTokenBadCredentialError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export interface IHttpTokenClient { - create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse>; - verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse>; -} - -export class HttpTokenClient implements IHttpTokenClient { - create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse> { - return axios - .post<HttpCreateTokenResponse>(`${apiBaseUrl}/token/create`, req) - .then(extractResponseData) - .catch( - convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError) - ) - .catch(convertToNetworkError); - } - - verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> { - return axios - .post<HttpVerifyTokenResponse>(`${apiBaseUrl}/token/verify`, req) - .then(extractResponseData) - .catch(convertToNetworkError); - } -} - -let client: IHttpTokenClient = new HttpTokenClient(); - -export function getHttpTokenClient(): IHttpTokenClient { - return client; -} - -export function setHttpTokenClient( - newClient: IHttpTokenClient -): IHttpTokenClient { - const old = client; - client = newClient; - return old; -} diff --git a/Timeline/ClientApp/src/app/http/user.ts b/Timeline/ClientApp/src/app/http/user.ts deleted file mode 100644 index a0a02cce..00000000 --- a/Timeline/ClientApp/src/app/http/user.ts +++ /dev/null @@ -1,134 +0,0 @@ -import axios, { AxiosError } from "axios"; - -import { - apiBaseUrl, - convertToNetworkError, - extractResponseData, - convertToIfStatusCodeIs, - convertToIfErrorCodeIs, - NotModified, - BlobWithEtag, - convertToBlobWithEtag, - convertToNotModified, -} from "./common"; - -export interface HttpUser { - uniqueId: string; - username: string; - administrator: boolean; - nickname: string; -} - -export interface HttpUserPatchRequest { - nickname?: string; -} - -export interface HttpChangePasswordRequest { - oldPassword: string; - newPassword: string; -} - -export class HttpUserNotExistError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpChangePasswordBadCredentialError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export interface IHttpUserClient { - get(username: string): Promise<HttpUser>; - patch( - username: string, - req: HttpUserPatchRequest, - token: string - ): Promise<HttpUser>; - getAvatar(username: string): Promise<BlobWithEtag>; - getAvatar( - username: string, - etag: string - ): Promise<BlobWithEtag | NotModified>; - putAvatar(username: string, data: Blob, token: string): Promise<void>; - changePassword(req: HttpChangePasswordRequest, token: string): Promise<void>; -} - -export class HttpUserClient implements IHttpUserClient { - get(username: string): Promise<HttpUser> { - return axios - .get<HttpUser>(`${apiBaseUrl}/users/${username}`) - .then(extractResponseData) - .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)) - .catch(convertToNetworkError); - } - - patch( - username: string, - req: HttpUserPatchRequest, - token: string - ): Promise<HttpUser> { - return axios - .patch<HttpUser>(`${apiBaseUrl}/users/${username}?token=${token}`, req) - .then(extractResponseData) - .catch(convertToNetworkError); - } - - getAvatar(username: string): Promise<BlobWithEtag>; - getAvatar( - username: string, - etag?: string - ): Promise<BlobWithEtag | NotModified> { - const headers = - etag != null - ? { - "If-None-Match": etag, - } - : undefined; - - return axios - .get(`${apiBaseUrl}/users/${username}/avatar`, { - responseType: "blob", - headers, - }) - .then(convertToBlobWithEtag) - .catch(convertToNotModified) - .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)) - .catch(convertToNetworkError); - } - - putAvatar(username: string, data: Blob, token: string): Promise<void> { - return axios - .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, { - headers: { - "Content-Type": data.type, - }, - }) - .catch(convertToNetworkError) - .then(); - } - - changePassword(req: HttpChangePasswordRequest, token: string): Promise<void> { - return axios - .post(`${apiBaseUrl}/userop/changepassword?token=${token}`, req) - .catch( - convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError) - ) - .catch(convertToNetworkError) - .then(); - } -} - -let client: IHttpUserClient = new HttpUserClient(); - -export function getHttpUserClient(): IHttpUserClient { - return client; -} - -export function setHttpUserClient(newClient: IHttpUserClient): IHttpUserClient { - const old = client; - client = newClient; - return old; -} diff --git a/Timeline/ClientApp/src/app/i18n.ts b/Timeline/ClientApp/src/app/i18n.ts deleted file mode 100644 index cdced7bf..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.ejs b/Timeline/ClientApp/src/app/index.ejs deleted file mode 100644 index 49306786..00000000 --- a/Timeline/ClientApp/src/app/index.ejs +++ /dev/null @@ -1,29 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <meta http-equiv="X-UA-Compatible" content="IE=edge" /> - <meta name="viewport" content="width=device-width,initial-scale=1.0" /> - - <link rel="icon" href="/favicon.ico" /> - <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><%= htmlWebpackPlugin.options.title %></title> - </head> - <body> - <noscript> - <strong> - We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work - properly without JavaScript enabled. Please enable it to continue. - </strong> - </noscript> - <div id="app"></div> - <!-- built files will be auto injected --> - </body> -</html> diff --git a/Timeline/ClientApp/src/app/index.sass b/Timeline/ClientApp/src/app/index.sass deleted file mode 100644 index 08e03bac..00000000 --- a/Timeline/ClientApp/src/app/index.sass +++ /dev/null @@ -1,66 +0,0 @@ -@import '~bootstrap/scss/bootstrap' - -@import './views/common/common' -@import './views/common/alert/alert' -@import './views/home/home' -@import './views/about/about' -@import './views/login/login' -@import './views/timeline-common/timeline-common' -@import './views/timeline/timeline' -@import './views/user/user' - -body - margin: 0 - -small - line-height: 1.2 - -.flex-fix-length - flex-grow: 0 - flex-shrink: 0 - -.position-lt - left: 0 - top: 0 - -.avatar - width: 60px - &.large - width: 100px - &.small - width: 40px - -.mt-appbar - margin-top: 56px - -.icon-button - font-size: 1.4em - &.large - 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 00a75a4a..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 c7f33d1e..00000000 --- a/Timeline/ClientApp/src/app/locales/en/translation.ts +++ /dev/null @@ -1,202 +0,0 @@ -import TranslationResource from "../scheme"; - -const translation: TranslationResource = { - welcome: "Welcome!", - search: "Search", - loadFailReload: "Load failed, click <1>here</1> to reload.", - 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", - offlinePrompt: - "Oh oh, it seems you are offline. Here list some timelines cached locally. You can view them or click <1>here</1> to refresh.", - 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.", - }, - postSyncState: { - syncing: "Syncing", - synced: "Synced", - offline: "Offline", - }, - 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 9e3534ac..00000000 --- a/Timeline/ClientApp/src/app/locales/scheme.ts +++ /dev/null @@ -1,182 +0,0 @@ -export default interface TranslationResource { - welcome: string; - search: string; - chooseImage: string; - loadImageError: string; - loadFailReload: 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; - offlinePrompt: 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; - }; - postSyncState: { - syncing: string; - synced: string; - offline: 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 df316366..00000000 --- a/Timeline/ClientApp/src/app/locales/zh/translation.ts +++ /dev/null @@ -1,195 +0,0 @@ -import TranslationResource from "../scheme"; - -const translation: TranslationResource = { - welcome: "欢迎!", - search: "搜索", - loadFailReload: "加载失败,<1>点击</1>重试。", - serviceWorker: { - availableOffline: "Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉", - upgradePrompt: "App 有新版本!", - upgradeNow: "现在升级", - upgradeSuccess: "App 升级成功,当然,你仍可以离线使用它。 🎉🎉🎉", - externalActivatedPrompt: - "一个新的 App 版本已经激活,请刷新页面使用,否则页面可能会出现故障。", - reloadNow: "立刻刷新", - }, - nav: { - settings: "设置", - login: "登陆", - about: "关于", - }, - chooseImage: "选择一个图片", - loadImageError: "加载图片失败", - home: { - go: "冲!", - allTimeline: "所有的时间线", - joinTimeline: "加入的时间线", - ownTimeline: "拥有的时间线", - offlinePrompt: - "你好像处于离线状态。以下是一些缓存在本地的时间线。你可以查看它们或者<1>点击</1>重新获取在线信息。", - 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: "名字不匹配", - }, - postSyncState: { - syncing: "同步中", - synced: "同步成功", - offline: "离线", - }, - 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 3be54bc1..00000000 --- a/Timeline/ClientApp/src/app/service-worker.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "react-bootstrap"; - -import { pushAlert } from "./services/alert"; - -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 - variant="outline-success" - size="sm" - onClick={upgradeReload} - > - {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 variant="outline-success" size="sm" onClick={upgrade}> - {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/services/DataHub.ts b/Timeline/ClientApp/src/app/services/DataHub.ts deleted file mode 100644 index 93a9b41f..00000000 --- a/Timeline/ClientApp/src/app/services/DataHub.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { pull } from "lodash"; -import { Observable, BehaviorSubject, combineLatest } from "rxjs"; -import { map } from "rxjs/operators"; - -export type Subscriber<TData> = (data: TData) => void; - -export type WithSyncStatus<T> = T & { syncing: boolean }; - -export class DataLine<TData> { - private _current: TData | undefined = undefined; - - private _syncPromise: Promise<void> | null = null; - private _syncingSubject = new BehaviorSubject<boolean>(false); - - private _observers: Subscriber<TData>[] = []; - - constructor( - private config: { - sync: () => Promise<void>; - destroyable?: (value: TData | undefined) => boolean; - disableInitSync?: boolean; - } - ) { - if (config.disableInitSync !== true) { - setImmediate(() => void this.sync()); - } - } - - private subscribe(subscriber: Subscriber<TData>): void { - this._observers.push(subscriber); - if (this._current !== undefined) { - subscriber(this._current); - } - } - - private unsubscribe(subscriber: Subscriber<TData>): void { - if (!this._observers.includes(subscriber)) return; - pull(this._observers, subscriber); - } - - getObservable(): Observable<TData> { - return new Observable<TData>((observer) => { - const f = (data: TData): void => { - observer.next(data); - }; - this.subscribe(f); - - return () => { - this.unsubscribe(f); - }; - }); - } - - getSyncStatusObservable(): Observable<boolean> { - return this._syncingSubject.asObservable(); - } - - getDataWithSyncStatusObservable(): Observable<WithSyncStatus<TData>> { - return combineLatest([ - this.getObservable(), - this.getSyncStatusObservable(), - ]).pipe( - map(([data, syncing]) => ({ - ...data, - syncing, - })) - ); - } - - get value(): TData | undefined { - return this._current; - } - - next(value: TData): void { - this._current = value; - this._observers.forEach((observer) => observer(value)); - } - - get isSyncing(): boolean { - return this._syncPromise != null; - } - - sync(): Promise<void> { - if (this._syncPromise == null) { - this._syncingSubject.next(true); - this._syncPromise = this.config.sync().then(() => { - this._syncingSubject.next(false); - this._syncPromise = null; - }); - } - - return this._syncPromise; - } - - syncWithAction( - syncAction: (line: DataLine<TData>) => Promise<void> - ): Promise<void> { - if (this._syncPromise == null) { - this._syncingSubject.next(true); - this._syncPromise = syncAction(this).then(() => { - this._syncingSubject.next(false); - this._syncPromise = null; - }); - } - - return this._syncPromise; - } - - get destroyable(): boolean { - const customDestroyable = this.config?.destroyable; - - return ( - this._observers.length === 0 && - !this.isSyncing && - (customDestroyable != null ? customDestroyable(this._current) : true) - ); - } -} - -export class DataHub<TKey, TData> { - private sync: (key: TKey, line: DataLine<TData>) => Promise<void>; - private keyToString: (key: TKey) => string; - private destroyable?: (key: TKey, value: TData | undefined) => boolean; - - private readonly subscriptionLineMap = new Map<string, DataLine<TData>>(); - - private cleanTimerId = 0; - - // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called. - constructor(config: { - sync: (key: TKey, line: DataLine<TData>) => Promise<void>; - keyToString?: (key: TKey) => string; - destroyable?: (key: TKey, value: TData | undefined) => boolean; - }) { - this.sync = config.sync; - this.keyToString = - config.keyToString ?? - ((value): string => { - if (typeof value === "string") return value; - else - throw new Error( - "Default keyToString function only pass string value." - ); - }); - - this.destroyable = config.destroyable; - } - - private cleanLines(): void { - const toDelete: string[] = []; - for (const [key, line] of this.subscriptionLineMap.entries()) { - if (line.destroyable) { - toDelete.push(key); - } - } - - if (toDelete.length === 0) return; - - for (const key of toDelete) { - this.subscriptionLineMap.delete(key); - } - - if (this.subscriptionLineMap.size === 0) { - window.clearInterval(this.cleanTimerId); - this.cleanTimerId = 0; - } - } - - private createLine(key: TKey, disableInitSync = false): DataLine<TData> { - const keyString = this.keyToString(key); - const { destroyable } = this; - const newLine: DataLine<TData> = new DataLine<TData>({ - sync: () => this.sync(key, newLine), - destroyable: - destroyable != null ? (value) => destroyable(key, value) : undefined, - disableInitSync: disableInitSync, - }); - this.subscriptionLineMap.set(keyString, newLine); - if (this.subscriptionLineMap.size === 1) { - this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); - } - return newLine; - } - - getObservable(key: TKey): Observable<TData> { - return this.getLineOrCreate(key).getObservable(); - } - - getSyncStatusObservable(key: TKey): Observable<boolean> { - return this.getLineOrCreate(key).getSyncStatusObservable(); - } - - getDataWithSyncStatusObservable( - key: TKey - ): Observable<WithSyncStatus<TData>> { - return this.getLineOrCreate(key).getDataWithSyncStatusObservable(); - } - - getLine(key: TKey): DataLine<TData> | null { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? null; - } - - getLineOrCreate(key: TKey): DataLine<TData> { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); - } - - getLineOrCreateWithoutInitSync(key: TKey): DataLine<TData> { - const keyString = this.keyToString(key); - return ( - this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true) - ); - } - - optionalInitLineWithSyncAction( - key: TKey, - syncAction: (line: DataLine<TData>) => Promise<void> - ): Promise<void> { - const optionalLine = this.getLine(key); - if (optionalLine != null) return Promise.resolve(); - const line = this.createLine(key, true); - return line.syncWithAction(syncAction); - } -} diff --git a/Timeline/ClientApp/src/app/services/alert.ts b/Timeline/ClientApp/src/app/services/alert.ts deleted file mode 100644 index e4c0e653..00000000 --- a/Timeline/ClientApp/src/app/services/alert.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/services/common.ts b/Timeline/ClientApp/src/app/services/common.ts deleted file mode 100644 index 3bb6b9d7..00000000 --- a/Timeline/ClientApp/src/app/services/common.ts +++ /dev/null @@ -1,23 +0,0 @@ -import localforage from "localforage"; - -import { HttpNetworkError } from "@/http/common"; - -export const dataStorage = localforage.createInstance({ - name: "data", - description: "Database for offline data.", - driver: localforage.INDEXEDDB, -}); - -export class ForbiddenError extends Error { - constructor(message?: string) { - super(message); - } -} - -export function throwIfNotNetworkError(e: unknown): void { - if (!(e instanceof HttpNetworkError)) { - throw e; - } -} - -export type BlobOrStatus = Blob | "loading" | "error"; diff --git a/Timeline/ClientApp/src/app/services/timeline.ts b/Timeline/ClientApp/src/app/services/timeline.ts deleted file mode 100644 index 9db76281..00000000 --- a/Timeline/ClientApp/src/app/services/timeline.ts +++ /dev/null @@ -1,702 +0,0 @@ -import React from "react"; -import XRegExp from "xregexp"; -import { Observable, from, combineLatest, of } from "rxjs"; -import { map, switchMap, startWith } from "rxjs/operators"; -import { uniqBy } from "lodash"; - -import { convertError } from "@/utilities/rxjs"; -import { - TimelineVisibility, - HttpTimelineInfo, - HttpTimelinePatchRequest, - HttpTimelinePostPostRequest, - HttpTimelinePostPostRequestContent, - HttpTimelinePostPostRequestTextContent, - HttpTimelinePostPostRequestImageContent, - HttpTimelinePostInfo, - HttpTimelinePostTextContent, - getHttpTimelineClient, - HttpTimelineNotExistError, - HttpTimelineNameConflictError, -} from "@/http/timeline"; -import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common"; -import { HttpUser } from "@/http/user"; - -export { kTimelineVisibilities } from "@/http/timeline"; - -export type { TimelineVisibility } from "@/http/timeline"; - -import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common"; -import { DataHub, WithSyncStatus } from "./DataHub"; -import { UserAuthInfo, checkLogin, userService, userInfoService } from "./user"; - -export type TimelineInfo = HttpTimelineInfo; -export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; -export type TimelineCreatePostRequest = HttpTimelinePostPostRequest; -export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent; -export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent; -export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent; - -export type TimelinePostTextContent = HttpTimelinePostTextContent; - -export interface TimelinePostImageContent { - type: "image"; - data: BlobOrStatus; -} - -export type TimelinePostContent = - | TimelinePostTextContent - | TimelinePostImageContent; - -export interface TimelinePostInfo { - id: number; - content: TimelinePostContent; - time: Date; - lastUpdated: Date; - author: HttpUser; -} - -export const timelineVisibilityTooltipTranslationMap: Record< - TimelineVisibility, - string -> = { - Public: "timeline.visibilityTooltip.public", - Register: "timeline.visibilityTooltip.register", - Private: "timeline.visibilityTooltip.private", -}; - -export class TimelineNotExistError extends Error {} -export class TimelineNameConflictError extends Error {} - -export type TimelineWithSyncStatus = WithSyncStatus< - | { - type: "cache"; - timeline: TimelineInfo; - } - | { - type: "offline" | "synced"; - timeline: TimelineInfo | null; - } ->; - -export type TimelinePostsWithSyncState = WithSyncStatus<{ - type: - | "cache" - | "offline" // Sync failed and use cache. - | "synced" // Sync succeeded. - | "forbid" // The list is forbidden to see. - | "notexist"; // The timeline does not exist. - posts: TimelinePostInfo[]; -}>; - -type TimelineData = Omit<HttpTimelineInfo, "owner" | "members"> & { - owner: string; - members: string[]; -}; - -type TimelinePostData = Omit<HttpTimelinePostInfo, "author"> & { - author: string; -}; - -export class TimelineService { - private getCachedTimeline( - timelineName: string - ): Promise<TimelineData | null> { - return dataStorage.getItem<TimelineData | null>(`timeline.${timelineName}`); - } - - private saveTimeline( - timelineName: string, - data: TimelineData - ): Promise<void> { - return dataStorage - .setItem<TimelineData>(`timeline.${timelineName}`, data) - .then(); - } - - private async clearTimelineData(timelineName: string): Promise<void> { - const keys = (await dataStorage.keys()).filter((k) => - k.startsWith(`timeline.${timelineName}`) - ); - await Promise.all(keys.map((k) => dataStorage.removeItem(k))); - } - - private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData { - return { - ...timeline, - owner: timeline.owner.username, - members: timeline.members.map((m) => m.username), - }; - } - - private _timelineHub = new DataHub< - string, - | { - type: "cache"; - timeline: TimelineData; - } - | { - type: "offline" | "synced"; - timeline: TimelineData | null; - } - >({ - sync: async (key, line) => { - const cache = await this.getCachedTimeline(key); - - if (line.value == undefined) { - if (cache != null) { - line.next({ type: "cache", timeline: cache }); - } - } - - try { - const httpTimeline = await getHttpTimelineClient().getTimeline(key); - - userInfoService.saveUsers([ - httpTimeline.owner, - ...httpTimeline.members, - ]); - - const timeline = this.convertHttpTimelineToData(httpTimeline); - - if (cache != null && timeline.uniqueId !== cache.uniqueId) { - console.log( - `Timeline with name ${key} has changed to a new one. Clear old data.` - ); - await this.clearTimelineData(key); // If timeline has changed, clear all old data. - } - - await this.saveTimeline(key, timeline); - - line.next({ type: "synced", timeline }); - } catch (e) { - if (e instanceof HttpTimelineNotExistError) { - line.next({ type: "synced", timeline: null }); - } else { - if (cache == null) { - line.next({ type: "offline", timeline: null }); - } else { - line.next({ type: "offline", timeline: cache }); - } - throwIfNotNetworkError(e); - } - } - }, - }); - - syncTimeline(timelineName: string): Promise<void> { - return this._timelineHub.getLineOrCreate(timelineName).sync(); - } - - getTimeline$(timelineName: string): Observable<TimelineWithSyncStatus> { - return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - const { timeline } = state; - if (timeline != null) { - return combineLatest( - [timeline.owner, ...timeline.members].map((u) => - userInfoService.getUser$(u) - ) - ).pipe( - map((users) => { - return { - ...state, - timeline: { - ...timeline, - owner: users[0], - members: users.slice(1), - }, - }; - }) - ); - } else { - return of(state as TimelineWithSyncStatus); - } - }) - ); - } - - createTimeline(timelineName: string): Observable<TimelineInfo> { - const user = checkLogin(); - return from( - getHttpTimelineClient().postTimeline( - { - name: timelineName, - }, - user.token - ) - ).pipe( - convertError(HttpTimelineNameConflictError, TimelineNameConflictError) - ); - } - - changeTimelineProperty( - timelineName: string, - req: TimelineChangePropertyRequest - ): Observable<TimelineInfo> { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .patchTimeline(timelineName, req, user.token) - .then((timeline) => { - void this.syncTimeline(timelineName); - return timeline; - }) - ); - } - - deleteTimeline(timelineName: string): Observable<unknown> { - const user = checkLogin(); - return from( - getHttpTimelineClient().deleteTimeline(timelineName, user.token) - ); - } - - addMember(timelineName: string, username: string): Observable<unknown> { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .memberPut(timelineName, username, user.token) - .then(() => { - void this.syncTimeline(timelineName); - }) - ); - } - - removeMember(timelineName: string, username: string): Observable<unknown> { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .memberDelete(timelineName, username, user.token) - .then(() => { - void this.syncTimeline(timelineName); - }) - ); - } - - private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData { - return { - ...post, - author: post.author.username, - }; - } - - private convertHttpPostToDataList( - posts: HttpTimelinePostInfo[] - ): TimelinePostData[] { - return posts.map((post) => this.convertHttpPostToData(post)); - } - - private getCachedPosts( - timelineName: string - ): Promise<TimelinePostData[] | null> { - return dataStorage.getItem<TimelinePostData[] | null>( - `timeline.${timelineName}.posts` - ); - } - - private savePosts( - timelineName: string, - data: TimelinePostData[] - ): Promise<void> { - return dataStorage - .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data) - .then(); - } - - private syncPosts(timelineName: string): Promise<void> { - return this._postsHub.getLineOrCreate(timelineName).sync(); - } - - private _postsHub = new DataHub< - string, - { - type: "cache" | "offline" | "synced" | "forbid" | "notexist"; - posts: TimelinePostData[]; - } - >({ - sync: async (key, line) => { - // Wait for timeline synced. In case the timeline has changed to another and old data has been cleaned. - await this.syncTimeline(key); - - if (line.value == null) { - const cache = await this.getCachedPosts(key); - if (cache != null) { - line.next({ type: "cache", posts: cache }); - } - } - - const now = new Date(); - - const lastUpdatedTime = await dataStorage.getItem<Date | null>( - `timeline.${key}.lastUpdated` - ); - - try { - if (lastUpdatedTime == null) { - const httpPosts = await getHttpTimelineClient().listPost( - key, - userService.currentUser?.token - ); - - userInfoService.saveUsers( - uniqBy( - httpPosts.map((post) => post.author), - "username" - ) - ); - - const posts = this.convertHttpPostToDataList(httpPosts); - await this.savePosts(key, posts); - await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now); - - line.next({ type: "synced", posts }); - } else { - const httpPosts = await getHttpTimelineClient().listPost( - key, - userService.currentUser?.token, - { - modifiedSince: lastUpdatedTime, - includeDeleted: true, - } - ); - - const deletedIds = httpPosts - .filter((p) => p.deleted) - .map((p) => p.id); - const changed = httpPosts.filter( - (p): p is HttpTimelinePostInfo => !p.deleted - ); - - userInfoService.saveUsers( - uniqBy( - httpPosts - .map((post) => post.author) - .filter((u): u is HttpUser => u != null), - "username" - ) - ); - - const cache = (await this.getCachedPosts(key)) ?? []; - - const posts = cache.filter((p) => !deletedIds.includes(p.id)); - - for (const changedPost of changed) { - const savedChangedPostIndex = posts.findIndex( - (p) => p.id === changedPost.id - ); - if (savedChangedPostIndex === -1) { - posts.push(this.convertHttpPostToData(changedPost)); - } else { - posts[savedChangedPostIndex] = this.convertHttpPostToData( - changedPost - ); - } - } - - await this.savePosts(key, posts); - await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now); - line.next({ type: "synced", posts }); - } - } catch (e) { - if (e instanceof HttpTimelineNotExistError) { - line.next({ type: "notexist", posts: [] }); - } else if (e instanceof HttpForbiddenError) { - line.next({ type: "forbid", posts: [] }); - } else { - const cache = await this.getCachedPosts(key); - if (cache == null) { - line.next({ type: "offline", posts: [] }); - } else { - line.next({ type: "offline", posts: cache }); - } - throwIfNotNetworkError(e); - } - } - }, - }); - - getPosts$(timelineName: string): Observable<TimelinePostsWithSyncState> { - return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - if (state.posts.length === 0) { - return of({ - ...state, - posts: [], - }); - } - - return combineLatest([ - combineLatest( - state.posts.map((post) => userInfoService.getUser$(post.author)) - ), - combineLatest( - state.posts.map((post) => { - if (post.content.type === "image") { - return this.getPostData$(timelineName, post.id); - } else { - return of(null); - } - }) - ), - ]).pipe( - map(([authors, datas]) => { - return { - ...state, - posts: state.posts.map((post, i) => { - const { content } = post; - - return { - ...post, - author: authors[i], - content: (() => { - if (content.type === "text") return content; - else - return { - type: "image", - data: datas[i], - } as TimelinePostImageContent; - })(), - }; - }), - }; - }) - ); - }) - ); - } - - private getCachedPostData(key: { - timelineName: string; - postId: number; - }): Promise<BlobWithEtag | null> { - return dataStorage.getItem<BlobWithEtag | null>( - `timeline.${key.timelineName}.post.${key.postId}.data` - ); - } - - private savePostData( - key: { - timelineName: string; - postId: number; - }, - data: BlobWithEtag - ): Promise<void> { - return dataStorage - .setItem<BlobWithEtag>( - `timeline.${key.timelineName}.post.${key.postId}.data`, - data - ) - .then(); - } - - private syncPostData(key: { - timelineName: string; - postId: number; - }): Promise<void> { - return this._postDataHub.getLineOrCreate(key).sync(); - } - - private _postDataHub = new DataHub< - { timelineName: string; postId: number }, - | { data: Blob; type: "cache" | "synced" | "offline" } - | { data?: undefined; type: "notexist" | "offline" } - >({ - keyToString: (key) => `${key.timelineName}.${key.postId}`, - sync: async (key, line) => { - const cache = await this.getCachedPostData(key); - if (line.value == null) { - if (cache != null) { - line.next({ type: "cache", data: cache.data }); - } - } - - if (cache == null) { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId - ); - await this.savePostData(key, res); - line.next({ data: res.data, type: "synced" }); - } catch (e) { - line.next({ type: "offline" }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId, - cache.etag - ); - if (res instanceof NotModified) { - line.next({ data: cache.data, type: "synced" }); - } else { - await this.savePostData(key, res); - line.next({ data: res.data, type: "synced" }); - } - } catch (e) { - line.next({ data: cache.data, type: "offline" }); - throwIfNotNetworkError(e); - } - } - }, - }); - - getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> { - return this._postDataHub.getObservable({ timelineName, postId }).pipe( - map((state): BlobOrStatus => state.data ?? "error"), - startWith("loading") - ); - } - - createPost( - timelineName: string, - request: TimelineCreatePostRequest - ): Observable<unknown> { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .postPost(timelineName, request, user.token) - .then(() => { - void this.syncPosts(timelineName); - }) - ); - } - - deletePost(timelineName: string, postId: number): Observable<unknown> { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .deletePost(timelineName, postId, user.token) - .then(() => { - void this.syncPosts(timelineName); - }) - ); - } - - isMemberOf(username: string, timeline: TimelineInfo): boolean { - return timeline.members.findIndex((m) => m.username == username) >= 0; - } - - hasReadPermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo - ): boolean { - if (user != null && user.administrator) return true; - - const { visibility } = timeline; - if (visibility === "Public") { - return true; - } else if (visibility === "Register") { - if (user != null) return true; - } else if (visibility === "Private") { - if ( - user != null && - (user.username === timeline.owner.username || - this.isMemberOf(user.username, timeline)) - ) { - return true; - } - } - return false; - } - - hasPostPermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo - ): boolean { - if (user != null && user.administrator) return true; - - return ( - user != null && - (timeline.owner.username === user.username || - this.isMemberOf(user.username, timeline)) - ); - } - - hasManagePermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo - ): boolean { - if (user != null && user.administrator) return true; - - return user != null && user.username == timeline.owner.username; - } - - hasModifyPostPermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo, - post: TimelinePostInfo - ): boolean { - if (user != null && user.administrator) return true; - - return ( - user != null && - (user.username === timeline.owner.username || - user.username === post.author.username) - ); - } -} - -export const timelineService = new TimelineService(); - -const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); - -export function validateTimelineName(name: string): boolean { - return timelineNameReg.test(name); -} - -export function useTimelineInfo( - timelineName: string -): TimelineWithSyncStatus | undefined { - const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>( - undefined - ); - React.useEffect(() => { - const subscription = timelineService - .getTimeline$(timelineName) - .subscribe((data) => { - setState(data); - }); - return () => { - subscription.unsubscribe(); - }; - }, [timelineName]); - return state; -} - -export function usePostList( - timelineName: string | null | undefined -): TimelinePostsWithSyncState | undefined { - const [state, setState] = React.useState< - TimelinePostsWithSyncState | undefined - >(undefined); - React.useEffect(() => { - if (timelineName == null) { - setState(undefined); - return; - } - - const subscription = timelineService - .getPosts$(timelineName) - .subscribe((data) => { - setState(data); - }); - return () => { - subscription.unsubscribe(); - }; - }, [timelineName]); - return state; -} - -export async function getAllCachedTimelineNames(): Promise<string[]> { - const keys = await dataStorage.keys(); - return keys - .filter( - (key) => - key.startsWith("timeline.") && (key.match(/\./g) ?? []).length === 1 - ) - .map((key) => key.substr("timeline.".length)); -} diff --git a/Timeline/ClientApp/src/app/services/user.ts b/Timeline/ClientApp/src/app/services/user.ts deleted file mode 100644 index f253fc19..00000000 --- a/Timeline/ClientApp/src/app/services/user.ts +++ /dev/null @@ -1,393 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { BehaviorSubject, Observable, from } from "rxjs"; -import { map, filter } from "rxjs/operators"; - -import { UiLogicError } from "@/common"; -import { convertError } from "@/utilities/rxjs"; - -import { HttpNetworkError, BlobWithEtag, NotModified } from "@/http/common"; -import { - getHttpTokenClient, - HttpCreateTokenBadCredentialError, -} from "@/http/token"; -import { - getHttpUserClient, - HttpUserNotExistError, - HttpUser, -} from "@/http/user"; - -import { dataStorage, throwIfNotNetworkError } from "./common"; -import { DataHub } from "./DataHub"; -import { pushAlert } from "./alert"; - -export type User = HttpUser; - -export interface UserAuthInfo { - username: string; - administrator: boolean; -} - -export interface UserWithToken extends User { - token: string; -} - -export interface LoginCredentials { - username: string; - password: string; -} - -export class BadCredentialError { - message = "login.badCredential"; -} - -const USER_STORAGE_KEY = "currentuser"; - -export class UserService { - private userSubject = new BehaviorSubject<UserWithToken | null | undefined>( - undefined - ); - - get user$(): Observable<UserWithToken | null | undefined> { - return this.userSubject; - } - - get currentUser(): UserWithToken | null | undefined { - return this.userSubject.value; - } - - async checkLoginState(): Promise<UserWithToken | null> { - if (this.currentUser !== undefined) { - console.warn("Already checked user. Can't check twice."); - } - - const savedUser = await dataStorage.getItem<UserWithToken | null>( - USER_STORAGE_KEY - ); - - if (savedUser == null) { - this.userSubject.next(null); - return null; - } - - this.userSubject.next(savedUser); - - const savedToken = savedUser.token; - try { - const res = await getHttpTokenClient().verify({ token: savedToken }); - const user: UserWithToken = { ...res.user, token: savedToken }; - await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user); - this.userSubject.next(user); - pushAlert({ - type: "success", - message: { - type: "i18n", - key: "user.welcomeBack", - }, - }); - return user; - } catch (error) { - if (error instanceof HttpNetworkError) { - pushAlert({ - type: "danger", - message: { type: "i18n", key: "user.verifyTokenFailedNetwork" }, - }); - return savedUser; - } else { - await dataStorage.removeItem(USER_STORAGE_KEY); - this.userSubject.next(null); - pushAlert({ - type: "danger", - message: { type: "i18n", key: "user.verifyTokenFailed" }, - }); - return null; - } - } - } - - async login( - credentials: LoginCredentials, - rememberMe: boolean - ): Promise<void> { - if (this.currentUser) { - throw new UiLogicError("Already login."); - } - try { - const res = await getHttpTokenClient().create({ - ...credentials, - expire: 30, - }); - const user: UserWithToken = { - ...res.user, - token: res.token, - }; - if (rememberMe) { - await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user); - } - this.userSubject.next(user); - } catch (e) { - if (e instanceof HttpCreateTokenBadCredentialError) { - throw new BadCredentialError(); - } else { - throw e; - } - } - } - - async logout(): Promise<void> { - if (this.currentUser === undefined) { - throw new UiLogicError("Please check user first."); - } - if (this.currentUser === null) { - throw new UiLogicError("No login."); - } - await dataStorage.removeItem(USER_STORAGE_KEY); - this.userSubject.next(null); - } - - changePassword( - oldPassword: string, - newPassword: string - ): Observable<unknown> { - if (this.currentUser == undefined) { - throw new UiLogicError("Not login or checked now, can't log out."); - } - const $ = from( - getHttpUserClient().changePassword( - { - oldPassword, - newPassword, - }, - this.currentUser.token - ) - ); - $.subscribe(() => { - void this.logout(); - }); - return $; - } -} - -export const userService = new UserService(); - -export function useRawUser(): UserWithToken | null | undefined { - const [user, setUser] = useState<UserWithToken | null | undefined>( - userService.currentUser - ); - useEffect(() => { - const subscription = userService.user$.subscribe((u) => setUser(u)); - return () => { - subscription.unsubscribe(); - }; - }); - return user; -} - -export function useUser(): UserWithToken | null { - const [user, setUser] = useState<UserWithToken | null>(() => { - const initUser = userService.currentUser; - if (initUser === undefined) { - throw new UiLogicError( - "This is a logic error in user module. Current user can't be undefined in useUser." - ); - } - return initUser; - }); - useEffect(() => { - const sub = userService.user$.subscribe((u) => { - if (u === undefined) { - throw new UiLogicError( - "This is a logic error in user module. User emitted can't be undefined later." - ); - } - setUser(u); - }); - return () => { - sub.unsubscribe(); - }; - }); - return user; -} - -export function useUserLoggedIn(): UserWithToken { - const user = useUser(); - if (user == null) { - throw new UiLogicError("You assert user has logged in but actually not."); - } - return user; -} - -export function checkLogin(): UserWithToken { - const user = userService.currentUser; - if (user == null) { - throw new UiLogicError("You must login to perform the operation."); - } - return user; -} - -export class UserNotExistError extends Error {} - -export class UserInfoService { - saveUser(user: HttpUser): void { - const key = user.username; - void this._userHub.optionalInitLineWithSyncAction(key, async (line) => { - await this.doSaveUser(user); - line.next({ user, type: "synced" }); - }); - } - - saveUsers(users: HttpUser[]): void { - return users.forEach((user) => this.saveUser(user)); - } - - private getCachedUser(username: string): Promise<User | null> { - return dataStorage.getItem<HttpUser | null>(`user.${username}`); - } - - private doSaveUser(user: HttpUser): Promise<void> { - return dataStorage.setItem<HttpUser>(`user.${user.username}`, user).then(); - } - - syncUser(username: string): Promise<void> { - return this._userHub.getLineOrCreate(username).sync(); - } - - private _userHub = new DataHub< - string, - | { user: User; type: "cache" | "synced" | "offline" } - | { user?: undefined; type: "notexist" | "offline" } - >({ - sync: async (key, line) => { - if (line.value == undefined) { - const cache = await this.getCachedUser(key); - if (cache != null) { - line.next({ user: cache, type: "cache" }); - } - } - - try { - const res = await getHttpUserClient().get(key); - await this.doSaveUser(res); - line.next({ user: res, type: "synced" }); - } catch (e) { - if (e instanceof HttpUserNotExistError) { - line.next({ type: "notexist" }); - } else { - const cache = await this.getCachedUser(key); - line.next({ user: cache ?? undefined, type: "offline" }); - throwIfNotNetworkError(e); - } - } - }, - }); - - getUser$(username: string): Observable<User> { - return this._userHub.getObservable(username).pipe( - map((state) => state?.user), - filter((user): user is User => user != null) - ); - } - - private getCachedAvatar(username: string): Promise<BlobWithEtag | null> { - return dataStorage.getItem<BlobWithEtag | null>(`user.${username}.avatar`); - } - - private saveAvatar(username: string, data: BlobWithEtag): Promise<void> { - return dataStorage - .setItem<BlobWithEtag>(`user.${username}.avatar`, data) - .then(); - } - - syncAvatar(username: string): Promise<void> { - return this._avatarHub.getLineOrCreate(username).sync(); - } - - private _avatarHub = new DataHub< - string, - | { data: Blob; type: "cache" | "synced" | "offline" } - | { data?: undefined; type: "notexist" | "offline" } - >({ - sync: async (key, line) => { - const cache = await this.getCachedAvatar(key); - if (line.value == null) { - if (cache != null) { - line.next({ data: cache.data, type: "cache" }); - } - } - - if (cache == null) { - try { - const avatar = await getHttpUserClient().getAvatar(key); - await this.saveAvatar(key, avatar); - line.next({ data: avatar.data, type: "synced" }); - } catch (e) { - line.next({ type: "offline" }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpUserClient().getAvatar(key, cache.etag); - if (res instanceof NotModified) { - line.next({ data: cache.data, type: "synced" }); - } else { - const avatar = res; - await this.saveAvatar(key, avatar); - line.next({ data: avatar.data, type: "synced" }); - } - } catch (e) { - line.next({ data: cache.data, type: "offline" }); - throwIfNotNetworkError(e); - } - } - }, - }); - - getAvatar$(username: string): Observable<Blob> { - return this._avatarHub.getObservable(username).pipe( - map((state) => state.data), - filter((blob): blob is Blob => blob != null) - ); - } - - getUserInfo(username: string): Observable<User> { - return from(getHttpUserClient().get(username)).pipe( - convertError(HttpUserNotExistError, UserNotExistError) - ); - } - - async setAvatar(username: string, blob: Blob): Promise<void> { - const user = checkLogin(); - await getHttpUserClient().putAvatar(username, blob, user.token); - this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" }); - } - - async setNickname(username: string, nickname: string): Promise<void> { - const user = checkLogin(); - return getHttpUserClient() - .patch(username, { nickname }, user.token) - .then((user) => { - this.saveUser(user); - }); - } -} - -export const userInfoService = new UserInfoService(); - -export function useAvatar(username?: string): Blob | undefined { - const [state, setState] = React.useState<Blob | undefined>(undefined); - React.useEffect(() => { - if (username == null) { - setState(undefined); - return; - } - - const subscription = userInfoService - .getAvatar$(username) - .subscribe((blob) => { - setState(blob); - }); - return () => { - subscription.unsubscribe(); - }; - }, [username]); - return state; -} 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/typings.d.ts b/Timeline/ClientApp/src/app/typings.d.ts deleted file mode 100644 index 34381682..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/utilities/rxjs.ts b/Timeline/ClientApp/src/app/utilities/rxjs.ts deleted file mode 100644 index 0730b899..00000000 --- a/Timeline/ClientApp/src/app/utilities/rxjs.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { OperatorFunction } from "rxjs"; -import { catchError } from "rxjs/operators"; - -export function convertError<T, NewError>( - oldErrorType: { new (...args: never[]): unknown }, - newErrorType: { new (): NewError } -): OperatorFunction<T, T> { - return catchError((error) => { - if (error instanceof oldErrorType) { - throw new newErrorType(); - } - throw error; - }); -} diff --git a/Timeline/ClientApp/src/app/utilities/url.ts b/Timeline/ClientApp/src/app/utilities/url.ts deleted file mode 100644 index 17ead5b2..00000000 --- a/Timeline/ClientApp/src/app/utilities/url.ts +++ /dev/null @@ -1,52 +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: undefined | string | null, - url: string -): string { - 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; - } - } -} - -export function applyQueryParameters<T>(url: string, query: T): string { - if (query == null) return url; - - for (const [key, value] of Object.entries(query)) { - if (typeof value === "string") url = updateQueryString(key, value, url); - else if (typeof value === "number") - url = updateQueryString(key, String(value), url); - else if (typeof value === "boolean") - url = updateQueryString(key, value ? "true" : "false", url); - else if (value instanceof Date) - url = updateQueryString(key, value.toISOString(), url); - else { - console.error("Unknown query parameter type. Param: ", value); - } - } - return url; -} diff --git a/Timeline/ClientApp/src/app/views/about/about.sass b/Timeline/ClientApp/src/app/views/about/about.sass deleted file mode 100644 index 3b5840cd..00000000 --- a/Timeline/ClientApp/src/app/views/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/views/about/author-avatar.png b/Timeline/ClientApp/src/app/views/about/author-avatar.png Binary files differdeleted file mode 100644 index d890d8d0..00000000 --- a/Timeline/ClientApp/src/app/views/about/author-avatar.png +++ /dev/null diff --git a/Timeline/ClientApp/src/app/views/about/github.png b/Timeline/ClientApp/src/app/views/about/github.png Binary files differdeleted file mode 100644 index ea6ff545..00000000 --- a/Timeline/ClientApp/src/app/views/about/github.png +++ /dev/null diff --git a/Timeline/ClientApp/src/app/views/about/index.tsx b/Timeline/ClientApp/src/app/views/about/index.tsx deleted file mode 100644 index e7771cec..00000000 --- a/Timeline/ClientApp/src/app/views/about/index.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React from "react"; -import { useTranslation, Trans } from "react-i18next"; - -import authorAvatarUrl from "./author-avatar.png"; -import githubLogoUrl from "./github.png"; - -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: "react-bootstrap", - url: "https://react-bootstrap.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 AboutPage: React.FC = () => { - const { t } = useTranslation(); - - return ( - <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 AboutPage; diff --git a/Timeline/ClientApp/src/app/views/admin/Admin.tsx b/Timeline/ClientApp/src/app/views/admin/Admin.tsx deleted file mode 100644 index 9c0250e7..00000000 --- a/Timeline/ClientApp/src/app/views/admin/Admin.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { Fragment } from "react"; -import { - Redirect, - Route, - Switch, - useRouteMatch, - useHistory, -} from "react-router"; -import { Nav } from "react-bootstrap"; - -import { UserWithToken } from "@/services/user"; - -import UserAdmin from "./UserAdmin"; - -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}`}> - <div style={{ height: 56 }} className="flex-fix-length" /> - <Nav variant="tabs"> - <Nav.Item> - <Nav.Link - active={tabName === "users"} - onClick={() => { - toggle("users"); - }} - > - Users - </Nav.Link> - </Nav.Item> - <Nav.Item> - <Nav.Link - active={tabName === "more"} - onClick={() => { - toggle("more"); - }} - > - More - </Nav.Link> - </Nav.Item> - </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/views/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx deleted file mode 100644 index 18b77ca8..00000000 --- a/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import React, { useState, useEffect } from "react"; -import axios from "axios"; -import { - ListGroup, - Row, - Col, - Dropdown, - Spinner, - Button, -} from "react-bootstrap"; - -import OperationDialog from "../common/OperationDialog"; -import { User, UserWithToken } from "@/services/user"; - -const apiBaseUrl = "/api"; - -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 ( - <ListGroup.Item 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"> - <Dropdown> - <Dropdown.Toggle variant="warning" className="text-light"> - Manage - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item onClick={createClickCallback(kChangeUsername)}> - Change Username - </Dropdown.Item> - <Dropdown.Item onClick={createClickCallback(kChangePassword)}> - Change Password - </Dropdown.Item> - <Dropdown.Item onClick={createClickCallback(kChangePermission)}> - Change Permission - </Dropdown.Item> - <Dropdown.Item - className="text-danger" - onClick={createClickCallback(kDelete)} - > - Delete - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </Col> - </Row> - </ListGroup.Item> - ); -}; - -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 - variant="success" - onClick={() => - setDialog({ - type: "create", - }) - } - className="align-self-end" - > - Create User - </Button> - {userComponents} - {dialogNode} - </> - ); - } else { - return <Spinner animation="border" />; - } -}; - -export default UserAdmin; diff --git a/Timeline/ClientApp/src/app/views/common/AppBar.tsx b/Timeline/ClientApp/src/app/views/common/AppBar.tsx deleted file mode 100644 index ee4ead8f..00000000 --- a/Timeline/ClientApp/src/app/views/common/AppBar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { LinkContainer } from "react-router-bootstrap"; -import { Navbar, Nav } from "react-bootstrap"; - -import { useUser, useAvatar } from "@/services/user"; - -import TimelineLogo from "./TimelineLogo"; -import BlobImage from "./BlobImage"; - -const AppBar: React.FC = (_) => { - const user = useUser(); - const avatar = useAvatar(user?.username); - - const { t } = useTranslation(); - - const isAdministrator = user && user.administrator; - - return ( - <Navbar bg="primary" variant="dark" expand="md" sticky="top"> - <LinkContainer to="/"> - <Navbar.Brand className="d-flex align-items-center"> - <TimelineLogo style={{ height: "1em" }} /> - Timeline - </Navbar.Brand> - </LinkContainer> - - <Navbar.Toggle /> - <Navbar.Collapse> - <Nav className="mr-auto"> - <LinkContainer to="/settings"> - <Nav.Link>{t("nav.settings")}</Nav.Link> - </LinkContainer> - - <LinkContainer to="/about"> - <Nav.Link>{t("nav.about")}</Nav.Link> - </LinkContainer> - - {isAdministrator && ( - <LinkContainer to="/admin"> - <Nav.Link>Administration</Nav.Link> - </LinkContainer> - )} - </Nav> - <Nav className="ml-auto mr-2"> - {user != null ? ( - <LinkContainer to={`/users/${user.username}`}> - <BlobImage - className="avatar small rounded-circle bg-white" - blob={avatar} - /> - </LinkContainer> - ) : ( - <LinkContainer to="/login"> - <Nav.Link>{t("nav.login")}</Nav.Link> - </LinkContainer> - )} - </Nav> - </Navbar.Collapse> - </Navbar> - ); -}; - -export default AppBar; diff --git a/Timeline/ClientApp/src/app/views/common/BlobImage.tsx b/Timeline/ClientApp/src/app/views/common/BlobImage.tsx deleted file mode 100644 index 0dd25c52..00000000 --- a/Timeline/ClientApp/src/app/views/common/BlobImage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; - -const BlobImage: React.FC< - Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & { - blob?: Blob | unknown; - } -> = (props) => { - const { blob, ...otherProps } = props; - - const [url, setUrl] = React.useState<string | undefined>(undefined); - - React.useEffect(() => { - if (blob instanceof Blob) { - const url = URL.createObjectURL(blob); - setUrl(url); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setUrl(undefined); - } - }, [blob]); - - return <img {...otherProps} src={url} />; -}; - -export default BlobImage; diff --git a/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx deleted file mode 100644 index b9db8b99..00000000 --- a/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import 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/views/common/LoadingButton.tsx b/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx deleted file mode 100644 index 154334a7..00000000 --- a/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { Button, ButtonProps, Spinner } from "react-bootstrap"; - -const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({ - loading, - variant, - disabled, - ...otherProps -}) => { - return ( - <Button - variant={variant != null ? `outline-${variant}` : "outline-primary"} - disabled={disabled || loading} - {...otherProps} - > - {otherProps.children} - {loading ? ( - <Spinner - className="ml-1" - variant={variant} - animation="grow" - size="sm" - /> - ) : null} - </Button> - ); -}; - -export default LoadingButton; diff --git a/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx deleted file mode 100644 index 590fafa0..00000000 --- a/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import { Spinner } from "react-bootstrap"; - -const LoadingPage: React.FC = () => { - return ( - <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center"> - <Spinner variant="primary" animation="border" /> - </div> - ); -}; - -export default LoadingPage; diff --git a/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx deleted file mode 100644 index 841392a6..00000000 --- a/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Form, Button, Modal } from "react-bootstrap"; - -import { UiLogicError } from "@/common"; - -import LoadingButton from "./LoadingButton"; - -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" | "aria-relevant" - >; - 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" || step === "process") { - const process = step === "process"; - - 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 = ( - <> - <Modal.Body> - {inputPrompt} - {inputScheme.map((item, index) => { - const value = values[index]; - const error: string | undefined = ((e) => - typeof e === "string" ? t(e) : undefined)(inputError?.[index]); - - if (item.type === "text") { - return ( - <Form.Group key={index}> - {item.label && <Form.Label>{t(item.label)}</Form.Label>} - <Form.Control - 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) - ) - ); - }} - isInvalid={error != null} - disabled={process} - /> - {error != null && ( - <Form.Control.Feedback type="invalid"> - {error} - </Form.Control.Feedback> - )} - {item.helperText && ( - <Form.Text>{t(item.helperText)}</Form.Text> - )} - </Form.Group> - ); - } else if (item.type === "bool") { - return ( - <Form.Group key={index}> - <Form.Check<"input"> - type="checkbox" - checked={value as boolean} - onChange={(event) => { - updateValue(index, event.currentTarget.checked); - }} - label={t(item.label)} - disabled={process} - /> - </Form.Group> - ); - } else if (item.type === "select") { - return ( - <Form.Group key={index}> - <Form.Label>{t(item.label)}</Form.Label> - <Form.Control - as="select" - value={value as string} - onChange={(event) => { - updateValue(index, event.target.value); - }} - disabled={process} - > - {item.options.map((option, i) => { - return ( - <option value={option.value} key={i}> - {option.icon} - {t(option.label)} - </option> - ); - })} - </Form.Control> - </Form.Group> - ); - } - })} - </Modal.Body> - <Modal.Footer> - <Button variant="outline-secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <LoadingButton - variant="primary" - loading={process} - disabled={testErrorInfo(inputError)} - onClick={() => { - if (validateAll()) { - onConfirm(); - } - }} - > - {t("operationDialog.confirm")} - </LoadingButton> - </Modal.Footer> - </> - ); - } 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 = ( - <> - <Modal.Body>{content}</Modal.Body> - <Modal.Footer> - <Button variant="primary" onClick={close}> - {t("operationDialog.ok")} - </Button> - </Modal.Footer> - </> - ); - } - - const title = typeof props.title === "string" ? t(props.title) : props.title; - - return ( - <Modal show={props.open} onHide={close}> - <Modal.Header - className={ - props.titleColor != null - ? "text-" + - (props.titleColor === "create" - ? "success" - : props.titleColor === "dangerous" - ? "danger" - : props.titleColor) - : undefined - } - > - {title} - </Modal.Header> - {body} - </Modal> - ); -}; - -export default OperationDialog; diff --git a/Timeline/ClientApp/src/app/views/common/SearchInput.tsx b/Timeline/ClientApp/src/app/views/common/SearchInput.tsx deleted file mode 100644 index 9833d515..00000000 --- a/Timeline/ClientApp/src/app/views/common/SearchInput.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useCallback } from "react"; -import clsx from "clsx"; -import { useTranslation } from "react-i18next"; -import { Spinner, Form, Button } from "react-bootstrap"; - -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 ( - <Form inline className={clsx("my-2", props.className)}> - <Form.Control - className="mr-sm-2 flex-grow-1" - 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 variant="primary" animation="border" /> - ) : ( - <Button variant="outline-primary" onClick={props.onButtonClick}> - {props.buttonText ?? t("search")} - </Button> - )} - </div> - </Form> - ); -}; - -export default SearchInput; diff --git a/Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx deleted file mode 100644 index 27d188fc..00000000 --- a/Timeline/ClientApp/src/app/views/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/views/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx deleted file mode 100644 index 29f6a69f..00000000 --- a/Timeline/ClientApp/src/app/views/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/views/common/alert/AlertHost.tsx b/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx deleted file mode 100644 index c74f18e2..00000000 --- a/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useCallback } from "react"; -import without from "lodash/without"; -import concat from "lodash/concat"; -import { useTranslation } from "react-i18next"; -import { Alert } from "react-bootstrap"; - -import { - alertService, - AlertInfoEx, - kAlertHostId, - AlertInfo, -} from "@/services/alert"; - -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" - variant={alert.type ?? "primary"} - onClose={props.close} - dismissible - > - {(() => { - 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; -} - -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/views/common/alert/alert.sass b/Timeline/ClientApp/src/app/views/common/alert/alert.sass deleted file mode 100644 index 5b6e65c2..00000000 --- a/Timeline/ClientApp/src/app/views/common/alert/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/views/common/common.sass b/Timeline/ClientApp/src/app/views/common/common.sass deleted file mode 100644 index 15d34d7c..00000000 --- a/Timeline/ClientApp/src/app/views/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/views/home/BoardWithUser.tsx b/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx deleted file mode 100644 index dcd39cbe..00000000 --- a/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from "react"; -import { Row, Col } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -import { UserWithToken } from "@/services/user"; -import { TimelineInfo } from "@/services/timeline"; -import { getHttpTimelineClient } from "@/http/timeline"; - -import TimelineBoard from "./TimelineBoard"; -import OfflineBoard from "./OfflineBoard"; - -const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => { - const { t } = useTranslation(); - - const [ownTimelines, setOwnTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" - >("loading"); - const [joinTimelines, setJoinTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" - >("loading"); - - React.useEffect(() => { - let subscribe = true; - if (ownTimelines === "loading") { - void getHttpTimelineClient() - .listTimeline({ relate: user.username, relateType: "own" }) - .then( - (timelines) => { - if (subscribe) { - setOwnTimelines(timelines); - } - }, - () => { - setOwnTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [user, ownTimelines]); - - React.useEffect(() => { - let subscribe = true; - if (joinTimelines === "loading") { - void getHttpTimelineClient() - .listTimeline({ relate: user.username, relateType: "join" }) - .then( - (timelines) => { - if (subscribe) { - setJoinTimelines(timelines); - } - }, - () => { - setJoinTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [user, joinTimelines]); - - return ( - <Row className="my-2 justify-content-center"> - {ownTimelines === "offline" && joinTimelines === "offline" ? ( - <Col className="py-2" sm="8" lg="6"> - <OfflineBoard - onReload={() => { - setOwnTimelines("loading"); - setJoinTimelines("loading"); - }} - /> - </Col> - ) : ( - <> - <Col sm="6" lg="5" className="py-2"> - <TimelineBoard - title={t("home.ownTimeline")} - timelines={ownTimelines} - onReload={() => { - setOwnTimelines("loading"); - }} - /> - </Col> - <Col sm="6" lg="5" className="py-2"> - <TimelineBoard - title={t("home.joinTimeline")} - timelines={joinTimelines} - onReload={() => { - setJoinTimelines("loading"); - }} - /> - </Col> - </> - )} - </Row> - ); -}; - -export default BoardWithUser; diff --git a/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx b/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx deleted file mode 100644 index ebfddb50..00000000 --- a/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { Row, Col } from "react-bootstrap"; - -import { TimelineInfo } from "@/services/timeline"; -import { getHttpTimelineClient } from "@/http/timeline"; - -import TimelineBoard from "./TimelineBoard"; -import OfflineBoard from "./OfflineBoard"; - -const BoardWithoutUser: React.FC = () => { - const [publicTimelines, setPublicTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" - >("loading"); - - React.useEffect(() => { - let subscribe = true; - if (publicTimelines === "loading") { - void getHttpTimelineClient() - .listTimeline({ visibility: "Public" }) - .then( - (timelines) => { - if (subscribe) { - setPublicTimelines(timelines); - } - }, - () => { - setPublicTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [publicTimelines]); - - return ( - <Row className="my-2 justify-content-center"> - {publicTimelines === "offline" ? ( - <Col sm="8" lg="6"> - <OfflineBoard - onReload={() => { - setPublicTimelines("loading"); - }} - /> - </Col> - ) : ( - <Col sm="8" lg="6"> - <TimelineBoard - timelines={publicTimelines} - onReload={() => { - setPublicTimelines("loading"); - }} - /> - </Col> - )} - </Row> - ); -}; - -export default BoardWithoutUser; diff --git a/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx b/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx deleted file mode 100644 index fc05bd74..00000000 --- a/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -import { Trans } from "react-i18next"; - -import { getAllCachedTimelineNames } from "@/services/timeline"; -import UserTimelineLogo from "../common/UserTimelineLogo"; -import TimelineLogo from "../common/TimelineLogo"; - -export interface OfflineBoardProps { - onReload: () => void; -} - -const OfflineBoard: React.FC<OfflineBoardProps> = ({ onReload }) => { - const [timelines, setTimelines] = React.useState<string[]>([]); - - React.useEffect(() => { - let subscribe = true; - void getAllCachedTimelineNames().then((t) => { - if (subscribe) setTimelines(t); - }); - return () => { - subscribe = false; - }; - }); - - return ( - <> - <Trans i18nKey="home.offlinePrompt"> - 0 - <a - href="#" - onClick={(e) => { - onReload(); - e.preventDefault(); - }} - > - 1 - </a> - 2 - </Trans> - {timelines.map((timeline) => { - const isPersonal = timeline.startsWith("@"); - const url = isPersonal - ? `/users/${timeline.slice(1)}` - : `/timelines/${timeline}`; - return ( - <div key={timeline} className="timeline-board-item"> - {isPersonal ? ( - <UserTimelineLogo className="icon" /> - ) : ( - <TimelineLogo className="icon" /> - )} - <Link to={url}>{timeline}</Link> - </div> - ); - })} - </> - ); -}; - -export default OfflineBoard; diff --git a/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx deleted file mode 100644 index a3d176e1..00000000 --- a/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -import { Link } from "react-router-dom"; -import { Trans } from "react-i18next"; -import { Spinner } from "react-bootstrap"; - -import { TimelineInfo } from "@/services/timeline"; -import TimelineLogo from "../common/TimelineLogo"; -import UserTimelineLogo from "../common/UserTimelineLogo"; - -export interface TimelineBoardProps { - title?: string; - timelines: TimelineInfo[] | "offline" | "loading"; - onReload: () => void; - 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 === "loading") { - return ( - <div className="d-flex flex-grow-1 justify-content-center align-items-center"> - <Spinner variant="primary" animation="border" /> - </div> - ); - } else if (timelines === "offline") { - return ( - <div className="d-flex flex-grow-1 justify-content-center align-items-center"> - <Trans i18nKey="loadFailReload" parent="div"> - 0 - <a - href="#" - onClick={(e) => { - props.onReload(); - e.preventDefault(); - }} - > - 1 - </a> - 2 - </Trans> - </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/views/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx deleted file mode 100644 index d9467719..00000000 --- a/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; - -import { validateTimelineName, timelineService } from "@/services/timeline"; -import OperationDialog from "../common/OperationDialog"; - -interface TimelineCreateDialogProps { - open: boolean; - close: () => void; -} - -const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { - const history = useHistory(); - - 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 timelineService.createTimeline(nameSaved).toPromise(); - }} - onSuccessAndClose={() => { - history.push(`timelines/${nameSaved}`); - }} - failurePrompt={(e) => `${e as string}`} - /> - ); -}; - -export default TimelineCreateDialog; diff --git a/Timeline/ClientApp/src/app/views/home/home.sass b/Timeline/ClientApp/src/app/views/home/home.sass deleted file mode 100644 index f5d6ffc3..00000000 --- a/Timeline/ClientApp/src/app/views/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/views/home/index.tsx b/Timeline/ClientApp/src/app/views/home/index.tsx deleted file mode 100644 index 760adcea..00000000 --- a/Timeline/ClientApp/src/app/views/home/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Row, Container, Button, Col } from "react-bootstrap"; - -import { useUser } from "@/services/user"; -import SearchInput from "../common/SearchInput"; - -import BoardWithoutUser from "./BoardWithoutUser"; -import BoardWithUser from "./BoardWithUser"; -import TimelineCreateDialog from "./TimelineCreateDialog"; - -const HomePage: React.FC = () => { - const history = useHistory(); - - const { t } = useTranslation(); - - const user = useUser(); - - const [navText, setNavText] = React.useState<string>(""); - - 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]); - - return ( - <> - <Container fluid> - <Row className="justify-content-center"> - <Col xs={12} sm={10} md={8} lg={6}> - <SearchInput - className="justify-content-center" - value={navText} - onChange={setNavText} - onButtonClick={goto} - buttonText={t("home.go")} - placeholder="@crupest" - additionalButton={ - user != null && ( - <Button - variant="outline-success" - onClick={() => { - setDialog("create"); - }} - > - {t("home.createButton")} - </Button> - ) - } - /> - </Col> - </Row> - {(() => { - if (user == null) { - return <BoardWithoutUser />; - } else { - return <BoardWithUser user={user} />; - } - })()} - </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={() => { - setDialog(null); - }} - /> - )} - </> - ); -}; - -export default HomePage; diff --git a/Timeline/ClientApp/src/app/views/login/index.tsx b/Timeline/ClientApp/src/app/views/login/index.tsx deleted file mode 100644 index 61b9a525..00000000 --- a/Timeline/ClientApp/src/app/views/login/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Container, Form } from "react-bootstrap"; - -import { useUser, userService } from "@/services/user"; - -import AppBar from "../common/AppBar"; -import LoadingButton from "../common/LoadingButton"; - -const LoginPage: React.FC = (_) => { - const { t } = useTranslation(); - const history = useHistory(); - const [username, setUsername] = React.useState<string>(""); - const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false); - const [password, setPassword] = React.useState<string>(""); - const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false); - const [rememberMe, setRememberMe] = React.useState<boolean>(true); - const [process, setProcess] = React.useState<boolean>(false); - const [error, setError] = React.useState<string | null>(null); - - const user = useUser(); - - React.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> - </> - ); - } - - const submit = (): void => { - if (username === "" || password === "") { - setUsernameDirty(true); - setPasswordDirty(true); - return; - } - - setProcess(true); - userService - .login( - { - username: username, - password: password, - }, - rememberMe - ) - .then( - () => { - if (history.length === 0) { - history.push("/"); - } else { - history.goBack(); - } - }, - (e: Error) => { - setProcess(false); - setError(e.message); - } - ); - }; - - const onEnterPressInPassword: React.KeyboardEventHandler = (e) => { - if (e.key === "Enter") { - submit(); - } - }; - - return ( - <Container fluid className="login-container mt-2"> - <h1 className="text-center">{t("welcome")}</h1> - <Form> - <Form.Group> - <Form.Label htmlFor="username">{t("user.username")}</Form.Label> - <Form.Control - id="username" - disabled={process} - onChange={(e) => { - setUsername(e.target.value); - setUsernameDirty(true); - }} - value={username} - isInvalid={usernameDirty && username === ""} - /> - {usernameDirty && username === "" && ( - <Form.Control.Feedback type="invalid"> - {t("login.emptyUsername")} - </Form.Control.Feedback> - )} - </Form.Group> - <Form.Group> - <Form.Label htmlFor="password">{t("user.password")}</Form.Label> - <Form.Control - id="password" - type="password" - disabled={process} - onChange={(e) => { - setPassword(e.target.value); - setPasswordDirty(true); - }} - value={password} - onKeyDown={onEnterPressInPassword} - isInvalid={passwordDirty && password === ""} - /> - {passwordDirty && password === "" && ( - <Form.Control.Feedback type="invalid"> - {t("login.emptyPassword")} - </Form.Control.Feedback> - )} - </Form.Group> - <Form.Group> - <Form.Check<"input"> - id="remember-me" - type="checkbox" - checked={rememberMe} - onChange={(e) => { - setRememberMe(e.currentTarget.checked); - }} - label={t("user.rememberMe")} - /> - </Form.Group> - {error ? <p className="text-danger">{t(error)}</p> : null} - <div className="text-right"> - <LoadingButton - loading={process} - variant="primary" - onClick={(e) => { - submit(); - e.preventDefault(); - }} - disabled={username === "" || password === "" ? true : undefined} - > - {t("user.login")} - </LoadingButton> - </div> - </Form> - </Container> - ); -}; - -export default LoginPage; diff --git a/Timeline/ClientApp/src/app/views/login/login.sass b/Timeline/ClientApp/src/app/views/login/login.sass deleted file mode 100644 index 0bf385f5..00000000 --- a/Timeline/ClientApp/src/app/views/login/login.sass +++ /dev/null @@ -1,2 +0,0 @@ -.login-container
- max-width: 600px
diff --git a/Timeline/ClientApp/src/app/views/settings/index.tsx b/Timeline/ClientApp/src/app/views/settings/index.tsx deleted file mode 100644 index 964e7442..00000000 --- a/Timeline/ClientApp/src/app/views/settings/index.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import React, { useState } from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap"; - -import { useUser, userService } from "@/services/user"; -import OperationDialog, { - OperationInputErrorInfo, -} from "../common/OperationDialog"; - -interface ChangePasswordDialogProps { - open: boolean; - close: () => void; -} - -const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { - 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 userService - .changePassword(oldPassword as string, newPassword as string) - .toPromise(); - await userService.logout(); - setRedirect(true); - }} - close={() => { - props.close(); - if (redirect) { - history.push("/login"); - } - }} - /> - ); -}; - -const ConfirmLogoutDialog: React.FC<{ - toggle: () => void; - onConfirm: () => void; -}> = ({ toggle, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal show centered onHide={toggle}> - <Modal.Header> - <Modal.Title className="text-danger"> - {t("settings.dialogConfirmLogout.title")} - </Modal.Title> - </Modal.Header> - <Modal.Body>{t("settings.dialogConfirmLogout.prompt")}</Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={toggle}> - {t("operationDialog.cancel")} - </Button> - <Button variant="danger" onClick={onConfirm}> - {t("operationDialog.confirm")} - </Button> - </Modal.Footer> - </Modal> - ); -}; - -const SettingsPage: 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 ( - <Container fluid> - {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"> - <Form.Control - as="select" - value={language} - onChange={(e) => { - void i18n.changeLanguage(e.target.value); - }} - > - <option value="zh">中文</option> - <option value="en">English</option> - </Form.Control> - </Col> - </Row> - {(() => { - switch (dialog) { - case "changepassword": - return ( - <ChangePasswordDialog - open - close={() => { - setDialog(null); - }} - /> - ); - case "logout": - return ( - <ConfirmLogoutDialog - toggle={() => setDialog(null)} - onConfirm={() => { - void userService.logout().then(() => { - history.push("/"); - }); - }} - /> - ); - default: - return null; - } - })()} - </Container> - ); -}; - -export default SettingsPage; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx b/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx deleted file mode 100644 index 3c52150f..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -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"; - -const CollapseButton: React.FC<{ - collapse: boolean; - onClick: () => void; - className?: string; - style?: React.CSSProperties; -}> = ({ collapse, onClick, className, style }) => { - return ( - <Svg - src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon} - onClick={onClick} - className={clsx("text-primary icon-button", className)} - style={style} - /> - ); -}; - -export default CollapseButton; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx deleted file mode 100644 index a8de20aa..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import clsx from "clsx"; - -import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; -import SyncStatusBadge from "../timeline-common/SyncStatusBadge"; -import CollapseButton from "../timeline-common/CollapseButton"; - -const InfoCardTemplate: React.FC< - Pick< - TimelineCardComponentProps<"">, - "collapse" | "toggleCollapse" | "syncStatus" | "className" - > & { children: React.ReactElement[] } -> = ({ collapse, toggleCollapse, syncStatus, className, children }) => { - return ( - <div className={clsx("cru-card p-2 clearfix", className)}> - <div className="float-right d-flex align-items-center"> - <SyncStatusBadge status={syncStatus} className="mr-2" /> - <CollapseButton collapse={collapse} onClick={toggleCollapse} /> - </div> - - <div style={{ display: collapse ? "none" : "block" }}>{children}</div> - </div> - ); -}; - -export default InfoCardTemplate; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx b/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx deleted file mode 100644 index e67cfb43..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -import { useTranslation } from "react-i18next"; - -import { UiLogicError } from "@/common"; - -export type TimelineSyncStatus = "syncing" | "synced" | "offline"; - -const SyncStatusBadge: React.FC<{ - status: TimelineSyncStatus; - style?: React.CSSProperties; - className?: string; -}> = ({ status, style, className }) => { - const { t } = useTranslation(); - - return ( - <div style={style} className={clsx("timeline-sync-state-badge", className)}> - {(() => { - switch (status) { - case "syncing": { - return ( - <> - <span className="timeline-sync-state-badge-pin bg-warning" /> - <span className="text-warning"> - {t("timeline.postSyncState.syncing")} - </span> - </> - ); - } - case "synced": { - return ( - <> - <span className="timeline-sync-state-badge-pin bg-success" /> - <span className="text-success"> - {t("timeline.postSyncState.synced")} - </span> - </> - ); - } - case "offline": { - return ( - <> - <span className="timeline-sync-state-badge-pin bg-danger" /> - <span className="text-danger"> - {t("timeline.postSyncState.offline")} - </span> - </> - ); - } - default: - throw new UiLogicError("Unknown sync state."); - } - })()} - </div> - ); -}; - -export default SyncStatusBadge; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx b/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx deleted file mode 100644 index fd051d45..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from "react"; -import clsx from "clsx"; - -import { TimelinePostInfo } from "@/services/timeline"; - -import TimelineItem from "./TimelineItem"; - -export interface TimelinePostInfoEx extends TimelinePostInfo { - deletable: boolean; -} - -export type TimelineDeleteCallback = (index: number, id: number) => void; - -export interface TimelineProps { - className?: string; - posts: TimelinePostInfoEx[]; - onDelete: TimelineDeleteCallback; - onResize?: () => void; - containerRef?: React.Ref<HTMLDivElement>; -} - -const Timeline: React.FC<TimelineProps> = (props) => { - const { posts, onDelete, onResize } = props; - - const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState< - number - >(-1); - - const onItemClick = React.useCallback(() => { - setIndexShowDeleteButton(-1); - }, []); - - const onToggleDelete = React.useMemo(() => { - return posts.map((post, i) => { - return post.deletable - ? () => { - setIndexShowDeleteButton((oldIndexShowDeleteButton) => { - return oldIndexShowDeleteButton !== i ? i : -1; - }); - } - : undefined; - }); - }, [posts]); - - const onItemDelete = React.useMemo(() => { - return posts.map((post, i) => { - return () => { - onDelete(i, post.id); - }; - }); - }, [posts, onDelete]); - - return ( - <div ref={props.containerRef} className={clsx("timeline", props.className)}> - {(() => { - const length = posts.length; - return posts.map((post, i) => { - 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} - onResize={onResize} - /> - ); - }); - })()} - </div> - ); -}; - -export default Timeline; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx deleted file mode 100644 index 4db23371..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -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 { Modal, Button } from "react-bootstrap"; - -import { useAvatar } from "@/services/user"; -import { TimelinePostInfo } from "@/services/timeline"; - -import BlobImage from "../common/BlobImage"; - -const TimelinePostDeleteConfirmDialog: React.FC<{ - toggle: () => void; - onConfirm: () => void; -}> = ({ toggle, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal toggle={toggle} isOpen centered> - <Modal.Header> - <Modal.Title className="text-danger"> - {t("timeline.post.deleteDialog.title")} - </Modal.Title> - </Modal.Header> - <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={toggle}> - {t("operationDialog.cancel")} - </Button> - <Button - variant="danger" - onClick={() => { - onConfirm(); - toggle(); - }} - > - {t("operationDialog.confirm")} - </Button> - </Modal.Footer> - </Modal> - ); -}; - -export interface TimelineItemProps { - post: TimelinePostInfo; - current?: boolean; - more?: { - isOpen: boolean; - toggle: () => void; - onDelete: () => void; - }; - onClick?: () => void; - onResize?: () => void; - className?: string; - style?: React.CSSProperties; -} - -const TimelineItem: React.FC<TimelineItemProps> = (props) => { - const { i18n } = useTranslation(); - - const current = props.current === true; - - const { more, onResize } = props; - - const avatar = useAvatar(props.post.author.username); - - const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false); - const toggleDeleteDialog = React.useCallback( - () => setDeleteDialog((old) => !old), - [] - ); - - return ( - <div - className={clsx( - "timeline-item position-relative", - current && "current", - props.className - )} - onClick={props.onClick} - style={props.style} - > - <div className="timeline-line-area-container"> - <div 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" />} - </div> - </div> - <div className="timeline-content-area"> - <div> - <span className="mr-2"> - <span className="text-primary white-space-no-wrap mr-2"> - {props.post.time.toLocaleString(i18n.languages)} - </span> - <small className="text-dark">{props.post.author.nickname}</small> - </span> - {more != null ? ( - <Svg - src={chevronDownIcon} - className="text-info icon-button" - onClick={(e) => { - more.toggle(); - e.stopPropagation(); - }} - /> - ) : null} - </div> - <div className="timeline-content"> - <Link - className="float-left m-2" - to={"/users/" + props.post.author.username} - > - <BlobImage - onLoad={onResize} - blob={avatar} - className="avatar rounded" - /> - </Link> - {(() => { - const { content } = props.post; - if (content.type === "text") { - return content.text; - } else { - return ( - <BlobImage - onLoad={onResize} - blob={content.data} - className="timeline-content-image" - /> - ); - } - })()} - </div> - </div> - {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 icon-button large" - onClick={(e) => { - toggleDeleteDialog(); - e.stopPropagation(); - }} - /> - </div> - {deleteDialog ? ( - <TimelinePostDeleteConfirmDialog - toggle={() => { - toggleDeleteDialog(); - more.toggle(); - }} - onConfirm={more.onDelete} - /> - ) : null} - </> - ) : null} - </div> - ); -}; - -export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx deleted file mode 100644 index 67a8543a..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap"; - -import { User, useAvatar } from "@/services/user"; - -import SearchInput from "../common/SearchInput"; -import BlobImage from "../common/BlobImage"; - -const TimelineMemberItem: React.FC<{ - user: User; - owner: boolean; - onRemove?: (username: string) => void; -}> = ({ user, owner, onRemove }) => { - const { t } = useTranslation(); - - const avatar = useAvatar(user.username); - - return ( - <ListGroup.Item className="container"> - <Row> - <Col xs="auto"> - <BlobImage blob={avatar} className="avatar small" /> - </Col> - <Col> - <Row>{user.nickname}</Row> - <Row> - <small>{"@" + user.username}</small> - </Row> - </Col> - {(() => { - if (owner) { - return null; - } - if (onRemove == null) { - return null; - } - return ( - <Button - className="align-self-center" - variant="danger" - onClick={() => { - onRemove(user.username); - }} - > - {t("timeline.member.remove")} - </Button> - ); - })()} - </Row> - </ListGroup.Item> - ); -}; - -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 userSearchAvatar = useAvatar( - userSearchState.type === "user" ? userSearchState.data.username : undefined - ); - - const members = props.members; - - return ( - <Container className="px-4"> - <ListGroup className="my-3"> - {members.map((member, index) => ( - <TimelineMemberItem - key={member.username} - user={member} - owner={index === 0} - onRemove={props.edit?.onRemoveUser} - /> - ))} - </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"> - <BlobImage - blob={userSearchAvatar} - className="avatar small" - /> - </Col> - <Col> - <Row>{u.nickname}</Row> - <Row> - <small>{"@" + u.username}</small> - </Row> - </Col> - <Button - variant="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 show centered onHide={props.onClose}> - <TimelineMember {...props} /> - </Modal> - ); -}; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx deleted file mode 100644 index d5c91622..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { of } from "rxjs"; -import { catchError } from "rxjs/operators"; - -import { UiLogicError } from "@/common"; -import { pushAlert } from "@/services/alert"; -import { useUser, userInfoService, UserNotExistError } from "@/services/user"; -import { - timelineService, - usePostList, - useTimelineInfo, -} from "@/services/timeline"; - -import { TimelineDeleteCallback } from "./Timeline"; -import { TimelineMemberDialog } from "./TimelineMember"; -import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; -import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI"; -import { TimelinePostSendCallback } from "./TimelinePostEdit"; - -export interface TimelinePageTemplateProps<TManageItem> { - name: string; - onManage: (item: TManageItem) => void; - UiComponent: React.ComponentType< - Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent"> - >; - notFoundI18nKey: string; -} - -export default function TimelinePageTemplate<TManageItem>( - props: TimelinePageTemplateProps<TManageItem> -): React.ReactElement | null { - const { t } = useTranslation(); - - const { name } = props; - - const service = timelineService; - - const user = useUser(); - - const [dialog, setDialog] = React.useState<null | "property" | "member">( - null - ); - - const timelineState = useTimelineInfo(name); - - const timeline = timelineState?.timeline; - - const postListState = usePostList(name); - - const error: string | undefined = (() => { - if (timelineState != null) { - const { type, timeline } = timelineState; - if (type === "offline" && timeline == null) return "Network Error"; - if (type === "synced" && timeline == null) - return t(props.notFoundI18nKey); - } - return undefined; - })(); - - 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.changeTimelineProperty(name, req).toPromise().then(); - }} - /> - ); - } 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 userInfoService - .getUserInfo(u) - .pipe( - catchError((e) => { - if (e instanceof UserNotExistError) { - return of(null); - } else { - throw e; - } - }) - ) - .toPromise(); - }, - onAddUser: (u) => { - return service.addMember(name, u.username).toPromise().then(); - }, - onRemoveUser: (u) => { - service.removeMember(name, u); - }, - } - : null - } - /> - ); - } - - const { UiComponent } = props; - - const onDelete: TimelineDeleteCallback = React.useCallback( - (index, id) => { - service.deletePost(name, id).subscribe(null, () => { - pushAlert({ - type: "danger", - message: t("timeline.deletePostFailed"), - }); - }); - }, - [service, name, t] - ); - - const onPost: TimelinePostSendCallback = React.useCallback( - (req) => { - return service.createPost(name, req).toPromise().then(); - }, - [service, name] - ); - - const onManageProp = props.onManage; - - const onManage = React.useCallback( - (item: "property" | TManageItem) => { - if (item === "property") { - setDialog(item); - } else { - onManageProp(item); - } - }, - [onManageProp] - ); - - return ( - <> - <UiComponent - error={error} - timeline={timeline ?? undefined} - postListState={postListState} - onDelete={onDelete} - onPost={ - timeline != null && service.hasPostPermission(user, timeline) - ? onPost - : undefined - } - onManage={ - timeline != null && service.hasManagePermission(user, timeline) - ? onManage - : undefined - } - onMember={() => setDialog("member")} - /> - {dialogElement} - </> - ); -} diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx deleted file mode 100644 index 6c2c43c1..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { fromEvent } from "rxjs"; -import { Spinner } from "react-bootstrap"; - -import { getAlertHost } from "@/services/alert"; -import { useEventEmiiter, UiLogicError } from "@/common"; -import { - TimelineInfo, - TimelinePostsWithSyncState, - timelineService, -} from "@/services/timeline"; -import { userService } from "@/services/user"; - -import Timeline, { - TimelinePostInfoEx, - TimelineDeleteCallback, -} from "./Timeline"; -import TimelineTop from "./TimelineTop"; -import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit"; -import { TimelineSyncStatus } from "./SyncStatusBadge"; - -export interface TimelineCardComponentProps<TManageItems> { - timeline: TimelineInfo; - onManage?: (item: TManageItems | "property") => void; - onMember: () => void; - className?: string; - collapse: boolean; - syncStatus: TimelineSyncStatus; - toggleCollapse: () => void; -} - -export interface TimelinePageTemplateUIProps<TManageItems> { - timeline?: TimelineInfo; - postListState?: TimelinePostsWithSyncState; - CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>; - onMember: () => void; - onManage?: (item: TManageItems | "property") => void; - onPost?: TimelinePostSendCallback; - onDelete: TimelineDeleteCallback; - error?: string; -} - -export default function TimelinePageTemplateUI<TManageItems>( - props: TimelinePageTemplateUIProps<TManageItems> -): React.ReactElement | null { - const { timeline, postListState } = 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, postListState]); - - const genCardCollapseLocalStorageKey = (uniqueId: string): string => - `timeline.${uniqueId}.cardCollapse`; - - const cardCollapseLocalStorageKey = - timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null; - - const [cardCollapse, setCardCollapse] = React.useState<boolean>(true); - React.useEffect(() => { - if (cardCollapseLocalStorageKey != null) { - const savedCollapse = - window.localStorage.getItem(cardCollapseLocalStorageKey) === "true"; - setCardCollapse(savedCollapse); - } - }, [cardCollapseLocalStorageKey]); - - const toggleCardCollapse = (): void => { - const newState = !cardCollapse; - setCardCollapse(newState); - if (timeline != null) { - window.localStorage.setItem( - genCardCollapseLocalStorageKey(timeline.uniqueId), - newState.toString() - ); - } - }; - - 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 (postListState != null) { - if (postListState.type === "notexist") { - throw new UiLogicError( - "Timeline is not null but post list state is notexist." - ); - } - if (postListState.type === "forbid") { - timelineBody = ( - <p className="text-danger">{t("timeline.messageCantSee")}</p> - ); - } else { - const posts: TimelinePostInfoEx[] = postListState.posts.map( - (post) => ({ - ...post, - deletable: timelineService.hasModifyPostPermission( - userService.currentUser, - timeline, - post - ), - }) - ); - - timelineBody = ( - <Timeline - containerRef={timelineRef} - posts={posts} - onDelete={props.onDelete} - onResize={triggerResizeEvent} - /> - ); - if (props.onPost != null) { - timelineBody = ( - <> - {timelineBody} - <div ref={bottomSpaceRef} className="flex-fix-length" /> - <TimelinePostEdit - className="fixed-bottom" - onPost={props.onPost} - onHeightChange={onPostEditHeightChange} - timelineUniqueId={timeline.uniqueId} - /> - </> - ); - } - } - } else { - timelineBody = ( - <div className="full-viewport-center-child"> - <Spinner variant="primary" animation="grow" /> - </div> - ); - } - - const { CardComponent } = props; - const syncStatus: TimelineSyncStatus = - postListState == null || postListState.syncing - ? "syncing" - : postListState.type === "synced" - ? "synced" - : "offline"; - - body = ( - <> - <CardComponent - className="timeline-template-card" - timeline={timeline} - onManage={props.onManage} - onMember={props.onMember} - syncStatus={syncStatus} - collapse={cardCollapse} - toggleCollapse={toggleCardCollapse} - /> - <TimelineTop height="56px" /> - {timelineBody} - </> - ); - } else { - body = ( - <div className="full-viewport-center-child"> - <Spinner variant="primary" animation="grow" /> - </div> - ); - } - } - - return body; -} diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx deleted file mode 100644 index dfa2f879..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -import { useTranslation } from "react-i18next"; -import Svg from "react-inlinesvg"; -import { Button, Spinner, Row, Col, Form } from "react-bootstrap"; -import textIcon from "bootstrap-icons/icons/card-text.svg"; -import imageIcon from "bootstrap-icons/icons/image.svg"; - -import { UiLogicError } from "@/common"; - -import { pushAlert } from "@/services/alert"; -import { TimelineCreatePostRequest } from "@/services/timeline"; - -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 ( - <> - <Form.File - label={t("chooseImage")} - onChange={onInputChange} - accept="image/*" - className="mx-3 my-1 d-inline-block" - /> - {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: TimelineCreatePostRequest -) => 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: TimelineCreatePostRequest = (() => { - switch (kind) { - case "text": - return { - content: { - type: "text", - text: text, - }, - } as TimelineCreatePostRequest; - case "image": - if (imageBlob == null) { - throw new UiLogicError( - "Content type is image but image blob is null." - ); - } - return { - content: { - type: "image", - data: imageBlob, - }, - } as TimelineCreatePostRequest; - default: - throw new UiLogicError("Unknown content type."); - } - })(); - - onPost(req).then( - (_) => { - if (kind === "text") { - setText(""); - window.localStorage.removeItem(draftLocalStorageKey); - } - setState("input"); - setKind("text"); - }, - (_) => { - pushAlert({ - type: "danger", - message: t("timeline.sendPostFailed"), - }); - setState("input"); - } - ); - }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]); - - const onImageSelect = React.useCallback((blob: Blob | null) => { - setImageBlob(blob); - }, []); - - return ( - <div - ref={containerRef} - className={clsx("container-fluid bg-light", props.className)} - > - <Row> - <Col className="px-1 py-1"> - {kind === "text" ? ( - <Form.Control - as="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 xs="auto" className="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 - variant="primary" - onClick={onSend} - disabled={!canSend} - > - {t("timeline.send")} - </Button> - </> - ); - } else { - return <Spinner variant="primary" animation="border" />; - } - })()} - </Col> - </Row> - </div> - ); -}; - -export default TimelinePostEdit; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index 87638f31..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from "react"; - -import { - TimelineVisibility, - kTimelineVisibilities, - TimelineChangePropertyRequest, -} from "@/services/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: TimelineChangePropertyRequest) => 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: TimelineChangePropertyRequest = {}; - 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/views/timeline-common/TimelineTop.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx deleted file mode 100644 index 93a2a32c..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; - -export interface TimelineTopProps { - height?: number | string; - children?: React.ReactElement; -} - -const TimelineTop: React.FC<TimelineTopProps> = ({ height, children }) => { - return ( - <div style={{ height: height }} className="timeline-top"> - <div className="timeline-line-area-container"> - <div className="timeline-line-area"> - <div className="timeline-line-segment"></div> - </div> - </div> - {children} - </div> - ); -}; - -export default TimelineTop; diff --git a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass deleted file mode 100644 index 4151bfcc..00000000 --- a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass +++ /dev/null @@ -1,146 +0,0 @@ -@use 'sass:color' - -.timeline - z-index: 0 - position: relative - - &-item - display: flex - -$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-container - display: flex - justify-content: flex-end - padding-right: 5px - - flex: 0 0 auto - width: 60px - - &-area - display: flex - flex-direction: column - align-items: center - width: 30px - - &-segment - width: $timeline-line-width - background: $timeline-line-color - - &.start - height: 14px - 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 - -.timeline-top - display: flex - justify-content: space-between - - .timeline-line-segment - flex: 1 1 auto - -.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-content-area - padding: 10px 0 - flex-grow: 1 - -.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 - -.timeline-page-top-space - transition: height 0.5s - -.timeline-sync-state-badge - font-size: 0.8em - padding: 3px 8px - border-radius: 5px - background: #e8fbff - -.timeline-sync-state-badge-pin - display: inline-block - width: 0.4em - height: 0.4em - border-radius: 50% - vertical-align: middle - margin-right: 0.6em - -.timeline-template-card - position: fixed - z-index: 1 - top: 56px - right: 0 - margin: 0.5em diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index 894b8195..00000000 --- a/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { Trans } from "react-i18next"; - -import { timelineService } from "@/services/timeline"; - -import OperationDialog from "../common/OperationDialog"; - -interface TimelineDeleteDialog { - open: boolean; - name: string; - close: () => void; -} - -const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { - 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 timelineService.deleteTimeline(name).toPromise(); - }} - onSuccessAndClose={() => { - history.replace("/"); - }} - /> - ); -}; - -export default TimelineDeleteDialog; diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx deleted file mode 100644 index 2d787709..00000000 --- a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Dropdown, Button } from "react-bootstrap"; - -import { useAvatar } from "@/services/user"; -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; - -import BlobImage from "../common/BlobImage"; -import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; -import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; - -export type OrdinaryTimelineManageItem = "delete"; - -export type TimelineInfoCardProps = TimelineCardComponentProps< - OrdinaryTimelineManageItem ->; - -const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { - const { - timeline, - collapse, - onMember, - onManage, - syncStatus, - toggleCollapse, - } = props; - - const { t } = useTranslation(); - - const avatar = useAvatar(timeline?.owner?.username); - - return ( - <InfoCardTemplate - className={props.className} - syncStatus={syncStatus} - collapse={collapse} - toggleCollapse={toggleCollapse} - > - <h3 className="text-primary mx-3 d-inline-block align-middle"> - {timeline.name} - </h3> - <div className="d-inline-block align-middle"> - <BlobImage blob={avatar} className="avatar small rounded-circle" /> - {timeline.owner.nickname} - <small className="ml-3 text-secondary"> - @{timeline.owner.username} - </small> - </div> - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="text-right mt-2"> - {onManage != null ? ( - <Dropdown> - <Dropdown.Toggle variant="outline-primary"> - {t("timeline.manage")} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item onClick={() => onManage("property")}> - {t("timeline.manageItem.property")} - </Dropdown.Item> - <Dropdown.Item onClick={onMember}> - {t("timeline.manageItem.member")} - </Dropdown.Item> - <Dropdown.Divider /> - <Dropdown.Item - className="text-danger" - onClick={() => onManage("delete")} - > - {t("timeline.manageItem.delete")} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - ) : ( - <Button variant="outline-primary" onClick={onMember}> - {t("timeline.memberButton")} - </Button> - )} - </div> - </InfoCardTemplate> - ); -}; - -export default TimelineInfoCard; diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx deleted file mode 100644 index 67ea699e..00000000 --- a/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from "react"; - -import TimelinePageTemplateUI, { - TimelinePageTemplateUIProps, -} from "../timeline-common/TimelinePageTemplateUI"; - -import TimelineInfoCard, { - OrdinaryTimelineManageItem, -} from "./TimelineInfoCard"; - -export type TimelinePageUIProps = Omit< - TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>, - "CardComponent" ->; - -const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => { - return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />; -}; - -export default TimelinePageUI; diff --git a/Timeline/ClientApp/src/app/views/timeline/index.tsx b/Timeline/ClientApp/src/app/views/timeline/index.tsx deleted file mode 100644 index 225a1a59..00000000 --- a/Timeline/ClientApp/src/app/views/timeline/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { useParams } from "react-router"; - -import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; - -import TimelinePageUI from "./TimelinePageUI"; -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)} - notFoundI18nKey="timeline.timelineNotExist" - /> - {dialogElement} - </> - ); -}; - -export default TimelinePage; diff --git a/Timeline/ClientApp/src/app/views/timeline/timeline.sass b/Timeline/ClientApp/src/app/views/timeline/timeline.sass deleted file mode 100644 index e69de29b..00000000 --- a/Timeline/ClientApp/src/app/views/timeline/timeline.sass +++ /dev/null diff --git a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx deleted file mode 100644 index ffa2218b..00000000 --- a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { AxiosError } from "axios"; -import { Modal, Row, Button } from "react-bootstrap"; - -import { UiLogicError } from "@/common"; - -import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; - -export interface ChangeAvatarDialogProps { - open: boolean; - close: () => void; - process: (blob: Blob) => Promise<void>; -} - -const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { - const { t } = useTranslation(); - - const [file, setFile] = React.useState<File | null>(null); - const [fileUrl, setFileUrl] = React.useState<string | null>(null); - const [clip, setClip] = React.useState<Clip | null>(null); - const [ - cropImgElement, - setCropImgElement, - ] = React.useState<HTMLImageElement | null>(null); - const [resultBlob, setResultBlob] = React.useState<Blob | null>(null); - const [resultUrl, setResultUrl] = React.useState<string | null>(null); - - const [state, setState] = React.useState< - | "select" - | "crop" - | "processcrop" - | "preview" - | "uploading" - | "success" - | "error" - >("select"); - - const [message, setMessage] = useState< - string | { type: "custom"; text: string } | null - >("userPage.dialogChangeAvatar.prompt.select"); - - const trueMessage = - message == null - ? null - : typeof message === "string" - ? t(message) - : message.text; - - const closeDialog = props.close; - - const close = 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 show={props.open} onHide={close}> - <Modal.Header> - <Modal.Title> {t("userPage.dialogChangeAvatar.title")}</Modal.Title> - </Modal.Header> - {(() => { - if (state === "select") { - return ( - <> - <Modal.Body className="container"> - <Row>{t("userPage.dialogChangeAvatar.prompt.select")}</Row> - <Row> - <input type="file" accept="image/*" onChange={onSelectFile} /> - </Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - </Modal.Footer> - </> - ); - } else if (state === "crop") { - if (fileUrl == null) { - throw new UiLogicError(); - } - return ( - <> - <Modal.Body className="container"> - <Row className="justify-content-center"> - <ImageCropper - clip={clip} - onChange={setClip} - imageUrl={fileUrl} - imageElementCallback={setCropImgElement} - /> - </Row> - <Row>{t("userPage.dialogChangeAvatar.prompt.crop")}</Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="secondary" onClick={onCropPrevious}> - {t("operationDialog.previousStep")} - </Button> - <Button - color="primary" - onClick={onCropNext} - disabled={ - cropImgElement == null || clip == null || clip.width === 0 - } - > - {t("operationDialog.nextStep")} - </Button> - </Modal.Footer> - </> - ); - } else if (state === "processcrop") { - return ( - <> - <Modal.Body className="container"> - <Row> - {t("userPage.dialogChangeAvatar.prompt.processingCrop")} - </Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="secondary" onClick={onPreviewPrevious}> - {t("operationDialog.previousStep")} - </Button> - </Modal.Footer> - </> - ); - } else if (state === "preview") { - return ( - <> - <Modal.Body className="container"> - {createPreviewRow()} - <Row>{t("userPage.dialogChangeAvatar.prompt.preview")}</Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="secondary" onClick={onPreviewPrevious}> - {t("operationDialog.previousStep")} - </Button> - <Button variant="primary" onClick={upload}> - {t("userPage.dialogChangeAvatar.upload")} - </Button> - </Modal.Footer> - </> - ); - } else if (state === "uploading") { - return ( - <> - <Modal.Body className="container"> - {createPreviewRow()} - <Row>{t("userPage.dialogChangeAvatar.prompt.uploading")}</Row> - </Modal.Body> - <Modal.Footer></Modal.Footer> - </> - ); - } else if (state === "success") { - return ( - <> - <Modal.Body className="container"> - <Row className="p-4 text-success"> - {t("operationDialog.success")} - </Row> - </Modal.Body> - <Modal.Footer> - <Button variant="success" onClick={close}> - {t("operationDialog.ok")} - </Button> - </Modal.Footer> - </> - ); - } else { - return ( - <> - <Modal.Body className="container"> - {createPreviewRow()} - <Row className="text-danger">{trueMessage}</Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="primary" onClick={upload}> - {t("operationDialog.retry")} - </Button> - </Modal.Footer> - </> - ); - } - })()} - </Modal> - ); -}; - -export default ChangeAvatarDialog; diff --git a/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx deleted file mode 100644 index 251b18c5..00000000 --- a/Timeline/ClientApp/src/app/views/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/views/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx deleted file mode 100644 index 888fb18a..00000000 --- a/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Dropdown, Button } from "react-bootstrap"; - -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; -import { useAvatar } from "@/services/user"; - -import BlobImage from "../common/BlobImage"; -import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; -import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; - -export type PersonalTimelineManageItem = "avatar" | "nickname"; - -export type UserInfoCardProps = TimelineCardComponentProps< - PersonalTimelineManageItem ->; - -const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { - const { - timeline, - collapse, - onMember, - onManage, - syncStatus, - toggleCollapse, - } = props; - const { t } = useTranslation(); - - const avatar = useAvatar(timeline?.owner?.username); - - return ( - <InfoCardTemplate - className={props.className} - syncStatus={syncStatus} - collapse={collapse} - toggleCollapse={toggleCollapse} - > - <div> - <BlobImage blob={avatar} className="avatar" /> - {timeline.owner.nickname} - <small className="ml-3 text-secondary"> - @{timeline.owner.username} - </small> - </div> - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="text-right mt-2"> - {onManage != null ? ( - <Dropdown> - <Dropdown.Toggle variant="outline-primary"> - {t("timeline.manage")} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item onClick={() => onManage("nickname")}> - {t("timeline.manageItem.nickname")} - </Dropdown.Item> - <Dropdown.Item onClick={() => onManage("avatar")}> - {t("timeline.manageItem.avatar")} - </Dropdown.Item> - <Dropdown.Item onClick={() => onManage("property")}> - {t("timeline.manageItem.property")} - </Dropdown.Item> - <Dropdown.Item onClick={onMember}> - {t("timeline.manageItem.member")} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - ) : ( - <Button variant="outline-primary" onClick={onMember}> - {t("timeline.memberButton")} - </Button> - )} - </div> - </InfoCardTemplate> - ); -}; - -export default UserInfoCard; diff --git a/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx b/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx deleted file mode 100644 index d405399c..00000000 --- a/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; - -import TimelinePageTemplateUI, { - TimelinePageTemplateUIProps, -} from "../timeline-common/TimelinePageTemplateUI"; - -import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard"; - -export type UserPageUIProps = Omit< - TimelinePageTemplateUIProps<PersonalTimelineManageItem>, - "CardComponent" ->; - -const UserPageUI: React.FC<UserPageUIProps> = (props) => { - return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />; -}; - -export default UserPageUI; diff --git a/Timeline/ClientApp/src/app/views/user/index.tsx b/Timeline/ClientApp/src/app/views/user/index.tsx deleted file mode 100644 index 7c0b1563..00000000 --- a/Timeline/ClientApp/src/app/views/user/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from "react"; -import { useParams } from "react-router"; - -import { UiLogicError } from "@/common"; -import { useUser, userInfoService } from "@/services/user"; - -import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; - -import UserPageUI from "./UserPageUI"; -import { PersonalTimelineManageItem } from "./UserInfoCard"; -import ChangeNicknameDialog from "./ChangeNicknameDialog"; -import ChangeAvatarDialog from "./ChangeAvatarDialog"; - -const UserPage: React.FC = (_) => { - const { username } = useParams<{ username: string }>(); - - const user = useUser(); - - const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null); - - 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) => - userInfoService.setNickname(username, newNickname) - } - /> - ); - } else if (dialog === "avatar") { - if (user == null) { - throw new UiLogicError("Change avatar without login."); - } - - dialogElement = ( - <ChangeAvatarDialog - open - close={closeDialogHandler} - process={(file) => userInfoService.setAvatar(username, file)} - /> - ); - } - - const onManage = React.useCallback((item: PersonalTimelineManageItem) => { - setDialog(item); - }, []); - - return ( - <> - <TimelinePageTemplate - name={`@${username}`} - UiComponent={UserPageUI} - onManage={onManage} - notFoundI18nKey="timeline.userNotExist" - /> - {dialogElement} - </> - ); -}; - -export default UserPage; diff --git a/Timeline/ClientApp/src/app/views/user/user.sass b/Timeline/ClientApp/src/app/views/user/user.sass deleted file mode 100644 index 5b7fcae7..00000000 --- a/Timeline/ClientApp/src/app/views/user/user.sass +++ /dev/null @@ -1,7 +0,0 @@ -.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 d6202f36..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 6937be63..00000000 --- a/Timeline/ClientApp/src/tsconfig.json +++ /dev/null @@ -1,23 +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, - "jsx": "preserve", - "sourceMap": true, - "baseUrl": "./", - "paths": { - "@/*": [ - "app/*" - ] - } - } -} diff --git a/Timeline/ClientApp/webpack.common.js b/Timeline/ClientApp/webpack.common.js deleted file mode 100644 index 3779003e..00000000 --- a/Timeline/ClientApp/webpack.common.js +++ /dev/null @@ -1,86 +0,0 @@ -const path = require("path");
-const HtmlWebpackPlugin = require("html-webpack-plugin");
-const postcssPresetEnv = require("postcss-preset-env");
-const Config = require("webpack-chain");
-
-const config = new Config();
-
-config.entry("index").add(path.resolve(__dirname, "src/app/index.tsx"));
-
-config.module
- .rule("ts")
- .test(/\.ts(x?)$/)
- .exclude.add(/node_modules/)
- .end()
- .use("babel")
- .loader("babel-loader")
- .end()
- .use("ts")
- .loader("ts-loader")
- .end();
-
-config.module
- .rule("js")
- .test(/\.js(x?)$/)
- .exclude.add(/node_modules/)
- .end()
- .use("babel")
- .loader("babel-loader")
- .end();
-
-config.module
- .rule("css")
- .test(/\.css$/)
- .use("css")
- .loader("css-loader")
- .end()
- .use("postcss")
- .loader("postcss-loader")
- .end();
-
-config.module
- .rule("sass")
- .test(/\.(scss|sass)$/)
- .use("css")
- .loader("css-loader")
- .end()
- .use("postcss")
- .loader("postcss-loader")
- .end()
- .use("sass")
- .loader("sass-loader")
- .end();
-
-config.module
- .rule("file")
- .test(/\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot)$/i)
- .use("url")
- .loader("url-loader")
- .options({
- limit: 8192,
- });
-
-config.resolve.extensions
- .add("*")
- .add(".js")
- .add(".jsx")
- .add(".ts")
- .add(".tsx")
- .end();
-
-config.resolve.alias.set("@", path.resolve(__dirname, "src/app"));
-
-config.output
- .path(path.resolve(__dirname, "dist/"))
- .filename("[name].[contenthash].js")
- .chunkFilename("[name].[contenthash].js")
- .publicPath("/");
-
-config.plugin("html").use(HtmlWebpackPlugin, [
- {
- template: "src/app/index.ejs",
- title: "Timeline",
- },
-]);
-
-module.exports = config;
diff --git a/Timeline/ClientApp/webpack.config.dev.js b/Timeline/ClientApp/webpack.config.dev.js deleted file mode 100644 index c88e1aaf..00000000 --- a/Timeline/ClientApp/webpack.config.dev.js +++ /dev/null @@ -1,52 +0,0 @@ -const path = require("path");
-const webpack = require("webpack");
-
-const config = require("./webpack.common");
-
-config.mode("development");
-
-config.entry("index").add("react-hot-loader/patch");
-
-config.module
- .rule("ts")
- .use("babel")
- .options({
- plugins: ["react-hot-loader/babel"],
- });
-
-config.module
- .rule("js")
- .use("babel")
- .options({
- plugins: ["react-hot-loader/babel"],
- });
-
-config.module
- .rule("css")
- .use("style")
- .before("css")
- .loader("style-loader")
- .end();
-
-config.module
- .rule("sass")
- .use("style")
- .before("css")
- .loader("style-loader")
- .end();
-
-config.devtool("eval-cheap-module-source-map");
-
-config.resolve.alias.set("react-dom", "@hot-loader/react-dom");
-
-config.devServer
- .contentBase(path.resolve(__dirname, "public/"))
- .host("0.0.0.0")
- .port(3000)
- .historyApiFallback(true)
- .hotOnly(true)
- .allowedHosts.add(".myide.io");
-
-config.plugin("hot").use(webpack.HotModuleReplacementPlugin);
-
-module.exports = config.toConfig();
diff --git a/Timeline/ClientApp/webpack.config.prod.js b/Timeline/ClientApp/webpack.config.prod.js deleted file mode 100644 index 188cb940..00000000 --- a/Timeline/ClientApp/webpack.config.prod.js +++ /dev/null @@ -1,53 +0,0 @@ -const path = require("path");
-const { CleanWebpackPlugin } = require("clean-webpack-plugin");
-const CopyPlugin = require("copy-webpack-plugin");
-const WorkboxPlugin = require("workbox-webpack-plugin");
-const MiniCssExtractPlugin = require("mini-css-extract-plugin");
-
-const config = require("./webpack.common");
-
-config.mode("production");
-
-config
- .entry("index")
- .add(path.resolve(__dirname, "src/app/service-worker.tsx"));
-
-config.module
- .rule("css")
- .use("mini-css-extract")
- .before("css")
- .loader(MiniCssExtractPlugin.loader)
- .end();
-
-config.module
- .rule("sass")
- .use("mini-css-extract")
- .before("css")
- .loader(MiniCssExtractPlugin.loader)
- .end();
-
-config.devtool("source-map");
-
-config.plugin("mini-css-extract").use(MiniCssExtractPlugin);
-
-config.plugin("clean").use(CleanWebpackPlugin);
-
-config.plugin("copy").use(CopyPlugin, [
- {
- patterns: [
- {
- from: path.resolve(__dirname, "public/"),
- to: path.resolve(__dirname, "dist/"),
- },
- ],
- },
-]);
-
-config.plugin("workbox").use(WorkboxPlugin.InjectManifest, [
- {
- swSrc: path.resolve(__dirname, "src/sw/sw.ts"),
- maximumFileSizeToCacheInBytes: 15000000,
- },
-]);
-
-module.exports = config.toConfig();
|