aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-08-24 22:59:45 +0800
committercrupest <crupest@outlook.com>2020-08-24 22:59:45 +0800
commitde1d582bf2ed7062fd400459f30d463d47ef9982 (patch)
tree777e27f954c9fd7beab36aad61bb767a28d65a89 /Timeline/ClientApp/src
parent5a8fb35c2791a921d8833beb37aa2edd5047da4c (diff)
downloadtimeline-de1d582bf2ed7062fd400459f30d463d47ef9982.tar.gz
timeline-de1d582bf2ed7062fd400459f30d463d47ef9982.tar.bz2
timeline-de1d582bf2ed7062fd400459f30d463d47ef9982.zip
...
Diffstat (limited to 'Timeline/ClientApp/src')
-rw-r--r--Timeline/ClientApp/src/app/App.tsx176
-rw-r--r--Timeline/ClientApp/src/app/about/About.tsx344
-rw-r--r--Timeline/ClientApp/src/app/about/about.sass8
-rw-r--r--Timeline/ClientApp/src/app/admin/Admin.tsx156
-rw-r--r--Timeline/ClientApp/src/app/admin/UserAdmin.tsx928
-rw-r--r--Timeline/ClientApp/src/app/common.ts88
-rw-r--r--Timeline/ClientApp/src/app/common/AlertHost.tsx192
-rw-r--r--Timeline/ClientApp/src/app/common/AppBar.tsx214
-rw-r--r--Timeline/ClientApp/src/app/common/BlobImage.tsx58
-rw-r--r--Timeline/ClientApp/src/app/common/FileInput.tsx82
-rw-r--r--Timeline/ClientApp/src/app/common/ImageCropper.tsx612
-rw-r--r--Timeline/ClientApp/src/app/common/LoadingPage.tsx24
-rw-r--r--Timeline/ClientApp/src/app/common/OperationDialog.tsx762
-rw-r--r--Timeline/ClientApp/src/app/common/SearchInput.tsx126
-rw-r--r--Timeline/ClientApp/src/app/common/TimelineLogo.tsx52
-rw-r--r--Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx52
-rw-r--r--Timeline/ClientApp/src/app/common/alert-service.ts122
-rw-r--r--Timeline/ClientApp/src/app/common/alert.sass30
-rw-r--r--Timeline/ClientApp/src/app/common/common.sass66
-rw-r--r--Timeline/ClientApp/src/app/data/DataHub.ts396
-rw-r--r--Timeline/ClientApp/src/app/data/common.ts46
-rw-r--r--Timeline/ClientApp/src/app/data/timeline.ts1394
-rw-r--r--Timeline/ClientApp/src/app/data/user.ts778
-rw-r--r--Timeline/ClientApp/src/app/home/BoardWithUser.tsx204
-rw-r--r--Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx122
-rw-r--r--Timeline/ClientApp/src/app/home/Home.tsx204
-rw-r--r--Timeline/ClientApp/src/app/home/OfflineBoard.tsx124
-rw-r--r--Timeline/ClientApp/src/app/home/TimelineBoard.tsx148
-rw-r--r--Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx108
-rw-r--r--Timeline/ClientApp/src/app/home/home.sass26
-rw-r--r--Timeline/ClientApp/src/app/http/common.ts322
-rw-r--r--Timeline/ClientApp/src/app/http/mock/common.ts156
-rw-r--r--Timeline/ClientApp/src/app/http/mock/install.ts22
-rw-r--r--Timeline/ClientApp/src/app/http/mock/timeline.ts1316
-rw-r--r--Timeline/ClientApp/src/app/http/mock/token.ts106
-rw-r--r--Timeline/ClientApp/src/app/http/mock/user.ts280
-rw-r--r--Timeline/ClientApp/src/app/http/timeline.ts1086
-rw-r--r--Timeline/ClientApp/src/app/http/token.ts144
-rw-r--r--Timeline/ClientApp/src/app/http/user.ts268
-rw-r--r--Timeline/ClientApp/src/app/i18n.ts158
-rw-r--r--Timeline/ClientApp/src/app/index.sass164
-rw-r--r--Timeline/ClientApp/src/app/index.tsx30
-rw-r--r--Timeline/ClientApp/src/app/locales/en/translation.ts404
-rw-r--r--Timeline/ClientApp/src/app/locales/scheme.ts364
-rw-r--r--Timeline/ClientApp/src/app/locales/zh/translation.ts390
-rw-r--r--Timeline/ClientApp/src/app/service-worker.tsx218
-rw-r--r--Timeline/ClientApp/src/app/settings/Settings.tsx444
-rw-r--r--Timeline/ClientApp/src/app/timeline/Timeline.tsx176
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx108
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx220
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineItem.tsx366
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineMember.tsx438
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePage.tsx72
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx382
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx650
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx42
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx468
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx144
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline-ui.sass70
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline.sass262
-rw-r--r--Timeline/ClientApp/src/app/tsconfig.json26
-rw-r--r--Timeline/ClientApp/src/app/typings.d.ts48
-rw-r--r--Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx612
-rw-r--r--Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx56
-rw-r--r--Timeline/ClientApp/src/app/user/Login.tsx296
-rw-r--r--Timeline/ClientApp/src/app/user/User.tsx154
-rw-r--r--Timeline/ClientApp/src/app/user/UserInfoCard.tsx210
-rw-r--r--Timeline/ClientApp/src/app/user/UserPage.tsx38
-rw-r--r--Timeline/ClientApp/src/app/user/api.ts20
-rw-r--r--Timeline/ClientApp/src/app/user/user-page.sass21
-rw-r--r--Timeline/ClientApp/src/app/utilities/rxjs.ts28
-rw-r--r--Timeline/ClientApp/src/app/utilities/type.ts2
-rw-r--r--Timeline/ClientApp/src/app/utilities/url.ts104
-rw-r--r--Timeline/ClientApp/src/sw/sw.ts56
-rw-r--r--Timeline/ClientApp/src/sw/tsconfig.json24
-rw-r--r--Timeline/ClientApp/src/tsconfig.json34
76 files changed, 9320 insertions, 9321 deletions
diff --git a/Timeline/ClientApp/src/app/App.tsx b/Timeline/ClientApp/src/app/App.tsx
index 31ff9a4e..7b382c2f 100644
--- a/Timeline/ClientApp/src/app/App.tsx
+++ b/Timeline/ClientApp/src/app/App.tsx
@@ -1,88 +1,88 @@
-import React from 'react';
-import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
-import { hot } from 'react-hot-loader/root';
-
-import AppBar from './common/AppBar';
-import LoadingPage from './common/LoadingPage';
-import Home from './home/Home';
-import Login from './user/Login';
-import Settings from './settings/Settings';
-import About from './about/About';
-import User from './user/User';
-import TimelinePage from './timeline/TimelinePage';
-import AlertHost from './common/AlertHost';
-
-import { dataStorage } from './data/common';
-import { userService, useRawUser } from './data/user';
-
-const NoMatch: React.FC = () => {
- return (
- <>
- <AppBar />
- <div style={{ height: 56 }} />
- <div>Ah-oh, 404!</div>
- </>
- );
-};
-
-const LazyAdmin = React.lazy(() =>
- import(/* webpackChunkName: "admin" */ './admin/Admin')
-);
-
-const App: React.FC = () => {
- const [loading, setLoading] = React.useState<boolean>(true);
-
- const user = useRawUser();
-
- React.useEffect(() => {
- void userService.checkLoginState();
- void dataStorage.ready().then(() => setLoading(false));
- }, []);
-
- let body;
- if (user === undefined || loading) {
- body = <LoadingPage />;
- } else {
- body = (
- <Router>
- <Switch>
- <Route exact path="/">
- <Home />
- </Route>
- <Route exact path="/login">
- <Login />
- </Route>
- <Route path="/settings">
- <Settings />
- </Route>
- <Route path="/about">
- <About />
- </Route>
- <Route path="/timelines/:name">
- <TimelinePage />
- </Route>
- <Route path="/users/:username">
- <User />
- </Route>
- {user && user.administrator && (
- <Route path="/admin">
- <LazyAdmin user={user} />
- </Route>
- )}
- <Route>
- <NoMatch />
- </Route>
- </Switch>
- </Router>
- );
- }
-
- return (
- <React.Suspense fallback={<LoadingPage />}>
- {body}
- <AlertHost />
- </React.Suspense>
- );
-};
-
-export default hot(App);
+import React from "react";
+import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
+import { hot } from "react-hot-loader/root";
+
+import AppBar from "./common/AppBar";
+import LoadingPage from "./common/LoadingPage";
+import Home from "./home/Home";
+import Login from "./user/Login";
+import Settings from "./settings/Settings";
+import About from "./about/About";
+import User from "./user/User";
+import TimelinePage from "./timeline/TimelinePage";
+import AlertHost from "./common/AlertHost";
+
+import { dataStorage } from "./data/common";
+import { userService, useRawUser } from "./data/user";
+
+const NoMatch: React.FC = () => {
+ return (
+ <>
+ <AppBar />
+ <div style={{ height: 56 }} />
+ <div>Ah-oh, 404!</div>
+ </>
+ );
+};
+
+const LazyAdmin = React.lazy(
+ () => import(/* webpackChunkName: "admin" */ "./admin/Admin")
+);
+
+const App: React.FC = () => {
+ const [loading, setLoading] = React.useState<boolean>(true);
+
+ const user = useRawUser();
+
+ React.useEffect(() => {
+ void userService.checkLoginState();
+ void dataStorage.ready().then(() => setLoading(false));
+ }, []);
+
+ let body;
+ if (user === undefined || loading) {
+ body = <LoadingPage />;
+ } else {
+ body = (
+ <Router>
+ <Switch>
+ <Route exact path="/">
+ <Home />
+ </Route>
+ <Route exact path="/login">
+ <Login />
+ </Route>
+ <Route path="/settings">
+ <Settings />
+ </Route>
+ <Route path="/about">
+ <About />
+ </Route>
+ <Route path="/timelines/:name">
+ <TimelinePage />
+ </Route>
+ <Route path="/users/:username">
+ <User />
+ </Route>
+ {user && user.administrator && (
+ <Route path="/admin">
+ <LazyAdmin user={user} />
+ </Route>
+ )}
+ <Route>
+ <NoMatch />
+ </Route>
+ </Switch>
+ </Router>
+ );
+ }
+
+ return (
+ <React.Suspense fallback={<LoadingPage />}>
+ {body}
+ <AlertHost />
+ </React.Suspense>
+ );
+};
+
+export default hot(App);
diff --git a/Timeline/ClientApp/src/app/about/About.tsx b/Timeline/ClientApp/src/app/about/About.tsx
index 38e16c7c..751fca14 100644
--- a/Timeline/ClientApp/src/app/about/About.tsx
+++ b/Timeline/ClientApp/src/app/about/About.tsx
@@ -1,172 +1,172 @@
-import React from 'react';
-import { useTranslation, Trans } from 'react-i18next';
-
-import authorAvatarUrl from './author-avatar.png';
-import githubLogoUrl from './github.png';
-
-import AppBar from '../common/AppBar';
-
-const frontendCredits: {
- name: string;
- url: string;
-}[] = [
- {
- name: 'reactjs',
- url: 'https://reactjs.org',
- },
- {
- name: 'typescript',
- url: 'https://www.typescriptlang.org',
- },
- {
- name: 'bootstrap',
- url: 'https://getbootstrap.com',
- },
- {
- name: 'reactstrap',
- url: 'https://reactstrap.github.io',
- },
- {
- name: 'babeljs',
- url: 'https://babeljs.io',
- },
- {
- name: 'webpack',
- url: 'https://webpack.js.org',
- },
- {
- name: 'sass',
- url: 'https://sass-lang.com',
- },
- {
- name: 'eslint',
- url: 'https://eslint.org',
- },
- {
- name: 'prettier',
- url: 'https://prettier.io',
- },
- {
- name: 'pepjs',
- url: 'https://github.com/jquery/PEP',
- },
- {
- name: 'react-inlinesvg',
- url: 'https://github.com/gilbarbara/react-inlinesvg',
- },
-];
-
-const backendCredits: {
- name: string;
- url: string;
-}[] = [
- {
- name: 'ASP.NET Core',
- url: 'https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core',
- },
- { name: 'sqlite', url: 'https://sqlite.org' },
- {
- name: 'ImageSharp',
- url: 'https://github.com/SixLabors/ImageSharp',
- },
-];
-
-const About: React.FC = () => {
- const { t } = useTranslation();
-
- return (
- <>
- <AppBar />
- <div className="mt-appbar px-2 mb-4">
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <h4 id="author-info">{t('about.author.title')}</h4>
- <div>
- <div className="d-flex">
- <img
- src={authorAvatarUrl}
- className="align-self-start avatar large rounded-circle"
- />
- <div>
- <p>
- <small>{t('about.author.fullname')}</small>
- <span className="text-primary">杨宇千</span>
- </p>
- <p>
- <small>{t('about.author.nickname')}</small>
- <span className="text-primary">crupest</span>
- </p>
- <p>
- <small>{t('about.author.introduction')}</small>
- {t('about.author.introductionContent')}
- </p>
- </div>
- </div>
- <p>
- <small>{t('about.author.links')}</small>
- <a
- href="https://github.com/crupest"
- target="_blank"
- rel="noopener noreferrer"
- >
- <img
- src={githubLogoUrl}
- className="about-link-icon text-body"
- />
- </a>
- </p>
- </div>
- </div>
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <h4>{t('about.site.title')}</h4>
- <p>
- <Trans i18nKey="about.site.content">
- 0<span className="text-primary">1</span>2<b>3</b>4
- <a href="#author-info">5</a>6
- </Trans>
- </p>
- <p>
- <a
- href="https://github.com/crupest/Timeline"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t('about.site.repo')}
- </a>
- </p>
- </div>
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <h4>{t('about.credits.title')}</h4>
- <p>{t('about.credits.content')}</p>
- <p>{t('about.credits.frontend')}</p>
- <ul>
- {frontendCredits.map((item, index) => {
- return (
- <li key={index}>
- <a href={item.url} target="_blank" rel="noopener noreferrer">
- {item.name}
- </a>
- </li>
- );
- })}
- <li>...</li>
- </ul>
- <p>{t('about.credits.backend')}</p>
- <ul>
- {backendCredits.map((item, index) => {
- return (
- <li key={index}>
- <a href={item.url} target="_blank" rel="noopener noreferrer">
- {item.name}
- </a>
- </li>
- );
- })}
- <li>...</li>
- </ul>
- </div>
- </div>
- </>
- );
-};
-
-export default About;
+import React from "react";
+import { useTranslation, Trans } from "react-i18next";
+
+import authorAvatarUrl from "./author-avatar.png";
+import githubLogoUrl from "./github.png";
+
+import AppBar from "../common/AppBar";
+
+const frontendCredits: {
+ name: string;
+ url: string;
+}[] = [
+ {
+ name: "reactjs",
+ url: "https://reactjs.org",
+ },
+ {
+ name: "typescript",
+ url: "https://www.typescriptlang.org",
+ },
+ {
+ name: "bootstrap",
+ url: "https://getbootstrap.com",
+ },
+ {
+ name: "reactstrap",
+ url: "https://reactstrap.github.io",
+ },
+ {
+ name: "babeljs",
+ url: "https://babeljs.io",
+ },
+ {
+ name: "webpack",
+ url: "https://webpack.js.org",
+ },
+ {
+ name: "sass",
+ url: "https://sass-lang.com",
+ },
+ {
+ name: "eslint",
+ url: "https://eslint.org",
+ },
+ {
+ name: "prettier",
+ url: "https://prettier.io",
+ },
+ {
+ name: "pepjs",
+ url: "https://github.com/jquery/PEP",
+ },
+ {
+ name: "react-inlinesvg",
+ url: "https://github.com/gilbarbara/react-inlinesvg",
+ },
+];
+
+const backendCredits: {
+ name: string;
+ url: string;
+}[] = [
+ {
+ name: "ASP.NET Core",
+ url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core",
+ },
+ { name: "sqlite", url: "https://sqlite.org" },
+ {
+ name: "ImageSharp",
+ url: "https://github.com/SixLabors/ImageSharp",
+ },
+];
+
+const About: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+ <>
+ <AppBar />
+ <div className="mt-appbar px-2 mb-4">
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4 id="author-info">{t("about.author.title")}</h4>
+ <div>
+ <div className="d-flex">
+ <img
+ src={authorAvatarUrl}
+ className="align-self-start avatar large rounded-circle"
+ />
+ <div>
+ <p>
+ <small>{t("about.author.fullname")}</small>
+ <span className="text-primary">杨宇千</span>
+ </p>
+ <p>
+ <small>{t("about.author.nickname")}</small>
+ <span className="text-primary">crupest</span>
+ </p>
+ <p>
+ <small>{t("about.author.introduction")}</small>
+ {t("about.author.introductionContent")}
+ </p>
+ </div>
+ </div>
+ <p>
+ <small>{t("about.author.links")}</small>
+ <a
+ href="https://github.com/crupest"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <img
+ src={githubLogoUrl}
+ className="about-link-icon text-body"
+ />
+ </a>
+ </p>
+ </div>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t("about.site.title")}</h4>
+ <p>
+ <Trans i18nKey="about.site.content">
+ 0<span className="text-primary">1</span>2<b>3</b>4
+ <a href="#author-info">5</a>6
+ </Trans>
+ </p>
+ <p>
+ <a
+ href="https://github.com/crupest/Timeline"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t("about.site.repo")}
+ </a>
+ </p>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t("about.credits.title")}</h4>
+ <p>{t("about.credits.content")}</p>
+ <p>{t("about.credits.frontend")}</p>
+ <ul>
+ {frontendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ <p>{t("about.credits.backend")}</p>
+ <ul>
+ {backendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ </div>
+ </div>
+ </>
+ );
+};
+
+export default About;
diff --git a/Timeline/ClientApp/src/app/about/about.sass b/Timeline/ClientApp/src/app/about/about.sass
index f4d00cae..3b5840cd 100644
--- a/Timeline/ClientApp/src/app/about/about.sass
+++ b/Timeline/ClientApp/src/app/about/about.sass
@@ -1,4 +1,4 @@
-.about-link-icon
- @extend .mx-2
- width: 1.2em
- height: 1.2em
+.about-link-icon
+ @extend .mx-2
+ width: 1.2em
+ height: 1.2em
diff --git a/Timeline/ClientApp/src/app/admin/Admin.tsx b/Timeline/ClientApp/src/app/admin/Admin.tsx
index af07dd2b..2f8b2c05 100644
--- a/Timeline/ClientApp/src/app/admin/Admin.tsx
+++ b/Timeline/ClientApp/src/app/admin/Admin.tsx
@@ -1,78 +1,78 @@
-import React, { Fragment } from 'react';
-import { Nav, NavItem, NavLink } from 'reactstrap';
-import {
- Redirect,
- Route,
- Switch,
- useRouteMatch,
- useHistory
-} from 'react-router';
-import classnames from 'classnames';
-
-import AppBar from '../common/AppBar';
-import UserAdmin from './UserAdmin';
-
-import { UserWithToken } from '../data/user';
-
-interface AdminProps {
- user: UserWithToken;
-}
-
-const Admin: React.FC<AdminProps> = props => {
- const match = useRouteMatch();
- const history = useHistory();
- type TabNames = 'users' | 'more';
-
- const tabName = history.location.pathname.replace(match.path + '/', '');
-
- function toggle(newTab: TabNames): void {
- history.push(`${match.url}/${newTab}`);
- }
-
- const createRoute = (
- name: string,
- body: React.ReactNode
- ): React.ReactNode => {
- return (
- <Route path={`${match.path}/${name}`}>
- <AppBar />
- <div style={{ height: 56 }} className="flex-fix-length" />
- <Nav tabs>
- <NavItem>
- <NavLink
- className={classnames({ active: tabName === 'users' })}
- onClick={() => {
- toggle('users');
- }}
- >
- Users
- </NavLink>
- </NavItem>
- <NavItem>
- <NavLink
- className={classnames({ active: tabName === 'more' })}
- onClick={() => {
- toggle('more');
- }}
- >
- More
- </NavLink>
- </NavItem>
- </Nav>
- {body}
- </Route>
- );
- };
-
- return (
- <Fragment>
- <Switch>
- <Redirect from={match.path} to={`${match.path}/users`} exact />
- {createRoute('users', <UserAdmin user={props.user} />)}
- {createRoute('more', <div>More Page Works</div>)}
- </Switch>
- </Fragment>
- );
-};
-
-export default Admin;
+import React, { Fragment } from "react";
+import { Nav, NavItem, NavLink } from "reactstrap";
+import {
+ Redirect,
+ Route,
+ Switch,
+ useRouteMatch,
+ useHistory,
+} from "react-router";
+import classnames from "classnames";
+
+import AppBar from "../common/AppBar";
+import UserAdmin from "./UserAdmin";
+
+import { UserWithToken } from "../data/user";
+
+interface AdminProps {
+ user: UserWithToken;
+}
+
+const Admin: React.FC<AdminProps> = (props) => {
+ const match = useRouteMatch();
+ const history = useHistory();
+ type TabNames = "users" | "more";
+
+ const tabName = history.location.pathname.replace(match.path + "/", "");
+
+ function toggle(newTab: TabNames): void {
+ history.push(`${match.url}/${newTab}`);
+ }
+
+ const createRoute = (
+ name: string,
+ body: React.ReactNode
+ ): React.ReactNode => {
+ return (
+ <Route path={`${match.path}/${name}`}>
+ <AppBar />
+ <div style={{ height: 56 }} className="flex-fix-length" />
+ <Nav tabs>
+ <NavItem>
+ <NavLink
+ className={classnames({ active: tabName === "users" })}
+ onClick={() => {
+ toggle("users");
+ }}
+ >
+ Users
+ </NavLink>
+ </NavItem>
+ <NavItem>
+ <NavLink
+ className={classnames({ active: tabName === "more" })}
+ onClick={() => {
+ toggle("more");
+ }}
+ >
+ More
+ </NavLink>
+ </NavItem>
+ </Nav>
+ {body}
+ </Route>
+ );
+ };
+
+ return (
+ <Fragment>
+ <Switch>
+ <Redirect from={match.path} to={`${match.path}/users`} exact />
+ {createRoute("users", <UserAdmin user={props.user} />)}
+ {createRoute("more", <div>More Page Works</div>)}
+ </Switch>
+ </Fragment>
+ );
+};
+
+export default Admin;
diff --git a/Timeline/ClientApp/src/app/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx
index a3115073..57eed455 100644
--- a/Timeline/ClientApp/src/app/admin/UserAdmin.tsx
+++ b/Timeline/ClientApp/src/app/admin/UserAdmin.tsx
@@ -1,464 +1,464 @@
-import React, { useState, useEffect } from 'react';
-import {
- ListGroupItem,
- Row,
- Col,
- UncontrolledDropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Spinner,
- Button,
-} from 'reactstrap';
-import axios from 'axios';
-
-import OperationDialog from '../common/OperationDialog';
-
-import { User, UserWithToken } from '../data/user';
-
-const apiBaseUrl = '/api';
-
-async function fetchUserList(_token: string): Promise<User[]> {
- const res = await axios.get<User[]>(`${apiBaseUrl}/users`);
- return res.data;
-}
-
-interface CreateUserInfo {
- username: string;
- password: string;
- administrator: boolean;
-}
-
-async function createUser(user: CreateUserInfo, token: string): Promise<User> {
- const res = await axios.post<User>(
- `${apiBaseUrl}/userop/createuser?token=${token}`,
- user
- );
- return res.data;
-}
-
-function deleteUser(username: string, token: string): Promise<void> {
- return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
-}
-
-function changeUsername(
- oldUsername: string,
- newUsername: string,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
- username: newUsername,
- });
-}
-
-function changePassword(
- username: string,
- newPassword: string,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
- password: newPassword,
- });
-}
-
-function changePermission(
- username: string,
- newPermission: boolean,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
- administrator: newPermission,
- });
-}
-
-const kChangeUsername = 'changeusername';
-const kChangePassword = 'changepassword';
-const kChangePermission = 'changepermission';
-const kDelete = 'delete';
-
-type TChangeUsername = typeof kChangeUsername;
-type TChangePassword = typeof kChangePassword;
-type TChangePermission = typeof kChangePermission;
-type TDelete = typeof kDelete;
-
-type ContextMenuItem =
- | TChangeUsername
- | TChangePassword
- | TChangePermission
- | TDelete;
-
-interface UserCardProps {
- onContextMenu: (item: ContextMenuItem) => void;
- user: User;
-}
-
-const UserItem: React.FC<UserCardProps> = (props) => {
- const user = props.user;
-
- const createClickCallback = (item: ContextMenuItem): (() => void) => {
- return () => {
- props.onContextMenu(item);
- };
- };
-
- return (
- <ListGroupItem className="container">
- <Row className="align-items-center">
- <Col>
- <p className="mb-0 text-primary">{user.username}</p>
- <small
- className={user.administrator ? 'text-danger' : 'text-secondary'}
- >
- {user.administrator ? 'administrator' : 'user'}
- </small>
- </Col>
- <Col className="col-auto">
- <UncontrolledDropdown>
- <DropdownToggle color="warning" className="text-light" caret>
- Manage
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={createClickCallback(kChangeUsername)}>
- Change Username
- </DropdownItem>
- <DropdownItem onClick={createClickCallback(kChangePassword)}>
- Change Password
- </DropdownItem>
- <DropdownItem onClick={createClickCallback(kChangePermission)}>
- Change Permission
- </DropdownItem>
- <DropdownItem
- className="text-danger"
- onClick={createClickCallback(kDelete)}
- >
- Delete
- </DropdownItem>
- </DropdownMenu>
- </UncontrolledDropdown>
- </Col>
- </Row>
- </ListGroupItem>
- );
-};
-
-interface DialogProps {
- open: boolean;
- close: () => void;
-}
-
-interface CreateUserDialogProps extends DialogProps {
- process: (user: CreateUserInfo) => Promise<void>;
-}
-
-const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
- return (
- <OperationDialog
- title="Create"
- titleColor="create"
- inputPrompt="You are creating a new user."
- inputScheme={[
- { type: 'text', label: 'Username' },
- { type: 'text', label: 'Password' },
- { type: 'bool', label: 'Administrator' },
- ]}
- onProcess={([username, password, administrator]) =>
- props.process({
- username: username as string,
- password: password as string,
- administrator: administrator as boolean,
- })
- }
- close={props.close}
- open={props.open}
- />
- );
-};
-
-const UsernameLabel: React.FC = (props) => {
- return <span style={{ color: 'blue' }}>{props.children}</span>;
-};
-
-interface UserDeleteDialogProps extends DialogProps {
- username: string;
- process: () => Promise<void>;
-}
-
-const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Dangerous"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are deleting user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' !'}
- </>
- )}
- onProcess={props.process}
- />
- );
-};
-
-interface UserModifyDialogProps<T> extends DialogProps {
- username: string;
- process: (value: T) => Promise<void>;
-}
-
-const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are change the username of user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' !'}
- </>
- )}
- inputScheme={[{ type: 'text', label: 'New Username' }]}
- onProcess={([newUsername]) => {
- return props.process(newUsername as string);
- }}
- />
- );
-};
-
-const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are change the password of user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' !'}
- </>
- )}
- inputScheme={[{ type: 'text', label: 'New Password' }]}
- onProcess={([newPassword]) => {
- return props.process(newPassword as string);
- }}
- />
- );
-};
-
-interface UserChangePermissionDialogProps extends DialogProps {
- username: string;
- newPermission: boolean;
- process: () => Promise<void>;
-}
-
-const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {'You are change user '}
- <UsernameLabel>{props.username}</UsernameLabel>
- {' to '}
- <span style={{ color: 'orange' }}>
- {props.newPermission ? 'administrator' : 'normal user'}
- </span>
- {' !'}
- </>
- )}
- onProcess={props.process}
- />
- );
-};
-
-interface UserAdminProps {
- user: UserWithToken;
-}
-
-const UserAdmin: React.FC<UserAdminProps> = (props) => {
- type DialogInfo =
- | null
- | {
- type: 'create';
- }
- | { type: TDelete; username: string }
- | {
- type: TChangeUsername;
- username: string;
- }
- | {
- type: TChangePassword;
- username: string;
- }
- | {
- type: TChangePermission;
- username: string;
- newPermission: boolean;
- };
-
- const [users, setUsers] = useState<User[] | null>(null);
- const [dialog, setDialog] = useState<DialogInfo>(null);
-
- const token = props.user.token;
-
- useEffect(() => {
- let subscribe = true;
- void fetchUserList(props.user.token).then((us) => {
- if (subscribe) {
- setUsers(us);
- }
- });
- return () => {
- subscribe = false;
- };
- }, [props.user]);
-
- let dialogNode: React.ReactNode;
- if (dialog)
- switch (dialog.type) {
- case 'create':
- dialogNode = (
- <CreateUserDialog
- open
- close={() => setDialog(null)}
- process={async (user) => {
- const u = await createUser(user, token);
- setUsers((oldUsers) => [...(oldUsers ?? []), u]);
- }}
- />
- );
- break;
- case 'delete':
- dialogNode = (
- <UserDeleteDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async () => {
- await deleteUser(dialog.username, token);
- setUsers((oldUsers) =>
- (oldUsers ?? []).filter((u) => u.username !== dialog.username)
- );
- }}
- />
- );
- break;
- case kChangeUsername:
- dialogNode = (
- <UserChangeUsernameDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async (newUsername) => {
- await changeUsername(dialog.username, newUsername, token);
- setUsers((oldUsers) => {
- const users = (oldUsers ?? []).slice();
- const findedUser = users.find(
- (u) => u.username === dialog.username
- );
- if (findedUser) findedUser.username = newUsername;
- return users;
- });
- }}
- />
- );
- break;
- case kChangePassword:
- dialogNode = (
- <UserChangePasswordDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async (newPassword) => {
- await changePassword(dialog.username, newPassword, token);
- }}
- />
- );
- break;
- case kChangePermission: {
- const newPermission = dialog.newPermission;
- dialogNode = (
- <UserChangePermissionDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- newPermission={newPermission}
- process={async () => {
- await changePermission(dialog.username, newPermission, token);
- setUsers((oldUsers) => {
- const users = (oldUsers ?? []).slice();
- const findedUser = users.find(
- (u) => u.username === dialog.username
- );
- if (findedUser) findedUser.administrator = newPermission;
- return users;
- });
- }}
- />
- );
- break;
- }
- }
-
- if (users) {
- const userComponents = users.map((user) => {
- return (
- <UserItem
- key={user.username}
- user={user}
- onContextMenu={(item) => {
- setDialog(
- item === kChangePermission
- ? {
- type: kChangePermission,
- username: user.username,
- newPermission: !user.administrator,
- }
- : {
- type: item,
- username: user.username,
- }
- );
- }}
- />
- );
- });
-
- return (
- <>
- <Button
- color="success"
- onClick={() =>
- setDialog({
- type: 'create',
- })
- }
- className="align-self-end"
- >
- Create User
- </Button>
- {userComponents}
- {dialogNode}
- </>
- );
- } else {
- return <Spinner />;
- }
-};
-
-export default UserAdmin;
+import React, { useState, useEffect } from "react";
+import {
+ ListGroupItem,
+ Row,
+ Col,
+ UncontrolledDropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Spinner,
+ Button,
+} from "reactstrap";
+import axios from "axios";
+
+import OperationDialog from "../common/OperationDialog";
+
+import { User, UserWithToken } from "../data/user";
+
+const apiBaseUrl = "/api";
+
+async function fetchUserList(_token: string): Promise<User[]> {
+ const res = await axios.get<User[]>(`${apiBaseUrl}/users`);
+ return res.data;
+}
+
+interface CreateUserInfo {
+ username: string;
+ password: string;
+ administrator: boolean;
+}
+
+async function createUser(user: CreateUserInfo, token: string): Promise<User> {
+ const res = await axios.post<User>(
+ `${apiBaseUrl}/userop/createuser?token=${token}`,
+ user
+ );
+ return res.data;
+}
+
+function deleteUser(username: string, token: string): Promise<void> {
+ return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
+}
+
+function changeUsername(
+ oldUsername: string,
+ newUsername: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
+ username: newUsername,
+ });
+}
+
+function changePassword(
+ username: string,
+ newPassword: string,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ password: newPassword,
+ });
+}
+
+function changePermission(
+ username: string,
+ newPermission: boolean,
+ token: string
+): Promise<void> {
+ return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ administrator: newPermission,
+ });
+}
+
+const kChangeUsername = "changeusername";
+const kChangePassword = "changepassword";
+const kChangePermission = "changepermission";
+const kDelete = "delete";
+
+type TChangeUsername = typeof kChangeUsername;
+type TChangePassword = typeof kChangePassword;
+type TChangePermission = typeof kChangePermission;
+type TDelete = typeof kDelete;
+
+type ContextMenuItem =
+ | TChangeUsername
+ | TChangePassword
+ | TChangePermission
+ | TDelete;
+
+interface UserCardProps {
+ onContextMenu: (item: ContextMenuItem) => void;
+ user: User;
+}
+
+const UserItem: React.FC<UserCardProps> = (props) => {
+ const user = props.user;
+
+ const createClickCallback = (item: ContextMenuItem): (() => void) => {
+ return () => {
+ props.onContextMenu(item);
+ };
+ };
+
+ return (
+ <ListGroupItem className="container">
+ <Row className="align-items-center">
+ <Col>
+ <p className="mb-0 text-primary">{user.username}</p>
+ <small
+ className={user.administrator ? "text-danger" : "text-secondary"}
+ >
+ {user.administrator ? "administrator" : "user"}
+ </small>
+ </Col>
+ <Col className="col-auto">
+ <UncontrolledDropdown>
+ <DropdownToggle color="warning" className="text-light" caret>
+ Manage
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={createClickCallback(kChangeUsername)}>
+ Change Username
+ </DropdownItem>
+ <DropdownItem onClick={createClickCallback(kChangePassword)}>
+ Change Password
+ </DropdownItem>
+ <DropdownItem onClick={createClickCallback(kChangePermission)}>
+ Change Permission
+ </DropdownItem>
+ <DropdownItem
+ className="text-danger"
+ onClick={createClickCallback(kDelete)}
+ >
+ Delete
+ </DropdownItem>
+ </DropdownMenu>
+ </UncontrolledDropdown>
+ </Col>
+ </Row>
+ </ListGroupItem>
+ );
+};
+
+interface DialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+interface CreateUserDialogProps extends DialogProps {
+ process: (user: CreateUserInfo) => Promise<void>;
+}
+
+const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ title="Create"
+ titleColor="create"
+ inputPrompt="You are creating a new user."
+ inputScheme={[
+ { type: "text", label: "Username" },
+ { type: "text", label: "Password" },
+ { type: "bool", label: "Administrator" },
+ ]}
+ onProcess={([username, password, administrator]) =>
+ props.process({
+ username: username as string,
+ password: password as string,
+ administrator: administrator as boolean,
+ })
+ }
+ close={props.close}
+ open={props.open}
+ />
+ );
+};
+
+const UsernameLabel: React.FC = (props) => {
+ return <span style={{ color: "blue" }}>{props.children}</span>;
+};
+
+interface UserDeleteDialogProps extends DialogProps {
+ username: string;
+ process: () => Promise<void>;
+}
+
+const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Dangerous"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are deleting user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" !"}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserModifyDialogProps<T> extends DialogProps {
+ username: string;
+ process: (value: T) => Promise<void>;
+}
+
+const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are change the username of user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" !"}
+ </>
+ )}
+ inputScheme={[{ type: "text", label: "New Username" }]}
+ onProcess={([newUsername]) => {
+ return props.process(newUsername as string);
+ }}
+ />
+ );
+};
+
+const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are change the password of user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" !"}
+ </>
+ )}
+ inputScheme={[{ type: "text", label: "New Password" }]}
+ onProcess={([newPassword]) => {
+ return props.process(newPassword as string);
+ }}
+ />
+ );
+};
+
+interface UserChangePermissionDialogProps extends DialogProps {
+ username: string;
+ newPermission: boolean;
+ process: () => Promise<void>;
+}
+
+const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
+ props
+) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="Caution"
+ titleColor="dangerous"
+ inputPrompt={() => (
+ <>
+ {"You are change user "}
+ <UsernameLabel>{props.username}</UsernameLabel>
+ {" to "}
+ <span style={{ color: "orange" }}>
+ {props.newPermission ? "administrator" : "normal user"}
+ </span>
+ {" !"}
+ </>
+ )}
+ onProcess={props.process}
+ />
+ );
+};
+
+interface UserAdminProps {
+ user: UserWithToken;
+}
+
+const UserAdmin: React.FC<UserAdminProps> = (props) => {
+ type DialogInfo =
+ | null
+ | {
+ type: "create";
+ }
+ | { type: TDelete; username: string }
+ | {
+ type: TChangeUsername;
+ username: string;
+ }
+ | {
+ type: TChangePassword;
+ username: string;
+ }
+ | {
+ type: TChangePermission;
+ username: string;
+ newPermission: boolean;
+ };
+
+ const [users, setUsers] = useState<User[] | null>(null);
+ const [dialog, setDialog] = useState<DialogInfo>(null);
+
+ const token = props.user.token;
+
+ useEffect(() => {
+ let subscribe = true;
+ void fetchUserList(props.user.token).then((us) => {
+ if (subscribe) {
+ setUsers(us);
+ }
+ });
+ return () => {
+ subscribe = false;
+ };
+ }, [props.user]);
+
+ let dialogNode: React.ReactNode;
+ if (dialog)
+ switch (dialog.type) {
+ case "create":
+ dialogNode = (
+ <CreateUserDialog
+ open
+ close={() => setDialog(null)}
+ process={async (user) => {
+ const u = await createUser(user, token);
+ setUsers((oldUsers) => [...(oldUsers ?? []), u]);
+ }}
+ />
+ );
+ break;
+ case "delete":
+ dialogNode = (
+ <UserDeleteDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async () => {
+ await deleteUser(dialog.username, token);
+ setUsers((oldUsers) =>
+ (oldUsers ?? []).filter((u) => u.username !== dialog.username)
+ );
+ }}
+ />
+ );
+ break;
+ case kChangeUsername:
+ dialogNode = (
+ <UserChangeUsernameDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newUsername) => {
+ await changeUsername(dialog.username, newUsername, token);
+ setUsers((oldUsers) => {
+ const users = (oldUsers ?? []).slice();
+ const findedUser = users.find(
+ (u) => u.username === dialog.username
+ );
+ if (findedUser) findedUser.username = newUsername;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ case kChangePassword:
+ dialogNode = (
+ <UserChangePasswordDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ process={async (newPassword) => {
+ await changePassword(dialog.username, newPassword, token);
+ }}
+ />
+ );
+ break;
+ case kChangePermission: {
+ const newPermission = dialog.newPermission;
+ dialogNode = (
+ <UserChangePermissionDialog
+ open
+ close={() => setDialog(null)}
+ username={dialog.username}
+ newPermission={newPermission}
+ process={async () => {
+ await changePermission(dialog.username, newPermission, token);
+ setUsers((oldUsers) => {
+ const users = (oldUsers ?? []).slice();
+ const findedUser = users.find(
+ (u) => u.username === dialog.username
+ );
+ if (findedUser) findedUser.administrator = newPermission;
+ return users;
+ });
+ }}
+ />
+ );
+ break;
+ }
+ }
+
+ if (users) {
+ const userComponents = users.map((user) => {
+ return (
+ <UserItem
+ key={user.username}
+ user={user}
+ onContextMenu={(item) => {
+ setDialog(
+ item === kChangePermission
+ ? {
+ type: kChangePermission,
+ username: user.username,
+ newPermission: !user.administrator,
+ }
+ : {
+ type: item,
+ username: user.username,
+ }
+ );
+ }}
+ />
+ );
+ });
+
+ return (
+ <>
+ <Button
+ color="success"
+ onClick={() =>
+ setDialog({
+ type: "create",
+ })
+ }
+ className="align-self-end"
+ >
+ Create User
+ </Button>
+ {userComponents}
+ {dialogNode}
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+};
+
+export default UserAdmin;
diff --git a/Timeline/ClientApp/src/app/common.ts b/Timeline/ClientApp/src/app/common.ts
index 297b4a77..0a2d345f 100644
--- a/Timeline/ClientApp/src/app/common.ts
+++ b/Timeline/ClientApp/src/app/common.ts
@@ -1,44 +1,44 @@
-import React from 'react';
-import { Observable, Subject } from 'rxjs';
-
-// This error is thrown when ui goes wrong with bad logic.
-// Such as a variable should not be null, but it does.
-// This error should never occur. If it does, it indicates there is some logic bug in codes.
-export class UiLogicError extends Error {}
-
-export function useEventEmiiter(): [() => Observable<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];
- }, []);
-}
+import React from "react";
+import { Observable, Subject } from "rxjs";
+
+// This error is thrown when ui goes wrong with bad logic.
+// Such as a variable should not be null, but it does.
+// This error should never occur. If it does, it indicates there is some logic bug in codes.
+export class UiLogicError extends Error {}
+
+export function useEventEmiiter(): [() => Observable<null>, () => void] {
+ const ref = React.useRef<Subject<null> | null>(null);
+
+ return React.useMemo(() => {
+ const getter = (): Subject<null> => {
+ if (ref.current == null) {
+ ref.current = new Subject<null>();
+ }
+ return ref.current;
+ };
+ const trigger = (): void => {
+ getter().next(null);
+ };
+ return [getter, trigger];
+ }, []);
+}
+
+export function useValueEventEmiiter<T>(): [
+ () => Observable<T>,
+ (value: T) => void
+] {
+ const ref = React.useRef<Subject<T> | null>(null);
+
+ return React.useMemo(() => {
+ const getter = (): Subject<T> => {
+ if (ref.current == null) {
+ ref.current = new Subject<T>();
+ }
+ return ref.current;
+ };
+ const trigger = (value: T): void => {
+ getter().next(value);
+ };
+ return [getter, trigger];
+ }, []);
+}
diff --git a/Timeline/ClientApp/src/app/common/AlertHost.tsx b/Timeline/ClientApp/src/app/common/AlertHost.tsx
index 89831073..e22354fa 100644
--- a/Timeline/ClientApp/src/app/common/AlertHost.tsx
+++ b/Timeline/ClientApp/src/app/common/AlertHost.tsx
@@ -1,96 +1,96 @@
-import React, { useCallback } from 'react';
-import { Alert } from 'reactstrap';
-import without from 'lodash/without';
-import concat from 'lodash/concat';
-
-import {
- alertService,
- AlertInfoEx,
- kAlertHostId,
- AlertInfo,
-} from './alert-service';
-import { useTranslation } from 'react-i18next';
-
-interface AutoCloseAlertProps {
- alert: AlertInfo;
- close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
- const { alert } = props;
- const { dismissTime } = alert;
-
- const { t } = useTranslation();
-
- React.useEffect(() => {
- const tag =
- dismissTime === 'never'
- ? null
- : typeof dismissTime === 'number'
- ? window.setTimeout(props.close, dismissTime)
- : window.setTimeout(props.close, 5000);
- return () => {
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
- }, [dismissTime, props.close]);
-
- return (
- <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}>
- {(() => {
- const { message } = alert;
- if (typeof message === 'function') {
- const Message = message;
- return <Message />;
- } else if (typeof message === 'object' && message.type === 'i18n') {
- return t(message.key);
- } else return alert.message;
- })()}
- </Alert>
- );
-};
-
-// oh what a bad name!
-interface AlertInfoExEx extends AlertInfoEx {
- close: () => void;
-}
-
-export const AlertHost: React.FC = () => {
- const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
-
- // react guarantee that state setters are stable, so we don't need to add it to dependency list
-
- const consume = useCallback((alert: AlertInfoEx): void => {
- const alertEx: AlertInfoExEx = {
- ...alert,
- close: () => {
- setAlerts((oldAlerts) => {
- return without(oldAlerts, alertEx);
- });
- },
- };
- setAlerts((oldAlerts) => {
- return concat(oldAlerts, alertEx);
- });
- }, []);
-
- React.useEffect(() => {
- alertService.registerConsumer(consume);
- return () => {
- alertService.unregisterConsumer(consume);
- };
- }, [consume]);
-
- return (
- <div id={kAlertHostId} className="alert-container">
- {alerts.map((alert) => {
- return (
- <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
- );
- })}
- </div>
- );
-};
-
-export default AlertHost;
+import React, { useCallback } from "react";
+import { Alert } from "reactstrap";
+import without from "lodash/without";
+import concat from "lodash/concat";
+
+import {
+ alertService,
+ AlertInfoEx,
+ kAlertHostId,
+ AlertInfo,
+} from "./alert-service";
+import { useTranslation } from "react-i18next";
+
+interface AutoCloseAlertProps {
+ alert: AlertInfo;
+ close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+ const { alert } = props;
+ const { dismissTime } = alert;
+
+ const { t } = useTranslation();
+
+ React.useEffect(() => {
+ const tag =
+ dismissTime === "never"
+ ? null
+ : typeof dismissTime === "number"
+ ? window.setTimeout(props.close, dismissTime)
+ : window.setTimeout(props.close, 5000);
+ return () => {
+ if (tag != null) {
+ window.clearTimeout(tag);
+ }
+ };
+ }, [dismissTime, props.close]);
+
+ return (
+ <Alert className="m-3" color={alert.type ?? "primary"} toggle={props.close}>
+ {(() => {
+ const { message } = alert;
+ if (typeof message === "function") {
+ const Message = message;
+ return <Message />;
+ } else if (typeof message === "object" && message.type === "i18n") {
+ return t(message.key);
+ } else return alert.message;
+ })()}
+ </Alert>
+ );
+};
+
+// oh what a bad name!
+interface AlertInfoExEx extends AlertInfoEx {
+ close: () => void;
+}
+
+export const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
+
+ // react guarantee that state setters are stable, so we don't need to add it to dependency list
+
+ const consume = useCallback((alert: AlertInfoEx): void => {
+ const alertEx: AlertInfoExEx = {
+ ...alert,
+ close: () => {
+ setAlerts((oldAlerts) => {
+ return without(oldAlerts, alertEx);
+ });
+ },
+ };
+ setAlerts((oldAlerts) => {
+ return concat(oldAlerts, alertEx);
+ });
+ }, []);
+
+ React.useEffect(() => {
+ alertService.registerConsumer(consume);
+ return () => {
+ alertService.unregisterConsumer(consume);
+ };
+ }, [consume]);
+
+ return (
+ <div id={kAlertHostId} className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
+ );
+ })}
+ </div>
+ );
+};
+
+export default AlertHost;
diff --git a/Timeline/ClientApp/src/app/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx
index 8349aef7..59239696 100644
--- a/Timeline/ClientApp/src/app/common/AppBar.tsx
+++ b/Timeline/ClientApp/src/app/common/AppBar.tsx
@@ -1,107 +1,107 @@
-import React from 'react';
-import { useHistory, matchPath } from 'react-router';
-import { Link, NavLink } from 'react-router-dom';
-import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap';
-import { useMediaQuery } from 'react-responsive';
-import { useTranslation } from 'react-i18next';
-
-import { useUser, useAvatar } from '../data/user';
-
-import TimelineLogo from './TimelineLogo';
-import BlobImage from './BlobImage';
-
-const AppBar: React.FC = (_) => {
- const history = useHistory();
- const user = useUser();
- const avatar = useAvatar(user?.username);
-
- const { t } = useTranslation();
-
- const isUpMd = useMediaQuery({
- minWidth: getComputedStyle(document.documentElement).getPropertyValue(
- '--breakpoint-md'
- ),
- });
-
- const [isMenuOpen, setIsMenuOpen] = React.useState(false);
-
- const toggleMenu = React.useCallback((): void => {
- setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen);
- }, []);
-
- const isAdministrator = user && user.administrator;
-
- const rightArea = (
- <div className="ml-auto mr-2">
- {user != null ? (
- <NavLink to={`/users/${user.username}`}>
- <BlobImage
- className="avatar small rounded-circle bg-white"
- blob={avatar}
- />
- </NavLink>
- ) : (
- <NavLink className="text-light" to="/login">
- {t('nav.login')}
- </NavLink>
- )}
- </div>
- );
-
- return (
- <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
- <Link to="/" className="navbar-brand d-flex align-items-center">
- <TimelineLogo style={{ height: '1em' }} />
- Timeline
- </Link>
-
- {isUpMd ? null : rightArea}
-
- <NavbarToggler onClick={toggleMenu} />
- <Collapse isOpen={isMenuOpen} navbar>
- <Nav className="mr-auto" navbar>
- <NavItem
- className={
- matchPath(history.location.pathname, '/settings')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/settings">
- {t('nav.settings')}
- </NavLink>
- </NavItem>
-
- <NavItem
- className={
- matchPath(history.location.pathname, '/about')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/about">
- {t('nav.about')}
- </NavLink>
- </NavItem>
-
- {isAdministrator && (
- <NavItem
- className={
- matchPath(history.location.pathname, '/admin')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/admin">
- Administration
- </NavLink>
- </NavItem>
- )}
- </Nav>
- {isUpMd ? rightArea : null}
- </Collapse>
- </Navbar>
- );
-};
-
-export default AppBar;
+import React from "react";
+import { useHistory, matchPath } from "react-router";
+import { Link, NavLink } from "react-router-dom";
+import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from "reactstrap";
+import { useMediaQuery } from "react-responsive";
+import { useTranslation } from "react-i18next";
+
+import { useUser, useAvatar } from "../data/user";
+
+import TimelineLogo from "./TimelineLogo";
+import BlobImage from "./BlobImage";
+
+const AppBar: React.FC = (_) => {
+ const history = useHistory();
+ const user = useUser();
+ const avatar = useAvatar(user?.username);
+
+ const { t } = useTranslation();
+
+ const isUpMd = useMediaQuery({
+ minWidth: getComputedStyle(document.documentElement).getPropertyValue(
+ "--breakpoint-md"
+ ),
+ });
+
+ const [isMenuOpen, setIsMenuOpen] = React.useState(false);
+
+ const toggleMenu = React.useCallback((): void => {
+ setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen);
+ }, []);
+
+ const isAdministrator = user && user.administrator;
+
+ const rightArea = (
+ <div className="ml-auto mr-2">
+ {user != null ? (
+ <NavLink to={`/users/${user.username}`}>
+ <BlobImage
+ className="avatar small rounded-circle bg-white"
+ blob={avatar}
+ />
+ </NavLink>
+ ) : (
+ <NavLink className="text-light" to="/login">
+ {t("nav.login")}
+ </NavLink>
+ )}
+ </div>
+ );
+
+ return (
+ <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
+ <Link to="/" className="navbar-brand d-flex align-items-center">
+ <TimelineLogo style={{ height: "1em" }} />
+ Timeline
+ </Link>
+
+ {isUpMd ? null : rightArea}
+
+ <NavbarToggler onClick={toggleMenu} />
+ <Collapse isOpen={isMenuOpen} navbar>
+ <Nav className="mr-auto" navbar>
+ <NavItem
+ className={
+ matchPath(history.location.pathname, "/settings")
+ ? "active"
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/settings">
+ {t("nav.settings")}
+ </NavLink>
+ </NavItem>
+
+ <NavItem
+ className={
+ matchPath(history.location.pathname, "/about")
+ ? "active"
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/about">
+ {t("nav.about")}
+ </NavLink>
+ </NavItem>
+
+ {isAdministrator && (
+ <NavItem
+ className={
+ matchPath(history.location.pathname, "/admin")
+ ? "active"
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/admin">
+ Administration
+ </NavLink>
+ </NavItem>
+ )}
+ </Nav>
+ {isUpMd ? rightArea : null}
+ </Collapse>
+ </Navbar>
+ );
+};
+
+export default AppBar;
diff --git a/Timeline/ClientApp/src/app/common/BlobImage.tsx b/Timeline/ClientApp/src/app/common/BlobImage.tsx
index 7e5e2447..8602f550 100644
--- a/Timeline/ClientApp/src/app/common/BlobImage.tsx
+++ b/Timeline/ClientApp/src/app/common/BlobImage.tsx
@@ -1,29 +1,29 @@
-import React from 'react';
-
-import { ExcludeKey } from '../utilities/type';
-
-const BlobImage: React.FC<
- ExcludeKey<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;
+import React from "react";
+
+import { ExcludeKey } from "../utilities/type";
+
+const BlobImage: React.FC<
+ ExcludeKey<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/common/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx
index 94f4aa43..3d1bc2b3 100644
--- a/Timeline/ClientApp/src/app/common/FileInput.tsx
+++ b/Timeline/ClientApp/src/app/common/FileInput.tsx
@@ -1,41 +1,41 @@
-import React from 'react';
-import clsx from 'clsx';
-
-import { ExcludeKey } from '../utilities/type';
-
-export interface FileInputProps
- extends ExcludeKey<
- React.InputHTMLAttributes<HTMLInputElement>,
- 'type' | 'id'
- > {
- inputId?: string;
- labelText: string;
- color?: string;
- className?: string;
-}
-
-const FileInput: React.FC<FileInputProps> = props => {
- const { inputId, labelText, color, className, ...otherProps } = props;
-
- const realInputId = React.useMemo<string>(() => {
- if (inputId != null) return inputId;
- return (
- 'file-input-' +
- (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
- );
- }, [inputId]);
-
- return (
- <>
- <input className="d-none" type="file" id={realInputId} {...otherProps} />
- <label
- htmlFor={realInputId}
- className={clsx('btn', 'btn-' + (color ?? 'primary'), className)}
- >
- {labelText}
- </label>
- </>
- );
-};
-
-export default FileInput;
+import React from "react";
+import clsx from "clsx";
+
+import { ExcludeKey } from "../utilities/type";
+
+export interface FileInputProps
+ extends ExcludeKey<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "id"
+ > {
+ inputId?: string;
+ labelText: string;
+ color?: string;
+ className?: string;
+}
+
+const FileInput: React.FC<FileInputProps> = (props) => {
+ const { inputId, labelText, color, className, ...otherProps } = props;
+
+ const realInputId = React.useMemo<string>(() => {
+ if (inputId != null) return inputId;
+ return (
+ "file-input-" +
+ (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
+ );
+ }, [inputId]);
+
+ return (
+ <>
+ <input className="d-none" type="file" id={realInputId} {...otherProps} />
+ <label
+ htmlFor={realInputId}
+ className={clsx("btn", "btn-" + (color ?? "primary"), className)}
+ >
+ {labelText}
+ </label>
+ </>
+ );
+};
+
+export default FileInput;
diff --git a/Timeline/ClientApp/src/app/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/common/ImageCropper.tsx
index fb9bb4f9..cd510969 100644
--- a/Timeline/ClientApp/src/app/common/ImageCropper.tsx
+++ b/Timeline/ClientApp/src/app/common/ImageCropper.tsx
@@ -1,306 +1,306 @@
-import * as React from 'react';
-import clsx from 'clsx';
-
-import { UiLogicError } from '../common';
-
-export interface Clip {
- left: number;
- top: number;
- width: number;
-}
-
-interface NormailizedClip extends Clip {
- height: number;
-}
-
-interface ImageInfo {
- width: number;
- height: number;
- landscape: boolean;
- ratio: number;
- maxClipWidth: number;
- maxClipHeight: number;
-}
-
-interface ImageCropperSavedState {
- clip: NormailizedClip;
- x: number;
- y: number;
- pointerId: number;
-}
-
-export interface ImageCropperProps {
- clip: Clip | null;
- imageUrl: string;
- onChange: (clip: Clip) => void;
- imageElementCallback?: (element: HTMLImageElement | null) => void;
- className?: string;
-}
-
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
- const { clip, imageUrl, onChange, imageElementCallback, className } = props;
-
- const [oldState, setOldState] = React.useState<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);
- });
-}
+import * as React from "react";
+import clsx from "clsx";
+
+import { UiLogicError } from "../common";
+
+export interface Clip {
+ left: number;
+ top: number;
+ width: number;
+}
+
+interface NormailizedClip extends Clip {
+ height: number;
+}
+
+interface ImageInfo {
+ width: number;
+ height: number;
+ landscape: boolean;
+ ratio: number;
+ maxClipWidth: number;
+ maxClipHeight: number;
+}
+
+interface ImageCropperSavedState {
+ clip: NormailizedClip;
+ x: number;
+ y: number;
+ pointerId: number;
+}
+
+export interface ImageCropperProps {
+ clip: Clip | null;
+ imageUrl: string;
+ onChange: (clip: Clip) => void;
+ imageElementCallback?: (element: HTMLImageElement | null) => void;
+ className?: string;
+}
+
+const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
+ const { clip, imageUrl, onChange, imageElementCallback, className } = props;
+
+ const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
+ null
+ );
+ const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
+
+ const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
+ if (c == null) {
+ return { left: 0, top: 0, width: 0, height: 0 };
+ }
+
+ return {
+ left: c.left || 0,
+ top: c.top || 0,
+ width: c.width || 0,
+ height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
+ };
+ };
+
+ const c = normalizeClip(clip);
+
+ const imgElementRef = React.useRef<HTMLImageElement | null>(null);
+
+ const onImageRef = React.useCallback(
+ (e: HTMLImageElement | null) => {
+ imgElementRef.current = e;
+ if (imageElementCallback != null && e == null) {
+ imageElementCallback(null);
+ }
+ },
+ [imageElementCallback]
+ );
+
+ const onImageLoad = React.useCallback(
+ (e: React.SyntheticEvent<HTMLImageElement>) => {
+ const img = e.currentTarget;
+ const landscape = img.naturalWidth >= img.naturalHeight;
+
+ const info = {
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ landscape,
+ ratio: img.naturalHeight / img.naturalWidth,
+ maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
+ maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
+ };
+ setImageInfo(info);
+ onChange({ left: 0, top: 0, width: info.maxClipWidth });
+ if (imageElementCallback != null) {
+ imageElementCallback(img);
+ }
+ },
+ [onChange, imageElementCallback]
+ );
+
+ const onPointerDown = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState != null) return;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ setOldState({
+ x: e.clientX,
+ y: e.clientY,
+ clip: c,
+ pointerId: e.pointerId,
+ });
+ },
+ [oldState, c]
+ );
+
+ const onPointerUp = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null || oldState.pointerId !== e.pointerId) return;
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ setOldState(null);
+ },
+ [oldState]
+ );
+
+ const onPointerMove = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null) return;
+
+ const oldClip = oldState.clip;
+
+ const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+ const { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.y / imgElement.height,
+ };
+
+ const newRatio = {
+ x: oldClip.left + moveRatio.x,
+ y: oldClip.top + moveRatio.y,
+ };
+ if (newRatio.x < 0) {
+ newRatio.x = 0;
+ } else if (newRatio.x > 1 - oldClip.width) {
+ newRatio.x = 1 - oldClip.width;
+ }
+ if (newRatio.y < 0) {
+ newRatio.y = 0;
+ } else if (newRatio.y > 1 - oldClip.height) {
+ newRatio.y = 1 - oldClip.height;
+ }
+
+ onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
+ },
+ [oldState, onChange]
+ );
+
+ const onHandlerPointerMove = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null) return;
+
+ const oldClip = oldState.clip;
+
+ const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+ const ratio = imageInfo == null ? 1 : imageInfo.ratio;
+
+ const { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.x / imgElement.width / ratio,
+ };
+
+ const newRatio = {
+ x: oldClip.width + moveRatio.x,
+ y: oldClip.height + moveRatio.y,
+ };
+
+ const maxRatio = {
+ x: Math.min(1 - oldClip.left, newRatio.x),
+ y: Math.min(1 - oldClip.top, newRatio.y),
+ };
+
+ const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
+
+ let newWidth;
+ if (newRatio.x < 0) {
+ newWidth = 0;
+ } else if (newRatio.x > maxWidthRatio) {
+ newWidth = maxWidthRatio;
+ } else {
+ newWidth = newRatio.x;
+ }
+
+ onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
+ },
+ [imageInfo, oldState, onChange]
+ );
+
+ const toPercentage = (n: number): string => `${n}%`;
+
+ // fuck!!! I just can't find a better way to implement this in pure css
+ const containerStyle: React.CSSProperties = (() => {
+ if (imageInfo == null) {
+ return { width: "100%", paddingTop: "100%", height: 0 };
+ } else {
+ if (imageInfo.ratio > 1) {
+ return {
+ width: toPercentage(100 / imageInfo.ratio),
+ paddingTop: "100%",
+ height: 0,
+ };
+ } else {
+ return {
+ width: "100%",
+ paddingTop: toPercentage(100 * imageInfo.ratio),
+ height: 0,
+ };
+ }
+ }
+ })();
+
+ return (
+ <div
+ className={clsx("image-cropper-container", className)}
+ style={containerStyle}
+ >
+ <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
+ <div className="image-cropper-mask-container">
+ <div
+ className="image-cropper-mask"
+ touch-action="none"
+ style={{
+ left: toPercentage(c.left * 100),
+ top: toPercentage(c.top * 100),
+ width: toPercentage(c.width * 100),
+ height: toPercentage(c.height * 100),
+ }}
+ onPointerMove={onPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="image-cropper-handler"
+ touch-action="none"
+ style={{
+ left: `calc(${(c.left + c.width) * 100}% - 15px)`,
+ top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+ }}
+ onPointerMove={onHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+};
+
+export default ImageCropper;
+
+export function applyClipToImage(
+ image: HTMLImageElement,
+ clip: Clip,
+ mimeType: string
+): Promise<Blob> {
+ return new Promise((resolve, reject) => {
+ const naturalSize = {
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ };
+ const clipArea = {
+ x: naturalSize.width * clip.left,
+ y: naturalSize.height * clip.top,
+ length: naturalSize.width * clip.width,
+ };
+
+ const canvas = document.createElement("canvas");
+ canvas.width = clipArea.length;
+ canvas.height = clipArea.length;
+ const context = canvas.getContext("2d");
+
+ if (context == null) throw new Error("Failed to create context.");
+
+ context.drawImage(
+ image,
+ clipArea.x,
+ clipArea.y,
+ clipArea.length,
+ clipArea.length,
+ 0,
+ 0,
+ clipArea.length,
+ clipArea.length
+ );
+
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ reject(new Error("canvas.toBlob returns null"));
+ } else {
+ resolve(blob);
+ }
+ }, mimeType);
+ });
+}
diff --git a/Timeline/ClientApp/src/app/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/common/LoadingPage.tsx
index c1bc7105..a849126d 100644
--- a/Timeline/ClientApp/src/app/common/LoadingPage.tsx
+++ b/Timeline/ClientApp/src/app/common/LoadingPage.tsx
@@ -1,12 +1,12 @@
-import React from 'react';
-import { Spinner } from 'reactstrap';
-
-const LoadingPage: React.FC = () => {
- return (
- <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
- <Spinner style={{ height: '2.5rem', width: '2.5rem' }} color="primary" />
- </div>
- );
-};
-
-export default LoadingPage;
+import React from "react";
+import { Spinner } from "reactstrap";
+
+const LoadingPage: React.FC = () => {
+ return (
+ <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+ <Spinner style={{ height: "2.5rem", width: "2.5rem" }} color="primary" />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/Timeline/ClientApp/src/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
index 501a353e..bca4580c 100644
--- a/Timeline/ClientApp/src/app/common/OperationDialog.tsx
+++ b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
@@ -1,381 +1,381 @@
-import React, { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- Spinner,
- Container,
- ModalBody,
- Label,
- Input,
- FormGroup,
- FormFeedback,
- ModalFooter,
- Button,
- Modal,
- ModalHeader,
- FormText,
-} from 'reactstrap';
-
-import { UiLogicError } from '../common';
-
-const DefaultProcessPrompt: React.FC = (_) => {
- return (
- <Container className="justify-content-center align-items-center">
- <Spinner />
- </Container>
- );
-};
-
-interface DefaultErrorPromptProps {
- error?: string;
-}
-
-const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
- const { t } = useTranslation();
-
- let result = <p className="text-danger">{t('operationDialog.error')}</p>;
-
- if (props.error != null) {
- result = (
- <>
- {result}
- <p className="text-danger">{props.error}</p>
- </>
- );
- }
-
- return result;
-};
-
-export type OperationInputOptionalError = undefined | null | string;
-
-export interface OperationInputErrorInfo {
- [index: number]: OperationInputOptionalError;
-}
-
-export type OperationInputValidator<TValue> = (
- value: TValue,
- values: (string | boolean)[]
-) => OperationInputOptionalError | OperationInputErrorInfo;
-
-export interface OperationTextInputInfo {
- type: 'text';
- password?: boolean;
- label?: string;
- initValue?: string;
- textFieldProps?: Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
- 'type' | 'value' | 'onChange'
- >;
- helperText?: string;
- validator?: OperationInputValidator<string>;
-}
-
-export interface OperationBoolInputInfo {
- type: 'bool';
- label: string;
- initValue?: boolean;
-}
-
-export interface OperationSelectInputInfoOption {
- value: string;
- label: string;
- icon?: React.ReactElement;
-}
-
-export interface OperationSelectInputInfo {
- type: 'select';
- label: string;
- options: OperationSelectInputInfoOption[];
- initValue?: string;
-}
-
-export type OperationInputInfo =
- | OperationTextInputInfo
- | OperationBoolInputInfo
- | OperationSelectInputInfo;
-
-interface OperationResult {
- type: 'success' | 'failure';
- data: unknown;
-}
-
-interface OperationDialogProps {
- open: boolean;
- close: () => void;
- title: React.ReactNode;
- titleColor?: 'default' | 'dangerous' | 'create' | string;
- onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
- inputScheme?: OperationInputInfo[];
- inputPrompt?: string | (() => React.ReactNode);
- processPrompt?: () => React.ReactNode;
- successPrompt?: (data: unknown) => React.ReactNode;
- failurePrompt?: (error: unknown) => React.ReactNode;
- onSuccessAndClose?: () => void;
-}
-
-const OperationDialog: React.FC<OperationDialogProps> = (props) => {
- const inputScheme = props.inputScheme ?? [];
-
- const { t } = useTranslation();
-
- type Step = 'input' | 'process' | OperationResult;
- const [step, setStep] = useState<Step>('input');
- const [values, setValues] = useState<(boolean | string)[]>(
- inputScheme.map((i) => {
- if (i.type === 'bool') {
- return i.initValue ?? false;
- } else if (i.type === 'text' || i.type === 'select') {
- return i.initValue ?? '';
- } else {
- throw new UiLogicError('Unknown input scheme.');
- }
- })
- );
- const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
-
- const close = (): void => {
- if (step !== 'process') {
- props.close();
- if (
- typeof step === 'object' &&
- step.type === 'success' &&
- props.onSuccessAndClose
- ) {
- props.onSuccessAndClose();
- }
- } else {
- console.log('Attempt to close modal when processing.');
- }
- };
-
- const onConfirm = (): void => {
- setStep('process');
- props.onProcess(values).then(
- (d: unknown) => {
- setStep({
- type: 'success',
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: 'failure',
- data: e,
- });
- }
- );
- };
-
- let body: React.ReactNode;
- if (step === 'input') {
- let inputPrompt =
- typeof props.inputPrompt === 'function'
- ? props.inputPrompt()
- : props.inputPrompt;
- inputPrompt = <h6>{inputPrompt}</h6>;
-
- const updateValue = (
- index: number,
- newValue: string | boolean
- ): (string | boolean)[] => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- setValues(newValues);
- return newValues;
- };
-
- const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
- for (let i = 0; i < inputScheme.length; i++) {
- if (inputScheme[i].type === 'text' && errorInfo[i] != null) {
- return true;
- }
- }
- return false;
- };
-
- const calculateError = (
- oldError: OperationInputErrorInfo,
- index: number,
- newError: OperationInputOptionalError | OperationInputErrorInfo
- ): OperationInputErrorInfo => {
- if (newError === undefined) {
- return oldError;
- } else if (newError === null || typeof newError === 'string') {
- return { ...oldError, [index]: newError };
- } else {
- const newInputError: OperationInputErrorInfo = { ...oldError };
- for (const [index, error] of Object.entries(newError)) {
- if (error !== undefined) {
- newInputError[+index] = error as OperationInputOptionalError;
- }
- }
- return newInputError;
- }
- };
-
- const validateAll = (): boolean => {
- let newInputError = inputError;
- for (let i = 0; i < inputScheme.length; i++) {
- const item = inputScheme[i];
- if (item.type === 'text') {
- newInputError = calculateError(
- newInputError,
- i,
- item.validator?.(values[i] as string, values)
- );
- }
- }
- const result = !testErrorInfo(newInputError);
- setInputError(newInputError);
- return result;
- };
-
- body = (
- <>
- <ModalBody>
- {inputPrompt}
- {inputScheme.map((item, index) => {
- const value = values[index];
- const error: string | undefined = ((e) =>
- typeof e === 'string' ? t(e) : undefined)(inputError?.[index]);
-
- if (item.type === 'text') {
- return (
- <FormGroup key={index}>
- {item.label && <Label>{t(item.label)}</Label>}
- <Input
- type={item.password === true ? 'password' : 'text'}
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- const newValues = updateValue(index, v);
- setInputError(
- calculateError(
- inputError,
- index,
- item.validator?.(v, newValues)
- )
- );
- }}
- invalid={error != null}
- {...item.textFieldProps}
- />
- {error != null && <FormFeedback>{error}</FormFeedback>}
- {item.helperText && <FormText>{t(item.helperText)}</FormText>}
- </FormGroup>
- );
- } else if (item.type === 'bool') {
- return (
- <FormGroup check key={index}>
- <Input
- type="checkbox"
- value={value as string}
- onChange={(e) => {
- updateValue(
- index,
- (e.target as HTMLInputElement).checked
- );
- }}
- />
- <Label check>{t(item.label)}</Label>
- </FormGroup>
- );
- } else if (item.type === 'select') {
- return (
- <FormGroup key={index}>
- <Label>{t(item.label)}</Label>
- <Input
- type="select"
- value={value as string}
- onChange={(event) => {
- updateValue(index, event.target.value);
- }}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {t(option.label)}
- </option>
- );
- })}
- </Input>
- </FormGroup>
- );
- }
- })}
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={close}>
- {t('operationDialog.cancel')}
- </Button>
- <Button
- color="primary"
- disabled={testErrorInfo(inputError)}
- onClick={() => {
- if (validateAll()) {
- onConfirm();
- }
- }}
- >
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (step === 'process') {
- body = (
- <ModalBody>
- {props.processPrompt?.() ?? <DefaultProcessPrompt />}
- </ModalBody>
- );
- } else {
- let content: React.ReactNode;
- const result = step;
- if (result.type === 'success') {
- content =
- props.successPrompt?.(result.data) ?? t('operationDialog.success');
- if (typeof content === 'string')
- content = <p className="text-success">{content}</p>;
- } else {
- content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
- if (typeof content === 'string')
- content = <DefaultErrorPrompt error={content} />;
- }
- body = (
- <>
- <ModalBody>{content}</ModalBody>
- <ModalFooter>
- <Button color="primary" onClick={close}>
- {t('operationDialog.ok')}
- </Button>
- </ModalFooter>
- </>
- );
- }
-
- const title = typeof props.title === 'string' ? t(props.title) : props.title;
-
- return (
- <Modal isOpen={props.open} toggle={close}>
- <ModalHeader
- className={
- props.titleColor != null
- ? 'text-' +
- (props.titleColor === 'create'
- ? 'success'
- : props.titleColor === 'dangerous'
- ? 'danger'
- : props.titleColor)
- : undefined
- }
- >
- {title}
- </ModalHeader>
- {body}
- </Modal>
- );
-};
-
-export default OperationDialog;
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Spinner,
+ Container,
+ ModalBody,
+ Label,
+ Input,
+ FormGroup,
+ FormFeedback,
+ ModalFooter,
+ Button,
+ Modal,
+ ModalHeader,
+ FormText,
+} from "reactstrap";
+
+import { UiLogicError } from "../common";
+
+const DefaultProcessPrompt: React.FC = (_) => {
+ return (
+ <Container className="justify-content-center align-items-center">
+ <Spinner />
+ </Container>
+ );
+};
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="text-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export type OperationInputOptionalError = undefined | null | string;
+
+export interface OperationInputErrorInfo {
+ [index: number]: OperationInputOptionalError;
+}
+
+export type OperationInputValidator<TValue> = (
+ value: TValue,
+ values: (string | boolean)[]
+) => OperationInputOptionalError | OperationInputErrorInfo;
+
+export interface OperationTextInputInfo {
+ type: "text";
+ password?: boolean;
+ label?: string;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "value" | "onChange"
+ >;
+ helperText?: string;
+ validator?: OperationInputValidator<string>;
+}
+
+export interface OperationBoolInputInfo {
+ type: "bool";
+ label: string;
+ initValue?: boolean;
+}
+
+export interface OperationSelectInputInfoOption {
+ value: string;
+ label: string;
+ icon?: React.ReactElement;
+}
+
+export interface OperationSelectInputInfo {
+ type: "select";
+ label: string;
+ options: OperationSelectInputInfoOption[];
+ initValue?: string;
+}
+
+export type OperationInputInfo =
+ | OperationTextInputInfo
+ | OperationBoolInputInfo
+ | OperationSelectInputInfo;
+
+interface OperationResult {
+ type: "success" | "failure";
+ data: unknown;
+}
+
+interface OperationDialogProps {
+ open: boolean;
+ close: () => void;
+ title: React.ReactNode;
+ titleColor?: "default" | "dangerous" | "create" | string;
+ onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
+ inputScheme?: OperationInputInfo[];
+ inputPrompt?: string | (() => React.ReactNode);
+ processPrompt?: () => React.ReactNode;
+ successPrompt?: (data: unknown) => React.ReactNode;
+ failurePrompt?: (error: unknown) => React.ReactNode;
+ onSuccessAndClose?: () => void;
+}
+
+const OperationDialog: React.FC<OperationDialogProps> = (props) => {
+ const inputScheme = props.inputScheme ?? [];
+
+ const { t } = useTranslation();
+
+ type Step = "input" | "process" | OperationResult;
+ const [step, setStep] = useState<Step>("input");
+ const [values, setValues] = useState<(boolean | string)[]>(
+ inputScheme.map((i) => {
+ if (i.type === "bool") {
+ return i.initValue ?? false;
+ } else if (i.type === "text" || i.type === "select") {
+ return i.initValue ?? "";
+ } else {
+ throw new UiLogicError("Unknown input scheme.");
+ }
+ })
+ );
+ const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+
+ const close = (): void => {
+ if (step !== "process") {
+ props.close();
+ if (
+ typeof step === "object" &&
+ step.type === "success" &&
+ props.onSuccessAndClose
+ ) {
+ props.onSuccessAndClose();
+ }
+ } else {
+ console.log("Attempt to close modal when processing.");
+ }
+ };
+
+ const onConfirm = (): void => {
+ setStep("process");
+ props.onProcess(values).then(
+ (d: unknown) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step === "input") {
+ let inputPrompt =
+ typeof props.inputPrompt === "function"
+ ? props.inputPrompt()
+ : props.inputPrompt;
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ const updateValue = (
+ index: number,
+ newValue: string | boolean
+ ): (string | boolean)[] => {
+ const oldValues = values;
+ const newValues = oldValues.slice();
+ newValues[index] = newValue;
+ setValues(newValues);
+ return newValues;
+ };
+
+ const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
+ for (let i = 0; i < inputScheme.length; i++) {
+ if (inputScheme[i].type === "text" && errorInfo[i] != null) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const calculateError = (
+ oldError: OperationInputErrorInfo,
+ index: number,
+ newError: OperationInputOptionalError | OperationInputErrorInfo
+ ): OperationInputErrorInfo => {
+ if (newError === undefined) {
+ return oldError;
+ } else if (newError === null || typeof newError === "string") {
+ return { ...oldError, [index]: newError };
+ } else {
+ const newInputError: OperationInputErrorInfo = { ...oldError };
+ for (const [index, error] of Object.entries(newError)) {
+ if (error !== undefined) {
+ newInputError[+index] = error as OperationInputOptionalError;
+ }
+ }
+ return newInputError;
+ }
+ };
+
+ const validateAll = (): boolean => {
+ let newInputError = inputError;
+ for (let i = 0; i < inputScheme.length; i++) {
+ const item = inputScheme[i];
+ if (item.type === "text") {
+ newInputError = calculateError(
+ newInputError,
+ i,
+ item.validator?.(values[i] as string, values)
+ );
+ }
+ }
+ const result = !testErrorInfo(newInputError);
+ setInputError(newInputError);
+ return result;
+ };
+
+ body = (
+ <>
+ <ModalBody>
+ {inputPrompt}
+ {inputScheme.map((item, index) => {
+ const value = values[index];
+ const error: string | undefined = ((e) =>
+ typeof e === "string" ? t(e) : undefined)(inputError?.[index]);
+
+ if (item.type === "text") {
+ return (
+ <FormGroup key={index}>
+ {item.label && <Label>{t(item.label)}</Label>}
+ <Input
+ type={item.password === true ? "password" : "text"}
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ const newValues = updateValue(index, v);
+ setInputError(
+ calculateError(
+ inputError,
+ index,
+ item.validator?.(v, newValues)
+ )
+ );
+ }}
+ invalid={error != null}
+ {...item.textFieldProps}
+ />
+ {error != null && <FormFeedback>{error}</FormFeedback>}
+ {item.helperText && <FormText>{t(item.helperText)}</FormText>}
+ </FormGroup>
+ );
+ } else if (item.type === "bool") {
+ return (
+ <FormGroup check key={index}>
+ <Input
+ type="checkbox"
+ value={value as string}
+ onChange={(e) => {
+ updateValue(
+ index,
+ (e.target as HTMLInputElement).checked
+ );
+ }}
+ />
+ <Label check>{t(item.label)}</Label>
+ </FormGroup>
+ );
+ } else if (item.type === "select") {
+ return (
+ <FormGroup key={index}>
+ <Label>{t(item.label)}</Label>
+ <Input
+ type="select"
+ value={value as string}
+ onChange={(event) => {
+ updateValue(index, event.target.value);
+ }}
+ >
+ {item.options.map((option, i) => {
+ return (
+ <option value={option.value} key={i}>
+ {option.icon}
+ {t(option.label)}
+ </option>
+ );
+ })}
+ </Input>
+ </FormGroup>
+ );
+ }
+ })}
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button
+ color="primary"
+ disabled={testErrorInfo(inputError)}
+ onClick={() => {
+ if (validateAll()) {
+ onConfirm();
+ }
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (step === "process") {
+ body = (
+ <ModalBody>
+ {props.processPrompt?.() ?? <DefaultProcessPrompt />}
+ </ModalBody>
+ );
+ } else {
+ let content: React.ReactNode;
+ const result = step;
+ if (result.type === "success") {
+ content =
+ props.successPrompt?.(result.data) ?? t("operationDialog.success");
+ if (typeof content === "string")
+ content = <p className="text-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === "string")
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <ModalBody>{content}</ModalBody>
+ <ModalFooter>
+ <Button color="primary" onClick={close}>
+ {t("operationDialog.ok")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+
+ const title = typeof props.title === "string" ? t(props.title) : props.title;
+
+ return (
+ <Modal isOpen={props.open} toggle={close}>
+ <ModalHeader
+ className={
+ props.titleColor != null
+ ? "text-" +
+ (props.titleColor === "create"
+ ? "success"
+ : props.titleColor === "dangerous"
+ ? "danger"
+ : props.titleColor)
+ : undefined
+ }
+ >
+ {title}
+ </ModalHeader>
+ {body}
+ </Modal>
+ );
+};
+
+export default OperationDialog;
diff --git a/Timeline/ClientApp/src/app/common/SearchInput.tsx b/Timeline/ClientApp/src/app/common/SearchInput.tsx
index 46fb00d1..5a0b0eaa 100644
--- a/Timeline/ClientApp/src/app/common/SearchInput.tsx
+++ b/Timeline/ClientApp/src/app/common/SearchInput.tsx
@@ -1,63 +1,63 @@
-import React, { useCallback } from 'react';
-import clsx from 'clsx';
-import { Spinner, Input, Button } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-export interface SearchInputProps {
- value: string;
- onChange: (value: string) => void;
- onButtonClick: () => void;
- className?: string;
- loading?: boolean;
- buttonText?: string;
- placeholder?: string;
- additionalButton?: React.ReactNode;
-}
-
-const SearchInput: React.FC<SearchInputProps> = (props) => {
- const { onChange, onButtonClick } = props;
-
- const { t } = useTranslation();
-
- const onInputChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>): void => {
- onChange(event.currentTarget.value);
- },
- [onChange]
- );
-
- const onInputKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>): void => {
- if (event.key === 'Enter') {
- onButtonClick();
- }
- },
- [onButtonClick]
- );
-
- return (
- <div className={clsx('form-inline my-2', props.className)}>
- <Input
- className="mr-sm-2"
- value={props.value}
- onChange={onInputChange}
- onKeyPress={onInputKeyPress}
- placeholder={props.placeholder}
- />
- <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
- {props.additionalButton}
- </div>
- <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
- {props.loading ? (
- <Spinner />
- ) : (
- <Button outline color="primary" onClick={props.onButtonClick}>
- {props.buttonText ?? t('search')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default SearchInput;
+import React, { useCallback } from "react";
+import clsx from "clsx";
+import { Spinner, Input, Button } from "reactstrap";
+import { useTranslation } from "react-i18next";
+
+export interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ className?: string;
+ loading?: boolean;
+ buttonText?: string;
+ placeholder?: string;
+ additionalButton?: React.ReactNode;
+}
+
+const SearchInput: React.FC<SearchInputProps> = (props) => {
+ const { onChange, onButtonClick } = props;
+
+ const { t } = useTranslation();
+
+ const onInputChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>): void => {
+ onChange(event.currentTarget.value);
+ },
+ [onChange]
+ );
+
+ const onInputKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>): void => {
+ if (event.key === "Enter") {
+ onButtonClick();
+ }
+ },
+ [onButtonClick]
+ );
+
+ return (
+ <div className={clsx("form-inline my-2", props.className)}>
+ <Input
+ className="mr-sm-2"
+ value={props.value}
+ onChange={onInputChange}
+ onKeyPress={onInputKeyPress}
+ placeholder={props.placeholder}
+ />
+ <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
+ {props.additionalButton}
+ </div>
+ <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
+ {props.loading ? (
+ <Spinner />
+ ) : (
+ <Button outline color="primary" onClick={props.onButtonClick}>
+ {props.buttonText ?? t("search")}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default SearchInput;
diff --git a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
index 1ec62021..27d188fc 100644
--- a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
@@ -1,26 +1,26 @@
-import React, { SVGAttributes } from 'react';
-
-export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
- color?: string;
-}
-
-const TimelineLogo: React.FC<TimelineLogoProps> = props => {
- const { color, ...forwardProps } = props;
- const coercedColor = color ?? 'currentcolor';
- return (
- <svg
- className={props.className}
- viewBox="0 0 100 100"
- fill="none"
- strokeWidth="12"
- stroke={coercedColor}
- {...forwardProps}
- >
- <line x1="50" y1="0" x2="50" y2="25" />
- <circle cx="50" cy="50" r="22" />
- <line x1="50" y1="75" x2="50" y2="100" />
- </svg>
- );
-};
-
-export default TimelineLogo;
+import React, { SVGAttributes } from "react";
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+ return (
+ <svg
+ className={props.className}
+ viewBox="0 0 100 100"
+ fill="none"
+ strokeWidth="12"
+ stroke={coercedColor}
+ {...forwardProps}
+ >
+ <line x1="50" y1="0" x2="50" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" y1="75" x2="50" y2="100" />
+ </svg>
+ );
+};
+
+export default TimelineLogo;
diff --git a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
index 3b721985..29f6a69f 100644
--- a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
@@ -1,26 +1,26 @@
-import React, { SVGAttributes } from 'react';
-
-export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
- color?: string;
-}
-
-const UserTimelineLogo: React.FC<UserTimelineLogoProps> = props => {
- const { color, ...forwardProps } = props;
- const coercedColor = color ?? 'currentcolor';
-
- return (
- <svg viewBox="0 0 100 100" {...forwardProps}>
- <g fill="none" stroke={coercedColor} strokeWidth="12">
- <line x1="50" x2="50" y1="0" y2="25" />
- <circle cx="50" cy="50" r="22" />
- <line x1="50" x2="50" y1="75" y2="100" />
- </g>
- <g fill={color}>
- <circle cx="85" cy="75" r="10" />
- <path d="m70,100c0,0 15,-30 30,0.25" />
- </g>
- </svg>
- );
-};
-
-export default UserTimelineLogo;
+import React, { SVGAttributes } from "react";
+
+export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const UserTimelineLogo: React.FC<UserTimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+
+ return (
+ <svg viewBox="0 0 100 100" {...forwardProps}>
+ <g fill="none" stroke={coercedColor} strokeWidth="12">
+ <line x1="50" x2="50" y1="0" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" x2="50" y1="75" y2="100" />
+ </g>
+ <g fill={color}>
+ <circle cx="85" cy="75" r="10" />
+ <path d="m70,100c0,0 15,-30 30,0.25" />
+ </g>
+ </svg>
+ );
+};
+
+export default UserTimelineLogo;
diff --git a/Timeline/ClientApp/src/app/common/alert-service.ts b/Timeline/ClientApp/src/app/common/alert-service.ts
index 957c5e10..e4c0e653 100644
--- a/Timeline/ClientApp/src/app/common/alert-service.ts
+++ b/Timeline/ClientApp/src/app/common/alert-service.ts
@@ -1,61 +1,61 @@
-import React from 'react';
-import pull from 'lodash/pull';
-
-export interface AlertInfo {
- type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
- message: string | React.FC<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);
-}
+import React from "react";
+import pull from "lodash/pull";
+
+export interface AlertInfo {
+ type?: "primary" | "secondary" | "success" | "danger" | "warning" | "info";
+ message: string | React.FC<unknown> | { type: "i18n"; key: string };
+ dismissTime?: number | "never";
+}
+
+export interface AlertInfoEx extends AlertInfo {
+ id: number;
+}
+
+export type AlertConsumer = (alerts: AlertInfoEx) => void;
+
+export class AlertService {
+ private consumers: AlertConsumer[] = [];
+ private savedAlerts: AlertInfoEx[] = [];
+ private currentId = 1;
+
+ private produce(alert: AlertInfoEx): void {
+ for (const consumer of this.consumers) {
+ consumer(alert);
+ }
+ }
+
+ registerConsumer(consumer: AlertConsumer): void {
+ this.consumers.push(consumer);
+ if (this.savedAlerts.length !== 0) {
+ for (const alert of this.savedAlerts) {
+ this.produce(alert);
+ }
+ this.savedAlerts = [];
+ }
+ }
+
+ unregisterConsumer(consumer: AlertConsumer): void {
+ pull(this.consumers, consumer);
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ };
+ if (this.consumers.length === 0) {
+ this.savedAlerts.push(newAlert);
+ } else {
+ this.produce(newAlert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
+
+export const kAlertHostId = "alert-host";
+
+export function getAlertHost(): HTMLElement | null {
+ return document.getElementById(kAlertHostId);
+}
diff --git a/Timeline/ClientApp/src/app/common/alert.sass b/Timeline/ClientApp/src/app/common/alert.sass
index c3560b87..5b6e65c2 100644
--- a/Timeline/ClientApp/src/app/common/alert.sass
+++ b/Timeline/ClientApp/src/app/common/alert.sass
@@ -1,15 +1,15 @@
-.alert-container
- position: fixed
- z-index: $zindex-popover
-
-@include media-breakpoint-up(sm)
- .alert-container
- bottom: 0
- right: 0
-
-@include media-breakpoint-down(sm)
- .alert-container
- bottom: 0
- right: 0
- left: 0
- text-align: center
+.alert-container
+ position: fixed
+ z-index: $zindex-popover
+
+@include media-breakpoint-up(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+
+@include media-breakpoint-down(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+ left: 0
+ text-align: center
diff --git a/Timeline/ClientApp/src/app/common/common.sass b/Timeline/ClientApp/src/app/common/common.sass
index 78e6fd14..15d34d7c 100644
--- a/Timeline/ClientApp/src/app/common/common.sass
+++ b/Timeline/ClientApp/src/app/common/common.sass
@@ -1,33 +1,33 @@
-.image-cropper-container
- position: relative
- box-sizing: border-box
- user-select: none
-
-.image-cropper-container img
- position: absolute
- left: 0
- top: 0
- width: 100%
- height: 100%
-
-.image-cropper-mask-container
- position: absolute
- left: 0
- top: 0
- right: 0
- bottom: 0
- overflow: hidden
-
-.image-cropper-mask
- position: absolute
- box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
- touch-action: none
-
-.image-cropper-handler
- position: absolute
- width: 26px
- height: 26px
- border: black solid 2px
- border-radius: 50%
- background: white
- touch-action: none
+.image-cropper-container
+ position: relative
+ box-sizing: border-box
+ user-select: none
+
+.image-cropper-container img
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+
+.image-cropper-mask-container
+ position: absolute
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ overflow: hidden
+
+.image-cropper-mask
+ position: absolute
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
+ touch-action: none
+
+.image-cropper-handler
+ position: absolute
+ width: 26px
+ height: 26px
+ border: black solid 2px
+ border-radius: 50%
+ background: white
+ touch-action: none
diff --git a/Timeline/ClientApp/src/app/data/DataHub.ts b/Timeline/ClientApp/src/app/data/DataHub.ts
index bfb96d1a..e6be740d 100644
--- a/Timeline/ClientApp/src/app/data/DataHub.ts
+++ b/Timeline/ClientApp/src/app/data/DataHub.ts
@@ -1,198 +1,198 @@
-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 _syncingSubject = new BehaviorSubject<boolean>(false);
-
- private _observers: Subscriber<TData>[] = [];
-
- constructor(
- private config?: { destroyable?: (value: TData | undefined) => boolean }
- ) {}
-
- 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._syncingSubject.value;
- }
-
- beginSync(): void {
- if (!this._syncingSubject.value) {
- this._syncingSubject.next(true);
- }
- }
-
- endSync(): void {
- if (this._syncingSubject.value) {
- this._syncingSubject.next(false);
- }
- }
-
- get destroyable(): boolean {
- const customDestroyable = this.config?.destroyable;
-
- return (
- this._observers.length === 0 &&
- !this._syncingSubject.value &&
- (customDestroyable != null ? customDestroyable(this._current) : true)
- );
- }
-
- endSyncAndNext(value: TData): void {
- this.endSync();
- this.next(value);
- }
-}
-
-export class DataHub<TKey, TData> {
- private keyToString: (key: TKey) => string;
- private setup?: (key: TKey, line: DataLine<TData>) => (() => void) | void;
- 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?: {
- keyToString?: (key: TKey) => string;
- setup?: (key: TKey, line: DataLine<TData>) => void;
- destroyable?: (key: TKey, value: TData | undefined) => boolean;
- }) {
- this.keyToString =
- config?.keyToString ??
- ((value): string => {
- if (typeof value === 'string') return value;
- else
- throw new Error(
- 'Default keyToString function only pass string value.'
- );
- });
-
- this.setup = config?.setup;
- 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, useSetup = true): DataLine<TData> {
- const keyString = this.keyToString(key);
- const { setup, destroyable } = this;
- const newLine = new DataLine<TData>({
- destroyable:
- destroyable != null ? (value) => destroyable(key, value) : undefined,
- });
- this.subscriptionLineMap.set(keyString, newLine);
- if (useSetup) {
- setup?.(key, newLine);
- }
- if (this.subscriptionLineMap.size === 1) {
- this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000);
- }
- return newLine;
- }
-
- getObservable(key: TKey): Observable<TData> {
- return this.getLineOrCreateWithSetup(key).getObservable();
- }
-
- getSyncStatusObservable(key: TKey): Observable<boolean> {
- return this.getLineOrCreateWithSetup(key).getSyncStatusObservable();
- }
-
- getDataWithSyncStatusObservable(
- key: TKey
- ): Observable<WithSyncStatus<TData>> {
- return this.getLineOrCreateWithSetup(key).getDataWithSyncStatusObservable();
- }
-
- getLine(key: TKey): DataLine<TData> | null {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? null;
- }
-
- getLineOrCreateWithSetup(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
- }
-
- getLineOrCreateWithoutSetup(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return (
- this.subscriptionLineMap.get(keyString) ?? this.createLine(key, false)
- );
- }
-}
+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 _syncingSubject = new BehaviorSubject<boolean>(false);
+
+ private _observers: Subscriber<TData>[] = [];
+
+ constructor(
+ private config?: { destroyable?: (value: TData | undefined) => boolean }
+ ) {}
+
+ 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._syncingSubject.value;
+ }
+
+ beginSync(): void {
+ if (!this._syncingSubject.value) {
+ this._syncingSubject.next(true);
+ }
+ }
+
+ endSync(): void {
+ if (this._syncingSubject.value) {
+ this._syncingSubject.next(false);
+ }
+ }
+
+ get destroyable(): boolean {
+ const customDestroyable = this.config?.destroyable;
+
+ return (
+ this._observers.length === 0 &&
+ !this._syncingSubject.value &&
+ (customDestroyable != null ? customDestroyable(this._current) : true)
+ );
+ }
+
+ endSyncAndNext(value: TData): void {
+ this.endSync();
+ this.next(value);
+ }
+}
+
+export class DataHub<TKey, TData> {
+ private keyToString: (key: TKey) => string;
+ private setup?: (key: TKey, line: DataLine<TData>) => (() => void) | void;
+ 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?: {
+ keyToString?: (key: TKey) => string;
+ setup?: (key: TKey, line: DataLine<TData>) => void;
+ destroyable?: (key: TKey, value: TData | undefined) => boolean;
+ }) {
+ this.keyToString =
+ config?.keyToString ??
+ ((value): string => {
+ if (typeof value === "string") return value;
+ else
+ throw new Error(
+ "Default keyToString function only pass string value."
+ );
+ });
+
+ this.setup = config?.setup;
+ 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, useSetup = true): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ const { setup, destroyable } = this;
+ const newLine = new DataLine<TData>({
+ destroyable:
+ destroyable != null ? (value) => destroyable(key, value) : undefined,
+ });
+ this.subscriptionLineMap.set(keyString, newLine);
+ if (useSetup) {
+ setup?.(key, newLine);
+ }
+ if (this.subscriptionLineMap.size === 1) {
+ this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000);
+ }
+ return newLine;
+ }
+
+ getObservable(key: TKey): Observable<TData> {
+ return this.getLineOrCreateWithSetup(key).getObservable();
+ }
+
+ getSyncStatusObservable(key: TKey): Observable<boolean> {
+ return this.getLineOrCreateWithSetup(key).getSyncStatusObservable();
+ }
+
+ getDataWithSyncStatusObservable(
+ key: TKey
+ ): Observable<WithSyncStatus<TData>> {
+ return this.getLineOrCreateWithSetup(key).getDataWithSyncStatusObservable();
+ }
+
+ getLine(key: TKey): DataLine<TData> | null {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? null;
+ }
+
+ getLineOrCreateWithSetup(key: TKey): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
+ }
+
+ getLineOrCreateWithoutSetup(key: TKey): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ return (
+ this.subscriptionLineMap.get(keyString) ?? this.createLine(key, false)
+ );
+ }
+}
diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts
index 87984e21..8d52abe5 100644
--- a/Timeline/ClientApp/src/app/data/common.ts
+++ b/Timeline/ClientApp/src/app/data/common.ts
@@ -1,23 +1,23 @@
-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';
+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/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts
index 216d903c..ed6cffd6 100644
--- a/Timeline/ClientApp/src/app/data/timeline.ts
+++ b/Timeline/ClientApp/src/app/data/timeline.ts
@@ -1,697 +1,697 @@
-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 { dataStorage, throwIfNotNetworkError, BlobOrStatus } from './common';
-import { DataHub, WithSyncStatus } from './DataHub';
-
-import { UserAuthInfo, checkLogin, userService, userInfoService } from './user';
-
-export { kTimelineVisibilities } from '../http/timeline';
-
-export type { TimelineVisibility } from '../http/timeline';
-
-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 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 convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
- return {
- ...timeline,
- owner: timeline.owner.username,
- members: timeline.members.map((m) => m.username),
- };
- }
-
- private async syncTimeline(timelineName: string): Promise<void> {
- const line = this._timelineHub.getLineOrCreateWithoutSetup(timelineName);
- if (line.isSyncing) return;
-
- if (line.value == undefined) {
- const cache = await this.getCachedTimeline(timelineName);
- if (cache != null) {
- line.next({ type: 'cache', timeline: cache });
- }
- }
-
- try {
- const httpTimeline = await getHttpTimelineClient().getTimeline(
- timelineName
- );
-
- [httpTimeline.owner, ...httpTimeline.members].forEach(
- (user) => void userInfoService.saveUser(user)
- );
-
- const timeline = this.convertHttpTimelineToData(httpTimeline);
- await this.saveTimeline(timelineName, timeline);
- line.endSyncAndNext({ type: 'synced', timeline });
- } catch (e) {
- if (e instanceof HttpTimelineNotExistError) {
- line.endSyncAndNext({ type: 'synced', timeline: null });
- } else {
- const cache = await this.getCachedTimeline(timelineName);
- if (cache == null) {
- line.endSyncAndNext({ type: 'offline', timeline: null });
- } else {
- line.endSyncAndNext({ type: 'offline', timeline: cache });
- }
- throwIfNotNetworkError(e);
- }
- }
- }
-
- private _timelineHub = new DataHub<
- string,
- | {
- type: 'cache';
- timeline: TimelineData;
- }
- | {
- type: 'offline' | 'synced';
- timeline: TimelineData | null;
- }
- >({
- setup: (key) => {
- void this.syncTimeline(key);
- },
- });
-
- 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 async syncPosts(timelineName: string): Promise<void> {
- const line = this._postsHub.getLineOrCreateWithoutSetup(timelineName);
- if (line.isSyncing) return;
- line.beginSync();
-
- if (line.value == null) {
- const cache = await this.getCachedPosts(timelineName);
- if (cache != null) {
- line.next({ type: 'cache', posts: cache });
- }
- }
-
- const now = new Date();
-
- const lastUpdatedTime = await dataStorage.getItem<Date | null>(
- `timeline.${timelineName}.lastUpdated`
- );
-
- try {
- if (lastUpdatedTime == null) {
- const httpPosts = await getHttpTimelineClient().listPost(
- timelineName,
- userService.currentUser?.token
- );
-
- uniqBy(
- httpPosts.map((post) => post.author),
- 'username'
- ).forEach((user) => void userInfoService.saveUser(user));
-
- const posts = this.convertHttpPostToDataList(httpPosts);
- await this.savePosts(timelineName, posts);
- await dataStorage.setItem<Date>(
- `timeline.${timelineName}.lastUpdated`,
- now
- );
-
- line.endSyncAndNext({ type: 'synced', posts });
- } else {
- const httpPosts = await getHttpTimelineClient().listPost(
- timelineName,
- 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
- );
-
- uniqBy(
- httpPosts
- .map((post) => post.author)
- .filter((u): u is HttpUser => u != null),
- 'username'
- ).forEach((user) => void userInfoService.saveUser(user));
-
- const cache = (await this.getCachedPosts(timelineName)) ?? [];
-
- 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(timelineName, posts);
- await dataStorage.setItem<Date>(
- `timeline.${timelineName}.lastUpdated`,
- now
- );
- line.endSyncAndNext({ type: 'synced', posts });
- }
- } catch (e) {
- if (e instanceof HttpTimelineNotExistError) {
- line.endSyncAndNext({ type: 'notexist', posts: [] });
- } else if (e instanceof HttpForbiddenError) {
- line.endSyncAndNext({ type: 'forbid', posts: [] });
- } else {
- const cache = await this.getCachedPosts(timelineName);
- if (cache == null) {
- line.endSyncAndNext({ type: 'offline', posts: [] });
- } else {
- line.endSyncAndNext({ type: 'offline', posts: cache });
- }
- throwIfNotNetworkError(e);
- }
- }
- }
-
- private _postsHub = new DataHub<
- string,
- {
- type: 'cache' | 'offline' | 'synced' | 'forbid' | 'notexist';
- posts: TimelinePostData[];
- }
- >({
- setup: (key) => {
- void this.syncPosts(key);
- },
- });
-
- 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 async syncPostData(key: {
- timelineName: string;
- postId: number;
- }): Promise<void> {
- const line = this._postDataHub.getLineOrCreateWithoutSetup(key);
- if (line.isSyncing) return;
- line.beginSync();
-
- 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.endSyncAndNext({ data: res.data, type: 'synced' });
- } catch (e) {
- line.endSyncAndNext({ type: 'offline' });
- throwIfNotNetworkError(e);
- }
- } else {
- try {
- const res = await getHttpTimelineClient().getPostData(
- key.timelineName,
- key.postId,
- cache.etag
- );
- if (res instanceof NotModified) {
- line.endSyncAndNext({ data: cache.data, type: 'synced' });
- } else {
- await this.savePostData(key, res);
- line.endSyncAndNext({ data: res.data, type: 'synced' });
- }
- } catch (e) {
- line.endSyncAndNext({ data: cache.data, type: 'offline' });
- throwIfNotNetworkError(e);
- }
- }
- }
-
- 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}`,
- setup: (key) => {
- void this.syncPostData(key);
- },
- });
-
- 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));
-}
+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 { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common";
+import { DataHub, WithSyncStatus } from "./DataHub";
+
+import { UserAuthInfo, checkLogin, userService, userInfoService } from "./user";
+
+export { kTimelineVisibilities } from "../http/timeline";
+
+export type { TimelineVisibility } from "../http/timeline";
+
+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 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 convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
+ return {
+ ...timeline,
+ owner: timeline.owner.username,
+ members: timeline.members.map((m) => m.username),
+ };
+ }
+
+ private async syncTimeline(timelineName: string): Promise<void> {
+ const line = this._timelineHub.getLineOrCreateWithoutSetup(timelineName);
+ if (line.isSyncing) return;
+
+ if (line.value == undefined) {
+ const cache = await this.getCachedTimeline(timelineName);
+ if (cache != null) {
+ line.next({ type: "cache", timeline: cache });
+ }
+ }
+
+ try {
+ const httpTimeline = await getHttpTimelineClient().getTimeline(
+ timelineName
+ );
+
+ [httpTimeline.owner, ...httpTimeline.members].forEach(
+ (user) => void userInfoService.saveUser(user)
+ );
+
+ const timeline = this.convertHttpTimelineToData(httpTimeline);
+ await this.saveTimeline(timelineName, timeline);
+ line.endSyncAndNext({ type: "synced", timeline });
+ } catch (e) {
+ if (e instanceof HttpTimelineNotExistError) {
+ line.endSyncAndNext({ type: "synced", timeline: null });
+ } else {
+ const cache = await this.getCachedTimeline(timelineName);
+ if (cache == null) {
+ line.endSyncAndNext({ type: "offline", timeline: null });
+ } else {
+ line.endSyncAndNext({ type: "offline", timeline: cache });
+ }
+ throwIfNotNetworkError(e);
+ }
+ }
+ }
+
+ private _timelineHub = new DataHub<
+ string,
+ | {
+ type: "cache";
+ timeline: TimelineData;
+ }
+ | {
+ type: "offline" | "synced";
+ timeline: TimelineData | null;
+ }
+ >({
+ setup: (key) => {
+ void this.syncTimeline(key);
+ },
+ });
+
+ 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 async syncPosts(timelineName: string): Promise<void> {
+ const line = this._postsHub.getLineOrCreateWithoutSetup(timelineName);
+ if (line.isSyncing) return;
+ line.beginSync();
+
+ if (line.value == null) {
+ const cache = await this.getCachedPosts(timelineName);
+ if (cache != null) {
+ line.next({ type: "cache", posts: cache });
+ }
+ }
+
+ const now = new Date();
+
+ const lastUpdatedTime = await dataStorage.getItem<Date | null>(
+ `timeline.${timelineName}.lastUpdated`
+ );
+
+ try {
+ if (lastUpdatedTime == null) {
+ const httpPosts = await getHttpTimelineClient().listPost(
+ timelineName,
+ userService.currentUser?.token
+ );
+
+ uniqBy(
+ httpPosts.map((post) => post.author),
+ "username"
+ ).forEach((user) => void userInfoService.saveUser(user));
+
+ const posts = this.convertHttpPostToDataList(httpPosts);
+ await this.savePosts(timelineName, posts);
+ await dataStorage.setItem<Date>(
+ `timeline.${timelineName}.lastUpdated`,
+ now
+ );
+
+ line.endSyncAndNext({ type: "synced", posts });
+ } else {
+ const httpPosts = await getHttpTimelineClient().listPost(
+ timelineName,
+ 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
+ );
+
+ uniqBy(
+ httpPosts
+ .map((post) => post.author)
+ .filter((u): u is HttpUser => u != null),
+ "username"
+ ).forEach((user) => void userInfoService.saveUser(user));
+
+ const cache = (await this.getCachedPosts(timelineName)) ?? [];
+
+ 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(timelineName, posts);
+ await dataStorage.setItem<Date>(
+ `timeline.${timelineName}.lastUpdated`,
+ now
+ );
+ line.endSyncAndNext({ type: "synced", posts });
+ }
+ } catch (e) {
+ if (e instanceof HttpTimelineNotExistError) {
+ line.endSyncAndNext({ type: "notexist", posts: [] });
+ } else if (e instanceof HttpForbiddenError) {
+ line.endSyncAndNext({ type: "forbid", posts: [] });
+ } else {
+ const cache = await this.getCachedPosts(timelineName);
+ if (cache == null) {
+ line.endSyncAndNext({ type: "offline", posts: [] });
+ } else {
+ line.endSyncAndNext({ type: "offline", posts: cache });
+ }
+ throwIfNotNetworkError(e);
+ }
+ }
+ }
+
+ private _postsHub = new DataHub<
+ string,
+ {
+ type: "cache" | "offline" | "synced" | "forbid" | "notexist";
+ posts: TimelinePostData[];
+ }
+ >({
+ setup: (key) => {
+ void this.syncPosts(key);
+ },
+ });
+
+ 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 async syncPostData(key: {
+ timelineName: string;
+ postId: number;
+ }): Promise<void> {
+ const line = this._postDataHub.getLineOrCreateWithoutSetup(key);
+ if (line.isSyncing) return;
+ line.beginSync();
+
+ 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.endSyncAndNext({ data: res.data, type: "synced" });
+ } catch (e) {
+ line.endSyncAndNext({ type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ } else {
+ try {
+ const res = await getHttpTimelineClient().getPostData(
+ key.timelineName,
+ key.postId,
+ cache.etag
+ );
+ if (res instanceof NotModified) {
+ line.endSyncAndNext({ data: cache.data, type: "synced" });
+ } else {
+ await this.savePostData(key, res);
+ line.endSyncAndNext({ data: res.data, type: "synced" });
+ }
+ } catch (e) {
+ line.endSyncAndNext({ data: cache.data, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ }
+
+ 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}`,
+ setup: (key) => {
+ void this.syncPostData(key);
+ },
+ });
+
+ 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/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts
index 419cff18..8aee0c5f 100644
--- a/Timeline/ClientApp/src/app/data/user.ts
+++ b/Timeline/ClientApp/src/app/data/user.ts
@@ -1,389 +1,389 @@
-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 { pushAlert } from '../common/alert-service';
-
-import { dataStorage, throwIfNotNetworkError } from './common';
-import { DataHub } from './DataHub';
-
-import { HttpNetworkError, BlobWithEtag, NotModified } from '../http/common';
-import {
- getHttpTokenClient,
- HttpCreateTokenBadCredentialError,
-} from '../http/token';
-import {
- getHttpUserClient,
- HttpUserNotExistError,
- HttpUser,
-} from '../http/user';
-
-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 {
- async saveUser(user: HttpUser): Promise<void> {
- const key = user.username;
- const line = this._userHub.getLineOrCreateWithoutSetup(key);
- if (line.isSyncing) return;
- line.beginSync();
- await this.doSaveUser(user);
- line.endSyncAndNext({ user, type: 'synced' });
- }
-
- 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();
- }
-
- private async syncUser(username: string): Promise<void> {
- const line = this._userHub.getLineOrCreateWithoutSetup(username);
- if (line.isSyncing) return;
- line.beginSync();
-
- if (line.value == undefined) {
- const cache = await this.getCachedUser(username);
- if (cache != null) {
- line.next({ user: cache, type: 'cache' });
- }
- }
-
- try {
- const res = await getHttpUserClient().get(username);
- await this.doSaveUser(res);
- line.endSyncAndNext({ user: res, type: 'synced' });
- } catch (e) {
- if (e instanceof HttpUserNotExistError) {
- line.endSyncAndNext({ type: 'notexist' });
- } else {
- const cache = await this.getCachedUser(username);
- line.endSyncAndNext({ user: cache ?? undefined, type: 'offline' });
- throwIfNotNetworkError(e);
- }
- }
- }
-
- private _userHub = new DataHub<
- string,
- | { user: User; type: 'cache' | 'synced' | 'offline' }
- | { user?: undefined; type: 'notexist' | 'offline' }
- >({
- setup: (key) => {
- void this.syncUser(key);
- },
- });
-
- 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();
- }
-
- private async syncAvatar(username: string): Promise<void> {
- const line = this._avatarHub.getLineOrCreateWithoutSetup(username);
- if (line.isSyncing) return;
- line.beginSync();
-
- const cache = await this.getCachedAvatar(username);
- if (line.value == null) {
- if (cache != null) {
- line.next({ data: cache.data, type: 'cache' });
- }
- }
-
- if (cache == null) {
- try {
- const avatar = await getHttpUserClient().getAvatar(username);
- await this.saveAvatar(username, avatar);
- line.endSyncAndNext({ data: avatar.data, type: 'synced' });
- } catch (e) {
- line.endSyncAndNext({ type: 'offline' });
- throwIfNotNetworkError(e);
- }
- } else {
- try {
- const res = await getHttpUserClient().getAvatar(username, cache.etag);
- if (res instanceof NotModified) {
- line.endSyncAndNext({ data: cache.data, type: 'synced' });
- } else {
- const avatar = res;
- await this.saveAvatar(username, avatar);
- line.endSyncAndNext({ data: avatar.data, type: 'synced' });
- }
- } catch (e) {
- line.endSyncAndNext({ data: cache.data, type: 'offline' });
- throwIfNotNetworkError(e);
- }
- }
- }
-
- private _avatarHub = new DataHub<
- string,
- | { data: Blob; type: 'cache' | 'synced' | 'offline' }
- | { data?: undefined; type: 'notexist' | 'offline' }
- >({
- setup: (key) => {
- void this.syncAvatar(key);
- },
- });
-
- 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' });
- }
-}
-
-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;
-}
+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 { pushAlert } from "../common/alert-service";
+
+import { dataStorage, throwIfNotNetworkError } from "./common";
+import { DataHub } from "./DataHub";
+
+import { HttpNetworkError, BlobWithEtag, NotModified } from "../http/common";
+import {
+ getHttpTokenClient,
+ HttpCreateTokenBadCredentialError,
+} from "../http/token";
+import {
+ getHttpUserClient,
+ HttpUserNotExistError,
+ HttpUser,
+} from "../http/user";
+
+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 {
+ async saveUser(user: HttpUser): Promise<void> {
+ const key = user.username;
+ const line = this._userHub.getLineOrCreateWithoutSetup(key);
+ if (line.isSyncing) return;
+ line.beginSync();
+ await this.doSaveUser(user);
+ line.endSyncAndNext({ user, type: "synced" });
+ }
+
+ 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();
+ }
+
+ private async syncUser(username: string): Promise<void> {
+ const line = this._userHub.getLineOrCreateWithoutSetup(username);
+ if (line.isSyncing) return;
+ line.beginSync();
+
+ if (line.value == undefined) {
+ const cache = await this.getCachedUser(username);
+ if (cache != null) {
+ line.next({ user: cache, type: "cache" });
+ }
+ }
+
+ try {
+ const res = await getHttpUserClient().get(username);
+ await this.doSaveUser(res);
+ line.endSyncAndNext({ user: res, type: "synced" });
+ } catch (e) {
+ if (e instanceof HttpUserNotExistError) {
+ line.endSyncAndNext({ type: "notexist" });
+ } else {
+ const cache = await this.getCachedUser(username);
+ line.endSyncAndNext({ user: cache ?? undefined, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ }
+
+ private _userHub = new DataHub<
+ string,
+ | { user: User; type: "cache" | "synced" | "offline" }
+ | { user?: undefined; type: "notexist" | "offline" }
+ >({
+ setup: (key) => {
+ void this.syncUser(key);
+ },
+ });
+
+ 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();
+ }
+
+ private async syncAvatar(username: string): Promise<void> {
+ const line = this._avatarHub.getLineOrCreateWithoutSetup(username);
+ if (line.isSyncing) return;
+ line.beginSync();
+
+ const cache = await this.getCachedAvatar(username);
+ if (line.value == null) {
+ if (cache != null) {
+ line.next({ data: cache.data, type: "cache" });
+ }
+ }
+
+ if (cache == null) {
+ try {
+ const avatar = await getHttpUserClient().getAvatar(username);
+ await this.saveAvatar(username, avatar);
+ line.endSyncAndNext({ data: avatar.data, type: "synced" });
+ } catch (e) {
+ line.endSyncAndNext({ type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ } else {
+ try {
+ const res = await getHttpUserClient().getAvatar(username, cache.etag);
+ if (res instanceof NotModified) {
+ line.endSyncAndNext({ data: cache.data, type: "synced" });
+ } else {
+ const avatar = res;
+ await this.saveAvatar(username, avatar);
+ line.endSyncAndNext({ data: avatar.data, type: "synced" });
+ }
+ } catch (e) {
+ line.endSyncAndNext({ data: cache.data, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ }
+
+ private _avatarHub = new DataHub<
+ string,
+ | { data: Blob; type: "cache" | "synced" | "offline" }
+ | { data?: undefined; type: "notexist" | "offline" }
+ >({
+ setup: (key) => {
+ void this.syncAvatar(key);
+ },
+ });
+
+ 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" });
+ }
+}
+
+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/home/BoardWithUser.tsx b/Timeline/ClientApp/src/app/home/BoardWithUser.tsx
index 3830104f..4910aebe 100644
--- a/Timeline/ClientApp/src/app/home/BoardWithUser.tsx
+++ b/Timeline/ClientApp/src/app/home/BoardWithUser.tsx
@@ -1,102 +1,102 @@
-import React from 'react';
-import { Row, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { UserWithToken } from '../data/user';
-import { TimelineInfo } from '../data/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;
+import React from "react";
+import { Row, Col } from "reactstrap";
+import { useTranslation } from "react-i18next";
+
+import { UserWithToken } from "../data/user";
+import { TimelineInfo } from "../data/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/home/BoardWithoutUser.tsx b/Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx
index 4b30fcc4..a7dc36be 100644
--- a/Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx
+++ b/Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx
@@ -1,61 +1,61 @@
-import React from 'react';
-import { Row, Col } from 'reactstrap';
-
-import { TimelineInfo } from '../data/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;
+import React from "react";
+import { Row, Col } from "reactstrap";
+
+import { TimelineInfo } from "../data/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/home/Home.tsx b/Timeline/ClientApp/src/app/home/Home.tsx
index b759fa50..00e6ff5a 100644
--- a/Timeline/ClientApp/src/app/home/Home.tsx
+++ b/Timeline/ClientApp/src/app/home/Home.tsx
@@ -1,102 +1,102 @@
-import React from 'react';
-import { useHistory } from 'react-router';
-import { Row, Container, Button, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { useUser } from '../data/user';
-
-import AppBar from '../common/AppBar';
-import SearchInput from '../common/SearchInput';
-import BoardWithoutUser from './BoardWithoutUser';
-import BoardWithUser from './BoardWithUser';
-import TimelineCreateDialog from './TimelineCreateDialog';
-
-const Home: 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 (
- <>
- <AppBar />
- <Container fluid style={{ marginTop: '56px' }}>
- <Row>
- <Col>
- <SearchInput
- className="justify-content-center"
- value={navText}
- onChange={setNavText}
- onButtonClick={goto}
- buttonText={t('home.go')}
- placeholder="@crupest"
- additionalButton={
- user != null && (
- <Button
- color="success"
- outline
- onClick={() => {
- 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 Home;
+import React from "react";
+import { useHistory } from "react-router";
+import { Row, Container, Button, Col } from "reactstrap";
+import { useTranslation } from "react-i18next";
+
+import { useUser } from "../data/user";
+
+import AppBar from "../common/AppBar";
+import SearchInput from "../common/SearchInput";
+import BoardWithoutUser from "./BoardWithoutUser";
+import BoardWithUser from "./BoardWithUser";
+import TimelineCreateDialog from "./TimelineCreateDialog";
+
+const Home: 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 (
+ <>
+ <AppBar />
+ <Container fluid style={{ marginTop: "56px" }}>
+ <Row>
+ <Col>
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={goto}
+ buttonText={t("home.go")}
+ placeholder="@crupest"
+ additionalButton={
+ user != null && (
+ <Button
+ color="success"
+ outline
+ onClick={() => {
+ 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 Home;
diff --git a/Timeline/ClientApp/src/app/home/OfflineBoard.tsx b/Timeline/ClientApp/src/app/home/OfflineBoard.tsx
index ca6d2a26..458166ac 100644
--- a/Timeline/ClientApp/src/app/home/OfflineBoard.tsx
+++ b/Timeline/ClientApp/src/app/home/OfflineBoard.tsx
@@ -1,62 +1,62 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { Trans } from 'react-i18next';
-
-import { getAllCachedTimelineNames } from '../data/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;
+import React from "react";
+import { Link } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { getAllCachedTimelineNames } from "../data/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/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx
index 8f8f6387..91bf2b77 100644
--- a/Timeline/ClientApp/src/app/home/TimelineBoard.tsx
+++ b/Timeline/ClientApp/src/app/home/TimelineBoard.tsx
@@ -1,74 +1,74 @@
-import React from 'react';
-import clsx from 'clsx';
-import { Link } from 'react-router-dom';
-import { Spinner } from 'reactstrap';
-import { Trans } from 'react-i18next';
-
-import { TimelineInfo } from '../data/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 color="primary" />
- </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;
+import React from "react";
+import clsx from "clsx";
+import { Link } from "react-router-dom";
+import { Spinner } from "reactstrap";
+import { Trans } from "react-i18next";
+
+import { TimelineInfo } from "../data/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 color="primary" />
+ </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/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx
index 06f908f9..c5ba64e7 100644
--- a/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx
+++ b/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx
@@ -1,54 +1,54 @@
-import React from 'react';
-import { useHistory } from 'react-router';
-
-import { validateTimelineName, timelineService } from '../data/timeline';
-
-import OperationDialog from '../common/OperationDialog';
-
-interface TimelineCreateDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const TimelineCreateDialog: React.FC<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;
+import React from "react";
+import { useHistory } from "react-router";
+
+import { validateTimelineName, timelineService } from "../data/timeline";
+
+import OperationDialog from "../common/OperationDialog";
+
+interface TimelineCreateDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<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/home/home.sass b/Timeline/ClientApp/src/app/home/home.sass
index 28a2e5f3..f5d6ffc3 100644
--- a/Timeline/ClientApp/src/app/home/home.sass
+++ b/Timeline/ClientApp/src/app/home/home.sass
@@ -1,13 +1,13 @@
-.timeline-board-item
- font-size: 1.1em
- @extend .my-2
- .icon
- height: 1.3em
- @extend .mr-2
-
-.timeline-board
- @extend .cru-card
- @extend .d-flex
- @extend .flex-column
- @extend .p-3
- min-height: 200px
+.timeline-board-item
+ font-size: 1.1em
+ @extend .my-2
+ .icon
+ height: 1.3em
+ @extend .mr-2
+
+.timeline-board
+ @extend .cru-card
+ @extend .d-flex
+ @extend .flex-column
+ @extend .p-3
+ min-height: 200px
diff --git a/Timeline/ClientApp/src/app/http/common.ts b/Timeline/ClientApp/src/app/http/common.ts
index 1f904106..54203d1a 100644
--- a/Timeline/ClientApp/src/app/http/common.ts
+++ b/Timeline/ClientApp/src/app/http/common.ts
@@ -1,161 +1,161 @@
-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'],
- };
-}
+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/mock/common.ts b/Timeline/ClientApp/src/app/http/mock/common.ts
index 3dce8117..787d81bd 100644
--- a/Timeline/ClientApp/src/app/http/mock/common.ts
+++ b/Timeline/ClientApp/src/app/http/mock/common.ts
@@ -1,78 +1,78 @@
-import localforage from 'localforage';
-import { SHA1 } from 'crypto-js';
-
-import { HttpNetworkError } from '../common';
-
-export const mockStorage = localforage.createInstance({
- name: 'mock-backend',
- description: 'Database for mock back end.',
- driver: localforage.INDEXEDDB,
-});
-
-export async function sha1(data: Blob): Promise<string> {
- const s = await new Promise<string>((resolve) => {
- const fileReader = new FileReader();
- fileReader.readAsBinaryString(data);
- fileReader.onload = () => {
- resolve(fileReader.result as string);
- };
- });
-
- return SHA1(s).toString();
-}
-
-const disableNetworkKey = 'mockServer.disableNetwork';
-const networkLatencyKey = 'mockServer.networkLatency';
-
-let disableNetwork: boolean =
- localStorage.getItem(disableNetworkKey) === 'true' ? true : false;
-
-const savedNetworkLatency = localStorage.getItem(networkLatencyKey);
-
-let networkLatency: number | null =
- savedNetworkLatency != null ? Number(savedNetworkLatency) : null;
-
-Object.defineProperty(window, 'disableNetwork', {
- get: () => disableNetwork,
- set: (value) => {
- if (value) {
- disableNetwork = true;
- localStorage.setItem(disableNetworkKey, 'true');
- } else {
- disableNetwork = false;
- localStorage.setItem(disableNetworkKey, 'false');
- }
- },
-});
-
-Object.defineProperty(window, 'networkLatency', {
- get: () => networkLatency,
- set: (value) => {
- if (typeof value === 'number') {
- networkLatency = value;
- localStorage.setItem(networkLatencyKey, value.toString());
- } else if (value == null) {
- networkLatency = null;
- localStorage.removeItem(networkLatencyKey);
- }
- },
-});
-
-export async function mockPrepare(key: string): Promise<void> {
- console.log(`Recieve request: ${key}`);
-
- if (disableNetwork) {
- console.warn('Network is disabled for mock server.');
- throw new HttpNetworkError();
- }
- if (networkLatency != null) {
- await new Promise((resolve) => {
- window.setTimeout(() => {
- resolve();
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- }, networkLatency! * 1000);
- });
- }
-
- await Promise.resolve();
-}
+import localforage from "localforage";
+import { SHA1 } from "crypto-js";
+
+import { HttpNetworkError } from "../common";
+
+export const mockStorage = localforage.createInstance({
+ name: "mock-backend",
+ description: "Database for mock back end.",
+ driver: localforage.INDEXEDDB,
+});
+
+export async function sha1(data: Blob): Promise<string> {
+ const s = await new Promise<string>((resolve) => {
+ const fileReader = new FileReader();
+ fileReader.readAsBinaryString(data);
+ fileReader.onload = () => {
+ resolve(fileReader.result as string);
+ };
+ });
+
+ return SHA1(s).toString();
+}
+
+const disableNetworkKey = "mockServer.disableNetwork";
+const networkLatencyKey = "mockServer.networkLatency";
+
+let disableNetwork: boolean =
+ localStorage.getItem(disableNetworkKey) === "true" ? true : false;
+
+const savedNetworkLatency = localStorage.getItem(networkLatencyKey);
+
+let networkLatency: number | null =
+ savedNetworkLatency != null ? Number(savedNetworkLatency) : null;
+
+Object.defineProperty(window, "disableNetwork", {
+ get: () => disableNetwork,
+ set: (value) => {
+ if (value) {
+ disableNetwork = true;
+ localStorage.setItem(disableNetworkKey, "true");
+ } else {
+ disableNetwork = false;
+ localStorage.setItem(disableNetworkKey, "false");
+ }
+ },
+});
+
+Object.defineProperty(window, "networkLatency", {
+ get: () => networkLatency,
+ set: (value) => {
+ if (typeof value === "number") {
+ networkLatency = value;
+ localStorage.setItem(networkLatencyKey, value.toString());
+ } else if (value == null) {
+ networkLatency = null;
+ localStorage.removeItem(networkLatencyKey);
+ }
+ },
+});
+
+export async function mockPrepare(key: string): Promise<void> {
+ console.log(`Recieve request: ${key}`);
+
+ if (disableNetwork) {
+ console.warn("Network is disabled for mock server.");
+ throw new HttpNetworkError();
+ }
+ if (networkLatency != null) {
+ await new Promise((resolve) => {
+ window.setTimeout(() => {
+ resolve();
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ }, networkLatency! * 1000);
+ });
+ }
+
+ await Promise.resolve();
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/install.ts b/Timeline/ClientApp/src/app/http/mock/install.ts
index 66174d41..17b7cc13 100644
--- a/Timeline/ClientApp/src/app/http/mock/install.ts
+++ b/Timeline/ClientApp/src/app/http/mock/install.ts
@@ -1,11 +1,11 @@
-import { setHttpTokenClient } from '../token';
-import { setHttpUserClient } from '../user';
-import { setHttpTimelineClient } from '../timeline';
-
-import { MockHttpTokenClient } from './token';
-import { MockHttpUserClient } from './user';
-import { MockHttpTimelineClient } from './timeline';
-
-setHttpTokenClient(new MockHttpTokenClient());
-setHttpUserClient(new MockHttpUserClient());
-setHttpTimelineClient(new MockHttpTimelineClient());
+import { setHttpTokenClient } from "../token";
+import { setHttpUserClient } from "../user";
+import { setHttpTimelineClient } from "../timeline";
+
+import { MockHttpTokenClient } from "./token";
+import { MockHttpUserClient } from "./user";
+import { MockHttpTimelineClient } from "./timeline";
+
+setHttpTokenClient(new MockHttpTokenClient());
+setHttpUserClient(new MockHttpUserClient());
+setHttpTimelineClient(new MockHttpTimelineClient());
diff --git a/Timeline/ClientApp/src/app/http/mock/timeline.ts b/Timeline/ClientApp/src/app/http/mock/timeline.ts
index 9434f666..0330b303 100644
--- a/Timeline/ClientApp/src/app/http/mock/timeline.ts
+++ b/Timeline/ClientApp/src/app/http/mock/timeline.ts
@@ -1,658 +1,658 @@
-import { random, without, range } from 'lodash';
-
-import { BlobWithEtag, NotModified } from '../common';
-import {
- IHttpTimelineClient,
- HttpTimelineInfo,
- TimelineVisibility,
- HttpTimelineListQuery,
- HttpTimelineNotExistError,
- HttpTimelinePostRequest,
- HttpTimelineNameConflictError,
- HttpTimelinePatchRequest,
- HttpTimelinePostInfo,
- HttpTimelinePostContent,
- HttpTimelinePostPostRequest,
- HttpTimelinePostNotExistError,
- HttpTimelineGenericPostInfo,
-} from '../timeline';
-import { HttpUser } from '../user';
-
-import { mockStorage, sha1, mockPrepare } from './common';
-import { getUser, MockUserNotExistError, checkToken } from './user';
-
-async function getTimelineNameList(): Promise<string[]> {
- return (await mockStorage.getItem<string[]>('timelines')) ?? [];
-}
-
-async function setTimelineNameList(newOne: string[]): Promise<void> {
- await mockStorage.setItem<string[]>('timelines', newOne);
-}
-
-type TimelinePropertyKey =
- | 'uniqueId'
- | 'lastModified'
- | 'owner'
- | 'description'
- | 'visibility'
- | 'members'
- | 'currentPostId';
-
-function getTimelinePropertyKey(
- name: string,
- property: TimelinePropertyKey
-): string {
- return `timeline.${name}.${property}`;
-}
-
-function getTimelinePropertyValue<T>(
- name: string,
- property: TimelinePropertyKey
-): Promise<T> {
- return mockStorage.getItem<T>(getTimelinePropertyKey(name, property));
-}
-
-function setTimelinePropertyValue<T>(
- name: string,
- property: TimelinePropertyKey,
- value: T
-): Promise<void> {
- return mockStorage
- .setItem<T>(getTimelinePropertyKey(name, property), value)
- .then();
-}
-
-function updateTimelineLastModified(name: string): Promise<void> {
- return setTimelinePropertyValue(
- name,
- 'lastModified',
- new Date().toISOString()
- );
-}
-
-interface HttpTimelineInfoEx extends HttpTimelineInfo {
- memberUsernames: string[];
-}
-
-function createUniqueId(): string {
- const s = 'abcdefghijklmnopqrstuvwxz0123456789';
- let result = '';
- for (let i = 0; i < 16; i++) {
- result += s[random(0, s.length - 1)];
- }
- return result;
-}
-
-class MockTimelineNotExistError extends Error {
- constructor() {
- super('Timeline not exist.');
- }
-}
-
-class MockTimelineAlreadyExistError extends Error {
- constructor() {
- super('Timeline already exist.');
- }
-}
-
-async function getTimelineInfo(name: string): Promise<HttpTimelineInfoEx> {
- let owner: HttpUser;
- if (name.startsWith('@')) {
- const ownerUsername = name.substr(1);
- owner = await getUser(ownerUsername);
- const optionalUniqueId = await getTimelinePropertyValue<string | null>(
- name,
- 'uniqueId'
- );
- if (optionalUniqueId == null) {
- await setTimelineNameList([...(await getTimelineNameList()), name]);
- await setTimelinePropertyValue(name, 'uniqueId', createUniqueId());
- await updateTimelineLastModified(name);
- }
- } else {
- const optionalOwnerUsername = await getTimelinePropertyValue<string | null>(
- name,
- 'owner'
- );
- if (optionalOwnerUsername == null) {
- throw new MockTimelineNotExistError();
- } else {
- owner = await getUser(optionalOwnerUsername);
- }
- }
-
- const memberUsernames =
- (await getTimelinePropertyValue<string[] | null>(name, 'members')) ?? [];
- const members = await Promise.all(
- memberUsernames.map(async (username) => {
- return await getUser(username);
- })
- );
-
- return {
- name,
- uniqueId: await getTimelinePropertyValue<string>(name, 'uniqueId'),
- owner,
- description:
- (await getTimelinePropertyValue<string | null>(name, 'description')) ??
- '',
- visibility:
- (await getTimelinePropertyValue<TimelineVisibility | null>(
- name,
- 'visibility'
- )) ?? 'Register',
- lastModified: new Date(
- await getTimelinePropertyValue<string>(name, 'lastModified')
- ),
- members,
- memberUsernames,
- };
-}
-
-async function createTimeline(name: string, owner: string): Promise<void> {
- const optionalOwnerUsername = await getTimelinePropertyValue<string | null>(
- name,
- 'owner'
- );
- if (optionalOwnerUsername != null) {
- throw new MockTimelineAlreadyExistError();
- }
-
- await setTimelineNameList([...(await getTimelineNameList()), name]);
- await setTimelinePropertyValue(name, 'uniqueId', createUniqueId());
- await setTimelinePropertyValue(name, 'owner', owner);
- await updateTimelineLastModified(name);
-}
-
-type TimelinePostPropertyKey =
- | 'type'
- | 'data'
- | 'etag'
- | 'author'
- | 'time'
- | 'lastUpdated';
-
-function getTimelinePostPropertyKey(
- timelineName: string,
- id: number,
- propertyKey: TimelinePostPropertyKey
-): string {
- return `timeline.${timelineName}.posts.${id}.${propertyKey}`;
-}
-
-function getTimelinePostPropertyValue<T>(
- timelineName: string,
- id: number,
- propertyKey: TimelinePostPropertyKey
-): Promise<T> {
- return mockStorage.getItem<T>(
- getTimelinePostPropertyKey(timelineName, id, propertyKey)
- );
-}
-
-function setTimelinePostPropertyValue<T>(
- timelineName: string,
- id: number,
- propertyKey: TimelinePostPropertyKey,
- value: T
-): Promise<T> {
- return mockStorage.setItem(
- getTimelinePostPropertyKey(timelineName, id, propertyKey),
- value
- );
-}
-
-function removeTimelinePostProperty(
- timelineName: string,
- id: number,
- propertyKey: TimelinePostPropertyKey
-): Promise<void> {
- return mockStorage.removeItem(
- getTimelinePostPropertyKey(timelineName, id, propertyKey)
- );
-}
-
-async function getTimelinePostInfo(
- timelineName: string,
- id: number
-): Promise<HttpTimelineGenericPostInfo> {
- const currentPostId = await getTimelinePropertyValue<number | null>(
- timelineName,
- 'currentPostId'
- );
- if (currentPostId == null || id > currentPostId) {
- throw new HttpTimelinePostNotExistError();
- }
-
- const type = await getTimelinePostPropertyValue<string | null>(
- timelineName,
- id,
- 'type'
- );
-
- if (type == null) {
- return {
- id,
- author: await getUser(
- await getTimelinePostPropertyValue<string>(timelineName, id, 'author')
- ),
- time: new Date(
- await getTimelinePostPropertyValue<string>(timelineName, id, 'time')
- ),
- lastUpdated: new Date(
- await getTimelinePostPropertyValue<string>(
- timelineName,
- id,
- 'lastUpdated'
- )
- ),
- deleted: true,
- };
- } else {
- let content: HttpTimelinePostContent;
- if (type === 'text') {
- content = {
- type: 'text',
- text: await getTimelinePostPropertyValue(timelineName, id, 'data'),
- };
- } else {
- content = {
- type: 'image',
- };
- }
-
- return {
- id,
- author: await getUser(
- await getTimelinePostPropertyValue<string>(timelineName, id, 'author')
- ),
- time: new Date(
- await getTimelinePostPropertyValue<string>(timelineName, id, 'time')
- ),
- lastUpdated: new Date(
- await getTimelinePostPropertyValue<string>(
- timelineName,
- id,
- 'lastUpdated'
- )
- ),
- content,
- deleted: false,
- };
- }
-}
-
-export class MockHttpTimelineClient implements IHttpTimelineClient {
- async listTimeline(
- query: HttpTimelineListQuery
- ): Promise<HttpTimelineInfo[]> {
- await mockPrepare('timeline.list');
- return (
- await Promise.all(
- (await getTimelineNameList()).map((name) => getTimelineInfo(name))
- )
- ).filter((timeline) => {
- if (
- query.visibility != null &&
- query.visibility !== timeline.visibility
- ) {
- return false;
- }
- if (query.relate != null) {
- if (query.relateType === 'own') {
- if (timeline.owner.username !== query.relate) {
- return false;
- }
- } else if (query.relateType === 'join') {
- if (!timeline.memberUsernames.includes(query.relate)) {
- return false;
- }
- } else if (
- timeline.owner.username !== query.relate &&
- !timeline.memberUsernames.includes(query.relate)
- ) {
- return false;
- }
- }
- return true;
- });
- }
-
- getTimeline(timelineName: string): Promise<HttpTimelineInfo>;
- getTimeline(
- timelineName: string,
- query: {
- checkUniqueId?: string;
- }
- ): Promise<HttpTimelineInfo>;
- getTimeline(
- timelineName: string,
- query: {
- checkUniqueId?: string;
- ifModifiedSince: Date;
- }
- ): Promise<HttpTimelineInfo | NotModified>;
- async getTimeline(
- timelineName: string,
- query?: {
- checkUniqueId?: string;
- ifModifiedSince?: Date;
- }
- ): Promise<HttpTimelineInfo | NotModified> {
- await mockPrepare('timeline.get');
- try {
- const timeline = await getTimelineInfo(timelineName);
- if (query != null && query.ifModifiedSince != null) {
- if (timeline.lastModified >= query.ifModifiedSince) {
- return timeline;
- } else {
- if (
- query.checkUniqueId != null &&
- timeline.uniqueId != query.checkUniqueId
- ) {
- return timeline;
- } else {
- return new NotModified();
- }
- }
- }
-
- return timeline;
- } catch (e) {
- if (
- e instanceof MockTimelineNotExistError ||
- e instanceof MockUserNotExistError
- ) {
- throw new HttpTimelineNotExistError();
- }
- throw e;
- }
- }
-
- async postTimeline(
- req: HttpTimelinePostRequest,
- token: string
- ): Promise<HttpTimelineInfo> {
- await mockPrepare('timeline.post');
- const user = checkToken(token);
- try {
- await createTimeline(req.name, user);
- } catch (e) {
- if (e instanceof MockTimelineAlreadyExistError) {
- throw new HttpTimelineNameConflictError();
- }
- throw e;
- }
- return await getTimelineInfo(req.name);
- }
-
- async patchTimeline(
- timelineName: string,
- req: HttpTimelinePatchRequest,
- _token: string
- ): Promise<HttpTimelineInfo> {
- await mockPrepare('timeline.patch');
- let modified = false;
- if (req.description != null) {
- modified = true;
- await setTimelinePropertyValue(
- timelineName,
- 'description',
- req.description
- );
- }
- if (req.visibility != null) {
- modified = true;
- await setTimelinePropertyValue(
- timelineName,
- 'visibility',
- req.visibility
- );
- }
- if (modified) {
- await updateTimelineLastModified(timelineName);
- }
- return await getTimelineInfo(timelineName);
- }
-
- async deleteTimeline(timelineName: string, _token: string): Promise<void> {
- await mockPrepare('timeline.delete');
- await setTimelineNameList(
- without(await getTimelineNameList(), timelineName)
- );
- await mockStorage.removeItem(
- getTimelinePropertyKey(timelineName, 'uniqueId')
- );
-
- // TODO: remove other things
- }
-
- async memberPut(
- timelineName: string,
- username: string,
- _token: string
- ): Promise<void> {
- await mockPrepare('timeline.member.put');
- const oldMembers =
- (await getTimelinePropertyValue<string[] | null>(
- timelineName,
- 'members'
- )) ?? [];
- if (!oldMembers.includes(username)) {
- await setTimelinePropertyValue(timelineName, 'members', [
- ...oldMembers,
- username,
- ]);
- await updateTimelineLastModified(timelineName);
- }
- }
-
- async memberDelete(
- timelineName: string,
- username: string,
- _token: string
- ): Promise<void> {
- await mockPrepare('timeline.member.delete');
- const oldMembers =
- (await getTimelinePropertyValue<string[] | null>(
- timelineName,
- 'members'
- )) ?? [];
- if (oldMembers.includes(username)) {
- await setTimelinePropertyValue(
- timelineName,
- 'members',
- without(oldMembers, username)
- );
- await updateTimelineLastModified(timelineName);
- }
- }
-
- 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[]>;
- async listPost(
- timelineName: string,
- _token?: string,
- query?: {
- modifiedSince?: Date;
- includeDeleted?: boolean;
- }
- ): Promise<HttpTimelineGenericPostInfo[]> {
- await mockPrepare('timeline.post.list');
- // TODO: Permission check.
-
- const currentPostId = await getTimelinePropertyValue<number | null>(
- timelineName,
- 'currentPostId'
- );
-
- return (
- await Promise.all(
- range(1, currentPostId == null ? 1 : currentPostId + 1).map(
- async (id) => {
- return await getTimelinePostInfo(timelineName, id);
- }
- )
- )
- )
- .filter((post) => {
- if (query?.includeDeleted !== true && post.deleted) {
- return false;
- }
- return true;
- })
- .filter((post) => {
- if (query?.modifiedSince != null) {
- return post.lastUpdated >= query.modifiedSince;
- }
- return true;
- });
- }
-
- getPostData(
- timelineName: string,
- postId: number,
- token: string
- ): Promise<BlobWithEtag>;
- async getPostData(
- timelineName: string,
- postId: number,
- _token?: string,
- etag?: string
- ): Promise<BlobWithEtag | NotModified> {
- await mockPrepare('timeline.post.data.get');
- // TODO: Permission check.
-
- const optionalSavedEtag = await getTimelinePostPropertyValue<string>(
- timelineName,
- postId,
- 'etag'
- );
-
- if (optionalSavedEtag == null) {
- const optionalType = await getTimelinePostPropertyValue<string>(
- timelineName,
- postId,
- 'type'
- );
-
- if (optionalType != null) {
- throw new Error('Post of this type has no data.');
- } else {
- throw new HttpTimelinePostNotExistError();
- }
- }
-
- if (etag === optionalSavedEtag) {
- return new NotModified();
- }
-
- return {
- data: await getTimelinePostPropertyValue<Blob>(
- timelineName,
- postId,
- 'data'
- ),
- etag: optionalSavedEtag,
- };
- }
-
- async postPost(
- timelineName: string,
- req: HttpTimelinePostPostRequest,
- token: string
- ): Promise<HttpTimelinePostInfo> {
- await mockPrepare('timeline.post.post');
- const user = checkToken(token);
-
- const savedId = await getTimelinePropertyValue<number | null>(
- timelineName,
- 'currentPostId'
- );
- const id = savedId ? savedId + 1 : 1;
- await setTimelinePropertyValue(timelineName, 'currentPostId', id);
-
- await setTimelinePostPropertyValue(timelineName, id, 'author', user);
-
- const currentTimeString = new Date().toISOString();
- await setTimelinePostPropertyValue(
- timelineName,
- id,
- 'lastUpdated',
- currentTimeString
- );
-
- await setTimelinePostPropertyValue(
- timelineName,
- id,
- 'time',
- req.time != null ? req.time.toISOString() : currentTimeString
- );
-
- const { content } = req;
- if (content.type === 'text') {
- await setTimelinePostPropertyValue(timelineName, id, 'type', 'text');
- await setTimelinePostPropertyValue(
- timelineName,
- id,
- 'data',
- content.text
- );
- } else {
- await setTimelinePostPropertyValue(timelineName, id, 'type', 'image');
- await setTimelinePostPropertyValue(
- timelineName,
- id,
- 'data',
- content.data
- );
- await setTimelinePostPropertyValue(
- timelineName,
- id,
- 'etag',
- await sha1(content.data)
- );
- }
-
- return (await getTimelinePostInfo(
- timelineName,
- id
- )) as HttpTimelinePostInfo;
- }
-
- async deletePost(
- timelineName: string,
- postId: number,
- _token: string
- ): Promise<void> {
- await mockPrepare('timeline.post.delete');
- // TODO: permission check
- await removeTimelinePostProperty(timelineName, postId, 'type');
- await removeTimelinePostProperty(timelineName, postId, 'data');
- await removeTimelinePostProperty(timelineName, postId, 'etag');
- await setTimelinePostPropertyValue(
- timelineName,
- postId,
- 'lastUpdated',
- new Date().toISOString()
- );
- }
-}
+import { random, without, range } from "lodash";
+
+import { BlobWithEtag, NotModified } from "../common";
+import {
+ IHttpTimelineClient,
+ HttpTimelineInfo,
+ TimelineVisibility,
+ HttpTimelineListQuery,
+ HttpTimelineNotExistError,
+ HttpTimelinePostRequest,
+ HttpTimelineNameConflictError,
+ HttpTimelinePatchRequest,
+ HttpTimelinePostInfo,
+ HttpTimelinePostContent,
+ HttpTimelinePostPostRequest,
+ HttpTimelinePostNotExistError,
+ HttpTimelineGenericPostInfo,
+} from "../timeline";
+import { HttpUser } from "../user";
+
+import { mockStorage, sha1, mockPrepare } from "./common";
+import { getUser, MockUserNotExistError, checkToken } from "./user";
+
+async function getTimelineNameList(): Promise<string[]> {
+ return (await mockStorage.getItem<string[]>("timelines")) ?? [];
+}
+
+async function setTimelineNameList(newOne: string[]): Promise<void> {
+ await mockStorage.setItem<string[]>("timelines", newOne);
+}
+
+type TimelinePropertyKey =
+ | "uniqueId"
+ | "lastModified"
+ | "owner"
+ | "description"
+ | "visibility"
+ | "members"
+ | "currentPostId";
+
+function getTimelinePropertyKey(
+ name: string,
+ property: TimelinePropertyKey
+): string {
+ return `timeline.${name}.${property}`;
+}
+
+function getTimelinePropertyValue<T>(
+ name: string,
+ property: TimelinePropertyKey
+): Promise<T> {
+ return mockStorage.getItem<T>(getTimelinePropertyKey(name, property));
+}
+
+function setTimelinePropertyValue<T>(
+ name: string,
+ property: TimelinePropertyKey,
+ value: T
+): Promise<void> {
+ return mockStorage
+ .setItem<T>(getTimelinePropertyKey(name, property), value)
+ .then();
+}
+
+function updateTimelineLastModified(name: string): Promise<void> {
+ return setTimelinePropertyValue(
+ name,
+ "lastModified",
+ new Date().toISOString()
+ );
+}
+
+interface HttpTimelineInfoEx extends HttpTimelineInfo {
+ memberUsernames: string[];
+}
+
+function createUniqueId(): string {
+ const s = "abcdefghijklmnopqrstuvwxz0123456789";
+ let result = "";
+ for (let i = 0; i < 16; i++) {
+ result += s[random(0, s.length - 1)];
+ }
+ return result;
+}
+
+class MockTimelineNotExistError extends Error {
+ constructor() {
+ super("Timeline not exist.");
+ }
+}
+
+class MockTimelineAlreadyExistError extends Error {
+ constructor() {
+ super("Timeline already exist.");
+ }
+}
+
+async function getTimelineInfo(name: string): Promise<HttpTimelineInfoEx> {
+ let owner: HttpUser;
+ if (name.startsWith("@")) {
+ const ownerUsername = name.substr(1);
+ owner = await getUser(ownerUsername);
+ const optionalUniqueId = await getTimelinePropertyValue<string | null>(
+ name,
+ "uniqueId"
+ );
+ if (optionalUniqueId == null) {
+ await setTimelineNameList([...(await getTimelineNameList()), name]);
+ await setTimelinePropertyValue(name, "uniqueId", createUniqueId());
+ await updateTimelineLastModified(name);
+ }
+ } else {
+ const optionalOwnerUsername = await getTimelinePropertyValue<string | null>(
+ name,
+ "owner"
+ );
+ if (optionalOwnerUsername == null) {
+ throw new MockTimelineNotExistError();
+ } else {
+ owner = await getUser(optionalOwnerUsername);
+ }
+ }
+
+ const memberUsernames =
+ (await getTimelinePropertyValue<string[] | null>(name, "members")) ?? [];
+ const members = await Promise.all(
+ memberUsernames.map(async (username) => {
+ return await getUser(username);
+ })
+ );
+
+ return {
+ name,
+ uniqueId: await getTimelinePropertyValue<string>(name, "uniqueId"),
+ owner,
+ description:
+ (await getTimelinePropertyValue<string | null>(name, "description")) ??
+ "",
+ visibility:
+ (await getTimelinePropertyValue<TimelineVisibility | null>(
+ name,
+ "visibility"
+ )) ?? "Register",
+ lastModified: new Date(
+ await getTimelinePropertyValue<string>(name, "lastModified")
+ ),
+ members,
+ memberUsernames,
+ };
+}
+
+async function createTimeline(name: string, owner: string): Promise<void> {
+ const optionalOwnerUsername = await getTimelinePropertyValue<string | null>(
+ name,
+ "owner"
+ );
+ if (optionalOwnerUsername != null) {
+ throw new MockTimelineAlreadyExistError();
+ }
+
+ await setTimelineNameList([...(await getTimelineNameList()), name]);
+ await setTimelinePropertyValue(name, "uniqueId", createUniqueId());
+ await setTimelinePropertyValue(name, "owner", owner);
+ await updateTimelineLastModified(name);
+}
+
+type TimelinePostPropertyKey =
+ | "type"
+ | "data"
+ | "etag"
+ | "author"
+ | "time"
+ | "lastUpdated";
+
+function getTimelinePostPropertyKey(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey
+): string {
+ return `timeline.${timelineName}.posts.${id}.${propertyKey}`;
+}
+
+function getTimelinePostPropertyValue<T>(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey
+): Promise<T> {
+ return mockStorage.getItem<T>(
+ getTimelinePostPropertyKey(timelineName, id, propertyKey)
+ );
+}
+
+function setTimelinePostPropertyValue<T>(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey,
+ value: T
+): Promise<T> {
+ return mockStorage.setItem(
+ getTimelinePostPropertyKey(timelineName, id, propertyKey),
+ value
+ );
+}
+
+function removeTimelinePostProperty(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey
+): Promise<void> {
+ return mockStorage.removeItem(
+ getTimelinePostPropertyKey(timelineName, id, propertyKey)
+ );
+}
+
+async function getTimelinePostInfo(
+ timelineName: string,
+ id: number
+): Promise<HttpTimelineGenericPostInfo> {
+ const currentPostId = await getTimelinePropertyValue<number | null>(
+ timelineName,
+ "currentPostId"
+ );
+ if (currentPostId == null || id > currentPostId) {
+ throw new HttpTimelinePostNotExistError();
+ }
+
+ const type = await getTimelinePostPropertyValue<string | null>(
+ timelineName,
+ id,
+ "type"
+ );
+
+ if (type == null) {
+ return {
+ id,
+ author: await getUser(
+ await getTimelinePostPropertyValue<string>(timelineName, id, "author")
+ ),
+ time: new Date(
+ await getTimelinePostPropertyValue<string>(timelineName, id, "time")
+ ),
+ lastUpdated: new Date(
+ await getTimelinePostPropertyValue<string>(
+ timelineName,
+ id,
+ "lastUpdated"
+ )
+ ),
+ deleted: true,
+ };
+ } else {
+ let content: HttpTimelinePostContent;
+ if (type === "text") {
+ content = {
+ type: "text",
+ text: await getTimelinePostPropertyValue(timelineName, id, "data"),
+ };
+ } else {
+ content = {
+ type: "image",
+ };
+ }
+
+ return {
+ id,
+ author: await getUser(
+ await getTimelinePostPropertyValue<string>(timelineName, id, "author")
+ ),
+ time: new Date(
+ await getTimelinePostPropertyValue<string>(timelineName, id, "time")
+ ),
+ lastUpdated: new Date(
+ await getTimelinePostPropertyValue<string>(
+ timelineName,
+ id,
+ "lastUpdated"
+ )
+ ),
+ content,
+ deleted: false,
+ };
+ }
+}
+
+export class MockHttpTimelineClient implements IHttpTimelineClient {
+ async listTimeline(
+ query: HttpTimelineListQuery
+ ): Promise<HttpTimelineInfo[]> {
+ await mockPrepare("timeline.list");
+ return (
+ await Promise.all(
+ (await getTimelineNameList()).map((name) => getTimelineInfo(name))
+ )
+ ).filter((timeline) => {
+ if (
+ query.visibility != null &&
+ query.visibility !== timeline.visibility
+ ) {
+ return false;
+ }
+ if (query.relate != null) {
+ if (query.relateType === "own") {
+ if (timeline.owner.username !== query.relate) {
+ return false;
+ }
+ } else if (query.relateType === "join") {
+ if (!timeline.memberUsernames.includes(query.relate)) {
+ return false;
+ }
+ } else if (
+ timeline.owner.username !== query.relate &&
+ !timeline.memberUsernames.includes(query.relate)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ getTimeline(timelineName: string): Promise<HttpTimelineInfo>;
+ getTimeline(
+ timelineName: string,
+ query: {
+ checkUniqueId?: string;
+ }
+ ): Promise<HttpTimelineInfo>;
+ getTimeline(
+ timelineName: string,
+ query: {
+ checkUniqueId?: string;
+ ifModifiedSince: Date;
+ }
+ ): Promise<HttpTimelineInfo | NotModified>;
+ async getTimeline(
+ timelineName: string,
+ query?: {
+ checkUniqueId?: string;
+ ifModifiedSince?: Date;
+ }
+ ): Promise<HttpTimelineInfo | NotModified> {
+ await mockPrepare("timeline.get");
+ try {
+ const timeline = await getTimelineInfo(timelineName);
+ if (query != null && query.ifModifiedSince != null) {
+ if (timeline.lastModified >= query.ifModifiedSince) {
+ return timeline;
+ } else {
+ if (
+ query.checkUniqueId != null &&
+ timeline.uniqueId != query.checkUniqueId
+ ) {
+ return timeline;
+ } else {
+ return new NotModified();
+ }
+ }
+ }
+
+ return timeline;
+ } catch (e) {
+ if (
+ e instanceof MockTimelineNotExistError ||
+ e instanceof MockUserNotExistError
+ ) {
+ throw new HttpTimelineNotExistError();
+ }
+ throw e;
+ }
+ }
+
+ async postTimeline(
+ req: HttpTimelinePostRequest,
+ token: string
+ ): Promise<HttpTimelineInfo> {
+ await mockPrepare("timeline.post");
+ const user = checkToken(token);
+ try {
+ await createTimeline(req.name, user);
+ } catch (e) {
+ if (e instanceof MockTimelineAlreadyExistError) {
+ throw new HttpTimelineNameConflictError();
+ }
+ throw e;
+ }
+ return await getTimelineInfo(req.name);
+ }
+
+ async patchTimeline(
+ timelineName: string,
+ req: HttpTimelinePatchRequest,
+ _token: string
+ ): Promise<HttpTimelineInfo> {
+ await mockPrepare("timeline.patch");
+ let modified = false;
+ if (req.description != null) {
+ modified = true;
+ await setTimelinePropertyValue(
+ timelineName,
+ "description",
+ req.description
+ );
+ }
+ if (req.visibility != null) {
+ modified = true;
+ await setTimelinePropertyValue(
+ timelineName,
+ "visibility",
+ req.visibility
+ );
+ }
+ if (modified) {
+ await updateTimelineLastModified(timelineName);
+ }
+ return await getTimelineInfo(timelineName);
+ }
+
+ async deleteTimeline(timelineName: string, _token: string): Promise<void> {
+ await mockPrepare("timeline.delete");
+ await setTimelineNameList(
+ without(await getTimelineNameList(), timelineName)
+ );
+ await mockStorage.removeItem(
+ getTimelinePropertyKey(timelineName, "uniqueId")
+ );
+
+ // TODO: remove other things
+ }
+
+ async memberPut(
+ timelineName: string,
+ username: string,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare("timeline.member.put");
+ const oldMembers =
+ (await getTimelinePropertyValue<string[] | null>(
+ timelineName,
+ "members"
+ )) ?? [];
+ if (!oldMembers.includes(username)) {
+ await setTimelinePropertyValue(timelineName, "members", [
+ ...oldMembers,
+ username,
+ ]);
+ await updateTimelineLastModified(timelineName);
+ }
+ }
+
+ async memberDelete(
+ timelineName: string,
+ username: string,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare("timeline.member.delete");
+ const oldMembers =
+ (await getTimelinePropertyValue<string[] | null>(
+ timelineName,
+ "members"
+ )) ?? [];
+ if (oldMembers.includes(username)) {
+ await setTimelinePropertyValue(
+ timelineName,
+ "members",
+ without(oldMembers, username)
+ );
+ await updateTimelineLastModified(timelineName);
+ }
+ }
+
+ 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[]>;
+ async listPost(
+ timelineName: string,
+ _token?: string,
+ query?: {
+ modifiedSince?: Date;
+ includeDeleted?: boolean;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]> {
+ await mockPrepare("timeline.post.list");
+ // TODO: Permission check.
+
+ const currentPostId = await getTimelinePropertyValue<number | null>(
+ timelineName,
+ "currentPostId"
+ );
+
+ return (
+ await Promise.all(
+ range(1, currentPostId == null ? 1 : currentPostId + 1).map(
+ async (id) => {
+ return await getTimelinePostInfo(timelineName, id);
+ }
+ )
+ )
+ )
+ .filter((post) => {
+ if (query?.includeDeleted !== true && post.deleted) {
+ return false;
+ }
+ return true;
+ })
+ .filter((post) => {
+ if (query?.modifiedSince != null) {
+ return post.lastUpdated >= query.modifiedSince;
+ }
+ return true;
+ });
+ }
+
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<BlobWithEtag>;
+ async getPostData(
+ timelineName: string,
+ postId: number,
+ _token?: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ await mockPrepare("timeline.post.data.get");
+ // TODO: Permission check.
+
+ const optionalSavedEtag = await getTimelinePostPropertyValue<string>(
+ timelineName,
+ postId,
+ "etag"
+ );
+
+ if (optionalSavedEtag == null) {
+ const optionalType = await getTimelinePostPropertyValue<string>(
+ timelineName,
+ postId,
+ "type"
+ );
+
+ if (optionalType != null) {
+ throw new Error("Post of this type has no data.");
+ } else {
+ throw new HttpTimelinePostNotExistError();
+ }
+ }
+
+ if (etag === optionalSavedEtag) {
+ return new NotModified();
+ }
+
+ return {
+ data: await getTimelinePostPropertyValue<Blob>(
+ timelineName,
+ postId,
+ "data"
+ ),
+ etag: optionalSavedEtag,
+ };
+ }
+
+ async postPost(
+ timelineName: string,
+ req: HttpTimelinePostPostRequest,
+ token: string
+ ): Promise<HttpTimelinePostInfo> {
+ await mockPrepare("timeline.post.post");
+ const user = checkToken(token);
+
+ const savedId = await getTimelinePropertyValue<number | null>(
+ timelineName,
+ "currentPostId"
+ );
+ const id = savedId ? savedId + 1 : 1;
+ await setTimelinePropertyValue(timelineName, "currentPostId", id);
+
+ await setTimelinePostPropertyValue(timelineName, id, "author", user);
+
+ const currentTimeString = new Date().toISOString();
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ "lastUpdated",
+ currentTimeString
+ );
+
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ "time",
+ req.time != null ? req.time.toISOString() : currentTimeString
+ );
+
+ const { content } = req;
+ if (content.type === "text") {
+ await setTimelinePostPropertyValue(timelineName, id, "type", "text");
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ "data",
+ content.text
+ );
+ } else {
+ await setTimelinePostPropertyValue(timelineName, id, "type", "image");
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ "data",
+ content.data
+ );
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ "etag",
+ await sha1(content.data)
+ );
+ }
+
+ return (await getTimelinePostInfo(
+ timelineName,
+ id
+ )) as HttpTimelinePostInfo;
+ }
+
+ async deletePost(
+ timelineName: string,
+ postId: number,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare("timeline.post.delete");
+ // TODO: permission check
+ await removeTimelinePostProperty(timelineName, postId, "type");
+ await removeTimelinePostProperty(timelineName, postId, "data");
+ await removeTimelinePostProperty(timelineName, postId, "etag");
+ await setTimelinePostPropertyValue(
+ timelineName,
+ postId,
+ "lastUpdated",
+ new Date().toISOString()
+ );
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/token.ts b/Timeline/ClientApp/src/app/http/mock/token.ts
index 6929be2a..0a350894 100644
--- a/Timeline/ClientApp/src/app/http/mock/token.ts
+++ b/Timeline/ClientApp/src/app/http/mock/token.ts
@@ -1,53 +1,53 @@
-import { AxiosError } from 'axios';
-
-import {
- IHttpTokenClient,
- HttpCreateTokenRequest,
- HttpCreateTokenResponse,
- HttpVerifyTokenRequest,
- HttpVerifyTokenResponse,
-} from '../token';
-
-import { mockPrepare } from './common';
-import { getUser, MockUserNotExistError, checkToken } from './user';
-
-export class MockHttpTokenClient implements IHttpTokenClient {
- // TODO: Mock bad credentials error.
- async create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse> {
- await mockPrepare('token.create');
- try {
- const user = await getUser(req.username);
- return {
- user,
- token: `token-${req.username}`,
- };
- } catch (e) {
- if (e instanceof MockUserNotExistError) {
- throw {
- isAxiosError: true,
- response: {
- status: 400,
- },
- } as Partial<AxiosError>;
- }
- throw e;
- }
- }
-
- async verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> {
- await mockPrepare('token.verify');
- try {
- const user = await getUser(checkToken(req.token));
- return {
- user,
- };
- } catch (e) {
- throw {
- isAxiosError: true,
- response: {
- status: 400,
- },
- } as Partial<AxiosError>;
- }
- }
-}
+import { AxiosError } from "axios";
+
+import {
+ IHttpTokenClient,
+ HttpCreateTokenRequest,
+ HttpCreateTokenResponse,
+ HttpVerifyTokenRequest,
+ HttpVerifyTokenResponse,
+} from "../token";
+
+import { mockPrepare } from "./common";
+import { getUser, MockUserNotExistError, checkToken } from "./user";
+
+export class MockHttpTokenClient implements IHttpTokenClient {
+ // TODO: Mock bad credentials error.
+ async create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse> {
+ await mockPrepare("token.create");
+ try {
+ const user = await getUser(req.username);
+ return {
+ user,
+ token: `token-${req.username}`,
+ };
+ } catch (e) {
+ if (e instanceof MockUserNotExistError) {
+ throw {
+ isAxiosError: true,
+ response: {
+ status: 400,
+ },
+ } as Partial<AxiosError>;
+ }
+ throw e;
+ }
+ }
+
+ async verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> {
+ await mockPrepare("token.verify");
+ try {
+ const user = await getUser(checkToken(req.token));
+ return {
+ user,
+ };
+ } catch (e) {
+ throw {
+ isAxiosError: true,
+ response: {
+ status: 400,
+ },
+ } as Partial<AxiosError>;
+ }
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/user.ts b/Timeline/ClientApp/src/app/http/mock/user.ts
index 76a35f17..1ecd7365 100644
--- a/Timeline/ClientApp/src/app/http/mock/user.ts
+++ b/Timeline/ClientApp/src/app/http/mock/user.ts
@@ -1,140 +1,140 @@
-import axios from 'axios';
-
-import { BlobWithEtag, NotModified } from '../common';
-import {
- IHttpUserClient,
- HttpUser,
- HttpUserNotExistError,
- HttpUserPatchRequest,
- HttpChangePasswordRequest,
-} from '../user';
-
-import { mockStorage, sha1, mockPrepare } from './common';
-
-import defaultAvatarUrl from './default-avatar.png';
-
-let _defaultAvatar: BlobWithEtag | undefined = undefined;
-
-async function getDefaultAvatar(): Promise<BlobWithEtag> {
- if (_defaultAvatar == null) {
- const blob = (
- await axios.get<Blob>(defaultAvatarUrl, {
- responseType: 'blob',
- })
- ).data;
- const etag = await sha1(blob);
- _defaultAvatar = {
- data: blob,
- etag,
- };
- }
- return _defaultAvatar;
-}
-
-export class MockTokenError extends Error {
- constructor() {
- super('Token bad format.');
- }
-}
-
-export class MockUserNotExistError extends Error {
- constructor() {
- super('Only two user "user" and "admin".');
- }
-}
-
-export function checkUsername(
- username: string
-): asserts username is 'user' | 'admin' {
- if (!['user', 'admin'].includes(username)) throw new MockUserNotExistError();
-}
-
-export function checkToken(token: string): string {
- if (!token.startsWith('token-')) {
- throw new MockTokenError();
- }
- return token.substr(6);
-}
-
-const uniqueIdMap = {
- user: 'e4c80127d092d9b2fc19c5e04612d4c0',
- admin: '5640fa45435f9a55077b9f77c42a77bb',
-};
-
-export async function getUser(
- username: 'user' | 'admin' | string
-): Promise<HttpUser> {
- checkUsername(username);
- const savedNickname = await mockStorage.getItem<string>(
- `user.${username}.nickname`
- );
- return {
- uniqueId: uniqueIdMap[username],
- username: username,
- nickname:
- savedNickname == null || savedNickname === '' ? username : savedNickname,
- administrator: username === 'admin',
- };
-}
-
-export class MockHttpUserClient implements IHttpUserClient {
- async get(username: string): Promise<HttpUser> {
- await mockPrepare('user.get');
- return await getUser(username).catch((e) => {
- if (e instanceof MockUserNotExistError) {
- throw new HttpUserNotExistError();
- } else {
- throw e;
- }
- });
- }
-
- async patch(
- username: string,
- req: HttpUserPatchRequest,
- _token: string
- ): Promise<HttpUser> {
- await mockPrepare('user.patch');
- if (req.nickname != null) {
- await mockStorage.setItem(`user.${username}.nickname`, req.nickname);
- }
- return await getUser(username);
- }
-
- getAvatar(username: string): Promise<BlobWithEtag>;
- async getAvatar(
- username: string,
- etag?: string
- ): Promise<BlobWithEtag | NotModified> {
- await mockPrepare('user.avatar.get');
-
- const savedEtag = await mockStorage.getItem(`user.${username}.avatar.etag`);
- if (savedEtag == null) {
- return await getDefaultAvatar();
- }
-
- if (savedEtag === etag) {
- return new NotModified();
- }
-
- return {
- data: await mockStorage.getItem<Blob>(`user.${username}.avatar.data`),
- etag: await mockStorage.getItem<string>(`user.${username}.avatar.etag`),
- };
- }
-
- async putAvatar(username: string, data: Blob, _token: string): Promise<void> {
- await mockPrepare('user.avatar.put');
- const etag = await sha1(data);
- await mockStorage.setItem<Blob>(`user.${username}.avatar.data`, data);
- await mockStorage.setItem<string>(`user.${username}.avatar.etag`, etag);
- }
-
- async changePassword(
- _req: HttpChangePasswordRequest,
- _token: string
- ): Promise<void> {
- await mockPrepare('userop.changepassowrd');
- throw new Error('Not Implemented.');
- }
-}
+import axios from "axios";
+
+import { BlobWithEtag, NotModified } from "../common";
+import {
+ IHttpUserClient,
+ HttpUser,
+ HttpUserNotExistError,
+ HttpUserPatchRequest,
+ HttpChangePasswordRequest,
+} from "../user";
+
+import { mockStorage, sha1, mockPrepare } from "./common";
+
+import defaultAvatarUrl from "./default-avatar.png";
+
+let _defaultAvatar: BlobWithEtag | undefined = undefined;
+
+async function getDefaultAvatar(): Promise<BlobWithEtag> {
+ if (_defaultAvatar == null) {
+ const blob = (
+ await axios.get<Blob>(defaultAvatarUrl, {
+ responseType: "blob",
+ })
+ ).data;
+ const etag = await sha1(blob);
+ _defaultAvatar = {
+ data: blob,
+ etag,
+ };
+ }
+ return _defaultAvatar;
+}
+
+export class MockTokenError extends Error {
+ constructor() {
+ super("Token bad format.");
+ }
+}
+
+export class MockUserNotExistError extends Error {
+ constructor() {
+ super('Only two user "user" and "admin".');
+ }
+}
+
+export function checkUsername(
+ username: string
+): asserts username is "user" | "admin" {
+ if (!["user", "admin"].includes(username)) throw new MockUserNotExistError();
+}
+
+export function checkToken(token: string): string {
+ if (!token.startsWith("token-")) {
+ throw new MockTokenError();
+ }
+ return token.substr(6);
+}
+
+const uniqueIdMap = {
+ user: "e4c80127d092d9b2fc19c5e04612d4c0",
+ admin: "5640fa45435f9a55077b9f77c42a77bb",
+};
+
+export async function getUser(
+ username: "user" | "admin" | string
+): Promise<HttpUser> {
+ checkUsername(username);
+ const savedNickname = await mockStorage.getItem<string>(
+ `user.${username}.nickname`
+ );
+ return {
+ uniqueId: uniqueIdMap[username],
+ username: username,
+ nickname:
+ savedNickname == null || savedNickname === "" ? username : savedNickname,
+ administrator: username === "admin",
+ };
+}
+
+export class MockHttpUserClient implements IHttpUserClient {
+ async get(username: string): Promise<HttpUser> {
+ await mockPrepare("user.get");
+ return await getUser(username).catch((e) => {
+ if (e instanceof MockUserNotExistError) {
+ throw new HttpUserNotExistError();
+ } else {
+ throw e;
+ }
+ });
+ }
+
+ async patch(
+ username: string,
+ req: HttpUserPatchRequest,
+ _token: string
+ ): Promise<HttpUser> {
+ await mockPrepare("user.patch");
+ if (req.nickname != null) {
+ await mockStorage.setItem(`user.${username}.nickname`, req.nickname);
+ }
+ return await getUser(username);
+ }
+
+ getAvatar(username: string): Promise<BlobWithEtag>;
+ async getAvatar(
+ username: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ await mockPrepare("user.avatar.get");
+
+ const savedEtag = await mockStorage.getItem(`user.${username}.avatar.etag`);
+ if (savedEtag == null) {
+ return await getDefaultAvatar();
+ }
+
+ if (savedEtag === etag) {
+ return new NotModified();
+ }
+
+ return {
+ data: await mockStorage.getItem<Blob>(`user.${username}.avatar.data`),
+ etag: await mockStorage.getItem<string>(`user.${username}.avatar.etag`),
+ };
+ }
+
+ async putAvatar(username: string, data: Blob, _token: string): Promise<void> {
+ await mockPrepare("user.avatar.put");
+ const etag = await sha1(data);
+ await mockStorage.setItem<Blob>(`user.${username}.avatar.data`, data);
+ await mockStorage.setItem<string>(`user.${username}.avatar.etag`, etag);
+ }
+
+ async changePassword(
+ _req: HttpChangePasswordRequest,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare("userop.changepassowrd");
+ throw new Error("Not Implemented.");
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/timeline.ts b/Timeline/ClientApp/src/app/http/timeline.ts
index c4ebdee9..4a325bca 100644
--- a/Timeline/ClientApp/src/app/http/timeline.ts
+++ b/Timeline/ClientApp/src/app/http/timeline.ts
@@ -1,543 +1,543 @@
-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: HttpTimelinePostContent;
- time: string;
- lastUpdated: string;
- author: HttpUser;
- deleted: false;
-}
-
-interface RawTimelineDeletedPostInfo {
- id: number;
- time: string;
- lastUpdated: string;
- author: HttpUser;
- deleted: true;
-}
-
-type RawTimelineGenericPostInfo =
- | RawTimelinePostInfo
- | RawTimelineDeletedPostInfo;
-
-interface RawTimelinePostPostRequestTextContent {
- type: 'text';
- text: string;
-}
-
-interface RawTimelinePostPostRequestImageContent {
- type: 'image';
- data: string;
-}
-
-type RawTimelinePostPostRequestContent =
- | RawTimelinePostPostRequestTextContent
- | RawTimelinePostPostRequestImageContent;
-
-interface RawTimelinePostPostRequest {
- content: RawTimelinePostPostRequestContent;
- time?: string;
-}
-
-//-------------------- end: internal model --------------------
-
-function 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;
-}
+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: HttpTimelinePostContent;
+ time: string;
+ lastUpdated: string;
+ author: HttpUser;
+ deleted: false;
+}
+
+interface RawTimelineDeletedPostInfo {
+ id: number;
+ time: string;
+ lastUpdated: string;
+ author: HttpUser;
+ deleted: true;
+}
+
+type RawTimelineGenericPostInfo =
+ | RawTimelinePostInfo
+ | RawTimelineDeletedPostInfo;
+
+interface RawTimelinePostPostRequestTextContent {
+ type: "text";
+ text: string;
+}
+
+interface RawTimelinePostPostRequestImageContent {
+ type: "image";
+ data: string;
+}
+
+type RawTimelinePostPostRequestContent =
+ | RawTimelinePostPostRequestTextContent
+ | RawTimelinePostPostRequestImageContent;
+
+interface RawTimelinePostPostRequest {
+ content: RawTimelinePostPostRequestContent;
+ time?: string;
+}
+
+//-------------------- end: internal model --------------------
+
+function 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
index 26f7891f..ae0cf3f6 100644
--- a/Timeline/ClientApp/src/app/http/token.ts
+++ b/Timeline/ClientApp/src/app/http/token.ts
@@ -1,72 +1,72 @@
-import axios, { AxiosError } from 'axios';
-
-import {
- apiBaseUrl,
- convertToNetworkError,
- convertToIfErrorCodeIs,
- extractResponseData,
-} from './common';
-import { HttpUser } from './user';
-
-export interface HttpCreateTokenRequest {
- username: string;
- password: string;
- expire: number;
-}
-
-export interface HttpCreateTokenResponse {
- token: string;
- user: HttpUser;
-}
-
-export interface HttpVerifyTokenRequest {
- token: string;
-}
-
-export interface HttpVerifyTokenResponse {
- user: HttpUser;
-}
-
-export class HttpCreateTokenBadCredentialError extends Error {
- constructor(public innerError?: AxiosError) {
- super();
- }
-}
-
-export interface IHttpTokenClient {
- create(req: HttpCreateTokenRequest): Promise<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;
-}
+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
index baa831aa..a0a02cce 100644
--- a/Timeline/ClientApp/src/app/http/user.ts
+++ b/Timeline/ClientApp/src/app/http/user.ts
@@ -1,134 +1,134 @@
-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;
-}
+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
index aa293819..cdced7bf 100644
--- a/Timeline/ClientApp/src/app/i18n.ts
+++ b/Timeline/ClientApp/src/app/i18n.ts
@@ -1,79 +1,79 @@
-import i18n, { BackendModule, ResourceKey } from 'i18next';
-import LanguageDetector from 'i18next-browser-languagedetector';
-import { initReactI18next } from 'react-i18next';
-
-const backend: BackendModule = {
- type: 'backend',
- async read(language, namespace, callback) {
- function error(message: string): void {
- callback(new Error(message), false);
- }
-
- function success(result: ResourceKey): void {
- callback(null, result);
- }
-
- if (namespace !== 'translation') {
- error("Namespace must be 'translation'.");
- }
-
- if (language === 'en') {
- const res = (
- await import(
- /* webpackChunkName: "locales-en" */ './locales/en/translation'
- )
- ).default;
- success(res);
- } else if (language === 'zh-cn' || language === 'zh') {
- const res = (
- await import(
- /* webpackChunkName: "locales-zh" */ './locales/zh/translation'
- )
- ).default;
- success(res);
- } else {
- error(`Language ${language} is not supported.`);
- }
- },
- init() {}, // eslint-disable-line @typescript-eslint/no-empty-function
- create() {}, // eslint-disable-line @typescript-eslint/no-empty-function
-};
-
-export const i18nPromise = i18n
- .use(LanguageDetector)
- .use(backend)
- .use(initReactI18next) // bind react-i18next to the instance
- .init({
- fallbackLng: false,
- lowerCaseLng: true,
-
- debug: process.env.NODE_ENV === 'development',
-
- interpolation: {
- escapeValue: false, // not needed for react!!
- },
-
- // react i18next special options (optional)
- // override if needed - omit if ok with defaults
- /*
- react: {
- bindI18n: 'languageChanged',
- bindI18nStore: '',
- transEmptyNodeValue: '',
- transSupportBasicHtmlNodes: true,
- transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
- useSuspense: true,
- }
- */
- });
-
-if (module.hot) {
- module.hot.accept(
- ['./locales/en/translation', './locales/zh/translation'],
- () => {
- void i18n.reloadResources();
- }
- );
-}
-
-export default i18n;
+import i18n, { BackendModule, ResourceKey } from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+
+const backend: BackendModule = {
+ type: "backend",
+ async read(language, namespace, callback) {
+ function error(message: string): void {
+ callback(new Error(message), false);
+ }
+
+ function success(result: ResourceKey): void {
+ callback(null, result);
+ }
+
+ if (namespace !== "translation") {
+ error("Namespace must be 'translation'.");
+ }
+
+ if (language === "en") {
+ const res = (
+ await import(
+ /* webpackChunkName: "locales-en" */ "./locales/en/translation"
+ )
+ ).default;
+ success(res);
+ } else if (language === "zh-cn" || language === "zh") {
+ const res = (
+ await import(
+ /* webpackChunkName: "locales-zh" */ "./locales/zh/translation"
+ )
+ ).default;
+ success(res);
+ } else {
+ error(`Language ${language} is not supported.`);
+ }
+ },
+ init() {}, // eslint-disable-line @typescript-eslint/no-empty-function
+ create() {}, // eslint-disable-line @typescript-eslint/no-empty-function
+};
+
+export const i18nPromise = i18n
+ .use(LanguageDetector)
+ .use(backend)
+ .use(initReactI18next) // bind react-i18next to the instance
+ .init({
+ fallbackLng: false,
+ lowerCaseLng: true,
+
+ debug: process.env.NODE_ENV === "development",
+
+ interpolation: {
+ escapeValue: false, // not needed for react!!
+ },
+
+ // react i18next special options (optional)
+ // override if needed - omit if ok with defaults
+ /*
+ react: {
+ bindI18n: 'languageChanged',
+ bindI18nStore: '',
+ transEmptyNodeValue: '',
+ transSupportBasicHtmlNodes: true,
+ transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
+ useSuspense: true,
+ }
+ */
+ });
+
+if (module.hot) {
+ module.hot.accept(
+ ["./locales/en/translation", "./locales/zh/translation"],
+ () => {
+ void i18n.reloadResources();
+ }
+ );
+}
+
+export default i18n;
diff --git a/Timeline/ClientApp/src/app/index.sass b/Timeline/ClientApp/src/app/index.sass
index 927a1f5a..ef0b03ba 100644
--- a/Timeline/ClientApp/src/app/index.sass
+++ b/Timeline/ClientApp/src/app/index.sass
@@ -1,82 +1,82 @@
-@import '~bootstrap/scss/bootstrap'
-
-@import './common/common'
-@import './common/alert'
-@import './home/home'
-@import './about/about'
-@import './timeline/timeline'
-@import './timeline/timeline-ui'
-@import './user/user-page'
-
-body
- margin: 0
-
-#app
- display: flex
- flex-direction: column
-
-small
- line-height: 1.2
-
-.width-1px
- width: 1px
-
-.flex-fix-length
- flex-grow: 0
- flex-shrink: 0
-
-.position-lt
- left: 0
- top: 0
-
-.position-rb
- right: 0
- bottom: 0
-
-.app-bar
- z-index: 1035
-
-.avatar
- width: 60px
-
-.avatar.large
- width: 100px
-
-.avatar.small
- width: 40px
-
-.mt-appbar
- margin-top: 56px
-
-.icon-button
- font-size: 1.4em
-
-.large-icon-button
- font-size: 1.6em
-
-.cursor-pointer
- cursor: pointer
-
-textarea
- resize: none
-
-.white-space-no-wrap
- white-space: nowrap
-
-.cru-card
- @extend .shadow
- @extend .border
- @extend .border-primary
- @extend .rounded
- @extend .bg-light
-
-.full-viewport-center-child
- position: fixed
- width: 100vw
- height: 100vh
- display: flex
- justify-content: center
- align-items: center
-
-.text-orange
- color: $orange
+@import '~bootstrap/scss/bootstrap'
+
+@import './common/common'
+@import './common/alert'
+@import './home/home'
+@import './about/about'
+@import './timeline/timeline'
+@import './timeline/timeline-ui'
+@import './user/user-page'
+
+body
+ margin: 0
+
+#app
+ display: flex
+ flex-direction: column
+
+small
+ line-height: 1.2
+
+.width-1px
+ width: 1px
+
+.flex-fix-length
+ flex-grow: 0
+ flex-shrink: 0
+
+.position-lt
+ left: 0
+ top: 0
+
+.position-rb
+ right: 0
+ bottom: 0
+
+.app-bar
+ z-index: 1035
+
+.avatar
+ width: 60px
+
+.avatar.large
+ width: 100px
+
+.avatar.small
+ width: 40px
+
+.mt-appbar
+ margin-top: 56px
+
+.icon-button
+ font-size: 1.4em
+
+.large-icon-button
+ font-size: 1.6em
+
+.cursor-pointer
+ cursor: pointer
+
+textarea
+ resize: none
+
+.white-space-no-wrap
+ white-space: nowrap
+
+.cru-card
+ @extend .shadow
+ @extend .border
+ @extend .border-primary
+ @extend .rounded
+ @extend .bg-light
+
+.full-viewport-center-child
+ position: fixed
+ width: 100vw
+ height: 100vh
+ display: flex
+ justify-content: center
+ align-items: center
+
+.text-orange
+ color: $orange
diff --git a/Timeline/ClientApp/src/app/index.tsx b/Timeline/ClientApp/src/app/index.tsx
index 70b28704..00a75a4a 100644
--- a/Timeline/ClientApp/src/app/index.tsx
+++ b/Timeline/ClientApp/src/app/index.tsx
@@ -1,15 +1,15 @@
-import 'regenerator-runtime';
-import 'core-js/modules/es.promise';
-import 'core-js/modules/es.array.iterator';
-import 'pepjs';
-
-import React from 'react';
-import ReactDOM from 'react-dom';
-
-import './index.sass';
-
-import './i18n';
-
-import App from './App';
-
-ReactDOM.render(<App />, document.getElementById('app'));
+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
index e7f395e1..c7f33d1e 100644
--- a/Timeline/ClientApp/src/app/locales/en/translation.ts
+++ b/Timeline/ClientApp/src/app/locales/en/translation.ts
@@ -1,202 +1,202 @@
-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;
+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
index bbbc2686..9e3534ac 100644
--- a/Timeline/ClientApp/src/app/locales/scheme.ts
+++ b/Timeline/ClientApp/src/app/locales/scheme.ts
@@ -1,182 +1,182 @@
-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;
- };
-}
+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
index 2e0eccd5..df316366 100644
--- a/Timeline/ClientApp/src/app/locales/zh/translation.ts
+++ b/Timeline/ClientApp/src/app/locales/zh/translation.ts
@@ -1,195 +1,195 @@
-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;
+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
index a353c809..f71b23b3 100644
--- a/Timeline/ClientApp/src/app/service-worker.tsx
+++ b/Timeline/ClientApp/src/app/service-worker.tsx
@@ -1,109 +1,109 @@
-import React from 'react';
-import { Button } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { pushAlert } from './common/alert-service';
-
-if ('serviceWorker' in navigator) {
- let isThisTriggerUpgrade = false;
-
- const upgradeSuccessLocalStorageKey = 'TIMELINE_UPGRADE_SUCCESS';
-
- if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) {
- pushAlert({
- message: {
- type: 'i18n',
- key: 'serviceWorker.upgradeSuccess',
- },
- type: 'success',
- });
- window.localStorage.removeItem(upgradeSuccessLocalStorageKey);
- }
-
- void import('workbox-window').then(({ Workbox, messageSW }) => {
- const wb = new Workbox('/sw.js');
- let registration: ServiceWorkerRegistration | undefined;
-
- // externalactivated is not usable but I still use its name.
- wb.addEventListener('controlling', () => {
- const upgradeReload = (): void => {
- window.localStorage.setItem(upgradeSuccessLocalStorageKey, 'true');
- window.location.reload();
- };
-
- if (isThisTriggerUpgrade) {
- upgradeReload();
- } else {
- const Message: React.FC = () => {
- const { t } = useTranslation();
- return (
- <>
- {t('serviceWorker.externalActivatedPrompt')}
- <Button color="success" size="sm" onClick={upgradeReload} outline>
- {t('serviceWorker.reloadNow')}
- </Button>
- </>
- );
- };
-
- pushAlert({
- message: Message,
- dismissTime: 'never',
- type: 'warning',
- });
- }
- });
-
- wb.addEventListener('activated', (event) => {
- if (!event.isUpdate) {
- pushAlert({
- message: {
- type: 'i18n',
- key: 'serviceWorker.availableOffline',
- },
- type: 'success',
- });
- }
- });
-
- const showSkipWaitingPrompt = (): void => {
- const upgrade = (): void => {
- isThisTriggerUpgrade = true;
- if (registration && registration.waiting) {
- // Send a message to the waiting service worker,
- // instructing it to activate.
- // Note: for this to work, you have to add a message
- // listener in your service worker. See below.
- void messageSW(registration.waiting, { type: 'SKIP_WAITING' });
- }
- };
-
- const UpgradeMessage: React.FC = () => {
- const { t } = useTranslation();
- return (
- <>
- {t('serviceWorker.upgradePrompt')}
- <Button color="success" size="sm" onClick={upgrade} outline>
- {t('serviceWorker.upgradeNow')}
- </Button>
- </>
- );
- };
-
- pushAlert({
- message: UpgradeMessage,
- dismissTime: 'never',
- type: 'success',
- });
- };
-
- // Add an event listener to detect when the registered
- // service worker has installed but is waiting to activate.
- wb.addEventListener('waiting', showSkipWaitingPrompt);
- wb.addEventListener('externalwaiting', showSkipWaitingPrompt);
-
- void wb.register().then((reg) => {
- registration = reg;
- });
- });
-}
+import React from "react";
+import { Button } from "reactstrap";
+import { useTranslation } from "react-i18next";
+
+import { pushAlert } from "./common/alert-service";
+
+if ("serviceWorker" in navigator) {
+ let isThisTriggerUpgrade = false;
+
+ const upgradeSuccessLocalStorageKey = "TIMELINE_UPGRADE_SUCCESS";
+
+ if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "serviceWorker.upgradeSuccess",
+ },
+ type: "success",
+ });
+ window.localStorage.removeItem(upgradeSuccessLocalStorageKey);
+ }
+
+ void import("workbox-window").then(({ Workbox, messageSW }) => {
+ const wb = new Workbox("/sw.js");
+ let registration: ServiceWorkerRegistration | undefined;
+
+ // externalactivated is not usable but I still use its name.
+ wb.addEventListener("controlling", () => {
+ const upgradeReload = (): void => {
+ window.localStorage.setItem(upgradeSuccessLocalStorageKey, "true");
+ window.location.reload();
+ };
+
+ if (isThisTriggerUpgrade) {
+ upgradeReload();
+ } else {
+ const Message: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+ <>
+ {t("serviceWorker.externalActivatedPrompt")}
+ <Button color="success" size="sm" onClick={upgradeReload} outline>
+ {t("serviceWorker.reloadNow")}
+ </Button>
+ </>
+ );
+ };
+
+ pushAlert({
+ message: Message,
+ dismissTime: "never",
+ type: "warning",
+ });
+ }
+ });
+
+ wb.addEventListener("activated", (event) => {
+ if (!event.isUpdate) {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "serviceWorker.availableOffline",
+ },
+ type: "success",
+ });
+ }
+ });
+
+ const showSkipWaitingPrompt = (): void => {
+ const upgrade = (): void => {
+ isThisTriggerUpgrade = true;
+ if (registration && registration.waiting) {
+ // Send a message to the waiting service worker,
+ // instructing it to activate.
+ // Note: for this to work, you have to add a message
+ // listener in your service worker. See below.
+ void messageSW(registration.waiting, { type: "SKIP_WAITING" });
+ }
+ };
+
+ const UpgradeMessage: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+ <>
+ {t("serviceWorker.upgradePrompt")}
+ <Button color="success" size="sm" onClick={upgrade} outline>
+ {t("serviceWorker.upgradeNow")}
+ </Button>
+ </>
+ );
+ };
+
+ pushAlert({
+ message: UpgradeMessage,
+ dismissTime: "never",
+ type: "success",
+ });
+ };
+
+ // Add an event listener to detect when the registered
+ // service worker has installed but is waiting to activate.
+ wb.addEventListener("waiting", showSkipWaitingPrompt);
+ wb.addEventListener("externalwaiting", showSkipWaitingPrompt);
+
+ void wb.register().then((reg) => {
+ registration = reg;
+ });
+ });
+}
diff --git a/Timeline/ClientApp/src/app/settings/Settings.tsx b/Timeline/ClientApp/src/app/settings/Settings.tsx
index 13e6f4f3..dd648538 100644
--- a/Timeline/ClientApp/src/app/settings/Settings.tsx
+++ b/Timeline/ClientApp/src/app/settings/Settings.tsx
@@ -1,222 +1,222 @@
-import React, { useState } from 'react';
-import { useHistory } from 'react-router';
-import { useTranslation } from 'react-i18next';
-import {
- Container,
- Row,
- Col,
- Input,
- Modal,
- ModalHeader,
- ModalBody,
- ModalFooter,
- Button,
-} from 'reactstrap';
-
-import { useUser, userService } from '../data/user';
-
-import AppBar from '../common/AppBar';
-import OperationDialog, {
- OperationInputErrorInfo,
-} from '../common/OperationDialog';
-
-interface ChangePasswordDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangePasswordDialog: React.FC<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 isOpen centered>
- <ModalHeader className="text-danger">
- {t('settings.dialogConfirmLogout.title')}
- </ModalHeader>
- <ModalBody>{t('settings.dialogConfirmLogout.prompt')}</ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="danger" onClick={onConfirm}>
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </Modal>
- );
-};
-
-const Settings: React.FC = (_) => {
- const { i18n, t } = useTranslation();
- const user = useUser();
- const history = useHistory();
-
- const [dialog, setDialog] = useState<null | 'changepassword' | 'logout'>(
- null
- );
-
- const language = i18n.language.slice(0, 2);
-
- return (
- <>
- <AppBar />
- <Container fluid className="mt-appbar">
- {user ? (
- <>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- onClick={() => {
- history.push(`/users/${user.username}`);
- }}
- >
- {t('settings.gotoSelf')}
- </h5>
- </Col>
- </Row>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- className="text-danger"
- onClick={() => setDialog('changepassword')}
- >
- {t('settings.changePassword')}
- </h5>
- </Col>
- </Row>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- className="text-danger"
- onClick={() => {
- setDialog('logout');
- }}
- >
- {t('settings.logout')}
- </h5>
- </Col>
- </Row>
- </>
- ) : null}
- <Row className="align-items-center border-bottom p-3">
- <Col xs="12" sm="auto">
- <h5>{t('settings.languagePrimary')}</h5>
- <p>{t('settings.languageSecondary')}</p>
- </Col>
- <Col xs="auto" className="ml-auto">
- <Input
- type="select"
- value={language}
- onChange={(e) => {
- void i18n.changeLanguage(e.target.value);
- }}
- >
- <option value="zh">中文</option>
- <option value="en">English</option>
- </Input>
- </Col>
- </Row>
- {(() => {
- switch (dialog) {
- case 'changepassword':
- return (
- <ChangePasswordDialog
- open
- close={() => {
- setDialog(null);
- }}
- />
- );
- case 'logout':
- return (
- <ConfirmLogoutDialog
- toggle={() => setDialog(null)}
- onConfirm={() => {
- void userService.logout().then(() => {
- history.push('/');
- });
- }}
- />
- );
- default:
- return null;
- }
- })()}
- </Container>
- </>
- );
-};
-
-export default Settings;
+import React, { useState } from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import {
+ Container,
+ Row,
+ Col,
+ Input,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+} from "reactstrap";
+
+import { useUser, userService } from "../data/user";
+
+import AppBar from "../common/AppBar";
+import OperationDialog, {
+ OperationInputErrorInfo,
+} from "../common/OperationDialog";
+
+interface ChangePasswordDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const ChangePasswordDialog: React.FC<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 isOpen centered>
+ <ModalHeader className="text-danger">
+ {t("settings.dialogConfirmLogout.title")}
+ </ModalHeader>
+ <ModalBody>{t("settings.dialogConfirmLogout.prompt")}</ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button color="danger" onClick={onConfirm}>
+ {t("operationDialog.confirm")}
+ </Button>
+ </ModalFooter>
+ </Modal>
+ );
+};
+
+const Settings: React.FC = (_) => {
+ const { i18n, t } = useTranslation();
+ const user = useUser();
+ const history = useHistory();
+
+ const [dialog, setDialog] = useState<null | "changepassword" | "logout">(
+ null
+ );
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <>
+ <AppBar />
+ <Container fluid className="mt-appbar">
+ {user ? (
+ <>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ onClick={() => {
+ history.push(`/users/${user.username}`);
+ }}
+ >
+ {t("settings.gotoSelf")}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => setDialog("changepassword")}
+ >
+ {t("settings.changePassword")}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => {
+ setDialog("logout");
+ }}
+ >
+ {t("settings.logout")}
+ </h5>
+ </Col>
+ </Row>
+ </>
+ ) : null}
+ <Row className="align-items-center border-bottom p-3">
+ <Col xs="12" sm="auto">
+ <h5>{t("settings.languagePrimary")}</h5>
+ <p>{t("settings.languageSecondary")}</p>
+ </Col>
+ <Col xs="auto" className="ml-auto">
+ <Input
+ type="select"
+ value={language}
+ onChange={(e) => {
+ void i18n.changeLanguage(e.target.value);
+ }}
+ >
+ <option value="zh">中文</option>
+ <option value="en">English</option>
+ </Input>
+ </Col>
+ </Row>
+ {(() => {
+ switch (dialog) {
+ case "changepassword":
+ return (
+ <ChangePasswordDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ );
+ case "logout":
+ return (
+ <ConfirmLogoutDialog
+ toggle={() => setDialog(null)}
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ history.push("/");
+ });
+ }}
+ />
+ );
+ default:
+ return null;
+ }
+ })()}
+ </Container>
+ </>
+ );
+};
+
+export default Settings;
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
index 7c3a93fb..780588d1 100644
--- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx
+++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
@@ -1,88 +1,88 @@
-import React from 'react';
-import clsx from 'clsx';
-
-import { TimelinePostInfo } from '../data/timeline';
-
-import TimelineItem from './TimelineItem';
-
-export interface TimelinePostInfoEx extends TimelinePostInfo {
- deletable: boolean;
-}
-
-export type TimelineDeleteCallback = (index: number, id: number) => void;
-
-export interface TimelineProps {
- className?: string;
- posts: TimelinePostInfoEx[];
- onDelete: TimelineDeleteCallback;
- onResize?: () => void;
- containerRef?: React.Ref<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('container-fluid timeline', props.className)}
- >
- <div className="timeline-enter-animation-mask" />
- {(() => {
- 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;
+import React from "react";
+import clsx from "clsx";
+
+import { TimelinePostInfo } from "../data/timeline";
+
+import TimelineItem from "./TimelineItem";
+
+export interface TimelinePostInfoEx extends TimelinePostInfo {
+ deletable: boolean;
+}
+
+export type TimelineDeleteCallback = (index: number, id: number) => void;
+
+export interface TimelineProps {
+ className?: string;
+ posts: TimelinePostInfoEx[];
+ onDelete: TimelineDeleteCallback;
+ onResize?: () => void;
+ containerRef?: React.Ref<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("container-fluid timeline", props.className)}
+ >
+ <div className="timeline-enter-animation-mask" />
+ {(() => {
+ 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/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
index 7bcea6c5..5ebbf9df 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
@@ -1,54 +1,54 @@
-import React from 'react';
-import { useHistory } from 'react-router';
-import { Trans } from 'react-i18next';
-
-import OperationDialog from '../common/OperationDialog';
-import { timelineService } from '../data/timeline';
-
-interface TimelineDeleteDialog {
- open: boolean;
- name: string;
- close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<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;
+import React from "react";
+import { useHistory } from "react-router";
+import { Trans } from "react-i18next";
+
+import OperationDialog from "../common/OperationDialog";
+import { timelineService } from "../data/timeline";
+
+interface TimelineDeleteDialog {
+ open: boolean;
+ name: string;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<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/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
index ece7d38a..3591b6f9 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
@@ -1,110 +1,110 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-
-import { useAvatar } from '../data/user';
-import { timelineVisibilityTooltipTranslationMap } from '../data/timeline';
-
-import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
-import BlobImage from '../common/BlobImage';
-
-export type OrdinaryTimelineManageItem = 'delete';
-
-export type TimelineInfoCardProps = TimelineCardComponentProps<
- OrdinaryTimelineManageItem
->;
-
-const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
- const { onHeight, onManage } = props;
-
- const { t } = useTranslation();
-
- const avatar = useAvatar(props.timeline.owner.username);
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeight = React.useCallback((): void => {
- if (onHeight) {
- onHeight(containerRef.current.getBoundingClientRect().height);
- }
- }, [onHeight]);
-
- React.useEffect(() => {
- const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
- return () => subscription.unsubscribe();
- });
-
- const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
- false
- );
- const toggleManageDropdown = React.useCallback(
- (): void => setManageDropdownOpen((old) => !old),
- []
- );
-
- return (
- <div
- ref={containerRef}
- className={clsx('rounded border p-2 bg-light', props.className)}
- onTransitionEnd={notifyHeight}
- >
- <h3 className="text-primary mx-3 d-inline-block align-middle">
- {props.timeline.name}
- </h3>
- <div className="d-inline-block align-middle">
- <BlobImage
- blob={avatar}
- onLoad={notifyHeight}
- className="avatar small rounded-circle"
- />
- {props.timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{props.timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{props.timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
- <DropdownToggle outline color="primary">
- {t('timeline.manage')}
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={() => onManage('property')}>
- {t('timeline.manageItem.property')}
- </DropdownItem>
- <DropdownItem onClick={props.onMember}>
- {t('timeline.manageItem.member')}
- </DropdownItem>
- <DropdownItem divider />
- <DropdownItem
- className="text-danger"
- onClick={() => onManage('delete')}
- >
- {t('timeline.manageItem.delete')}
- </DropdownItem>
- </DropdownMenu>
- </Dropdown>
- ) : (
- <Button color="primary" outline onClick={props.onMember}>
- {t('timeline.memberButton')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default TimelineInfoCard;
+import React from "react";
+import clsx from "clsx";
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from "reactstrap";
+import { useTranslation } from "react-i18next";
+import { fromEvent } from "rxjs";
+
+import { useAvatar } from "../data/user";
+import { timelineVisibilityTooltipTranslationMap } from "../data/timeline";
+
+import { TimelineCardComponentProps } from "./TimelinePageTemplateUI";
+import BlobImage from "../common/BlobImage";
+
+export type OrdinaryTimelineManageItem = "delete";
+
+export type TimelineInfoCardProps = TimelineCardComponentProps<
+ OrdinaryTimelineManageItem
+>;
+
+const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(props.timeline.owner.username);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeight = React.useCallback((): void => {
+ if (onHeight) {
+ onHeight(containerRef.current.getBoundingClientRect().height);
+ }
+ }, [onHeight]);
+
+ React.useEffect(() => {
+ const subscription = fromEvent(window, "resize").subscribe(notifyHeight);
+ return () => subscription.unsubscribe();
+ });
+
+ const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx("rounded border p-2 bg-light", props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <h3 className="text-primary mx-3 d-inline-block align-middle">
+ {props.timeline.name}
+ </h3>
+ <div className="d-inline-block align-middle">
+ <BlobImage
+ blob={avatar}
+ onLoad={notifyHeight}
+ className="avatar small rounded-circle"
+ />
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t("timeline.manage")}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={() => onManage("property")}>
+ {t("timeline.manageItem.property")}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t("timeline.manageItem.member")}
+ </DropdownItem>
+ <DropdownItem divider />
+ <DropdownItem
+ className="text-danger"
+ onClick={() => onManage("delete")}
+ >
+ {t("timeline.manageItem.delete")}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t("timeline.memberButton")}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default TimelineInfoCard;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
index 727de1fe..0d62b0e7 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
@@ -1,183 +1,183 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Row,
- Col,
- Modal,
- ModalHeader,
- ModalBody,
- ModalFooter,
- Button,
-} from 'reactstrap';
-import { Link } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import Svg from 'react-inlinesvg';
-
-import chevronDownIcon from 'bootstrap-icons/icons/chevron-down.svg';
-import trashIcon from 'bootstrap-icons/icons/trash.svg';
-
-import BlobImage from '../common/BlobImage';
-
-import { useAvatar } from '../data/user';
-import { TimelinePostInfo } from '../data/timeline';
-
-const TimelinePostDeleteConfirmDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal toggle={toggle} isOpen centered>
- <ModalHeader className="text-danger">
- {t('timeline.post.deleteDialog.title')}
- </ModalHeader>
- <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button
- color="danger"
- onClick={() => {
- onConfirm();
- toggle();
- }}
- >
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </Modal>
- );
-};
-
-export interface TimelineItemProps {
- post: TimelinePostInfo;
- current?: boolean;
- more?: {
- isOpen: boolean;
- toggle: () => void;
- onDelete: () => void;
- };
- onClick?: () => void;
- 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 (
- <Row
- className={clsx(
- 'position-relative flex-nowrap',
- current && 'current',
- props.className
- )}
- onClick={props.onClick}
- style={props.style}
- >
- <Col className="timeline-line-area">
- <div className="timeline-line-segment start"></div>
- <div className="timeline-line-node-container">
- <div className="timeline-line-node"></div>
- </div>
- <div className="timeline-line-segment end"></div>
- {current && <div className="timeline-line-segment current-end" />}
- </Col>
- <Col className="timeline-pt-start">
- <Row className="flex-nowrap">
- <div className="col-auto flex-shrink-1 px-0">
- <Row className="ml-n3 mr-0 align-items-center">
- <span className="ml-3 text-primary white-space-no-wrap">
- {props.post.time.toLocaleString(i18n.languages)}
- </span>
- <small className="text-dark ml-3">
- {props.post.author.nickname}
- </small>
- </Row>
- </div>
- {more != null ? (
- <div className="col-auto px-2 d-flex justify-content-center align-items-center">
- <Svg
- src={chevronDownIcon}
- className="text-info icon-button"
- onClick={(e: Event) => {
- more.toggle();
- e.stopPropagation();
- }}
- />
- </div>
- ) : null}
- </Row>
- <div className="row d-block timeline-content">
- <Link
- className="float-right float-sm-left mx-2"
- to={'/users/' + props.post.author.username}
- >
- <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>
- </Col>
- {more != null && more.isOpen ? (
- <>
- <div
- className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
- >
- <Svg
- src={trashIcon}
- className="text-danger large-icon-button"
- onClick={(e: Event) => {
- toggleDeleteDialog();
- e.stopPropagation();
- }}
- />
- </div>
- {deleteDialog ? (
- <TimelinePostDeleteConfirmDialog
- toggle={() => {
- toggleDeleteDialog();
- more.toggle();
- }}
- onConfirm={more.onDelete}
- />
- ) : null}
- </>
- ) : null}
- </Row>
- );
-};
-
-export default TimelineItem;
+import React from "react";
+import clsx from "clsx";
+import {
+ Row,
+ Col,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+} from "reactstrap";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import Svg from "react-inlinesvg";
+
+import chevronDownIcon from "bootstrap-icons/icons/chevron-down.svg";
+import trashIcon from "bootstrap-icons/icons/trash.svg";
+
+import BlobImage from "../common/BlobImage";
+
+import { useAvatar } from "../data/user";
+import { TimelinePostInfo } from "../data/timeline";
+
+const TimelinePostDeleteConfirmDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal toggle={toggle} isOpen centered>
+ <ModalHeader className="text-danger">
+ {t("timeline.post.deleteDialog.title")}
+ </ModalHeader>
+ <ModalBody>{t("timeline.post.deleteDialog.prompt")}</ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button
+ color="danger"
+ onClick={() => {
+ onConfirm();
+ toggle();
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </Button>
+ </ModalFooter>
+ </Modal>
+ );
+};
+
+export interface TimelineItemProps {
+ post: TimelinePostInfo;
+ current?: boolean;
+ more?: {
+ isOpen: boolean;
+ toggle: () => void;
+ onDelete: () => void;
+ };
+ onClick?: () => void;
+ 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 (
+ <Row
+ className={clsx(
+ "position-relative flex-nowrap",
+ current && "current",
+ props.className
+ )}
+ onClick={props.onClick}
+ style={props.style}
+ >
+ <Col className="timeline-line-area">
+ <div className="timeline-line-segment start"></div>
+ <div className="timeline-line-node-container">
+ <div className="timeline-line-node"></div>
+ </div>
+ <div className="timeline-line-segment end"></div>
+ {current && <div className="timeline-line-segment current-end" />}
+ </Col>
+ <Col className="timeline-pt-start">
+ <Row className="flex-nowrap">
+ <div className="col-auto flex-shrink-1 px-0">
+ <Row className="ml-n3 mr-0 align-items-center">
+ <span className="ml-3 text-primary white-space-no-wrap">
+ {props.post.time.toLocaleString(i18n.languages)}
+ </span>
+ <small className="text-dark ml-3">
+ {props.post.author.nickname}
+ </small>
+ </Row>
+ </div>
+ {more != null ? (
+ <div className="col-auto px-2 d-flex justify-content-center align-items-center">
+ <Svg
+ src={chevronDownIcon}
+ className="text-info icon-button"
+ onClick={(e: Event) => {
+ more.toggle();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ ) : null}
+ </Row>
+ <div className="row d-block timeline-content">
+ <Link
+ className="float-right float-sm-left mx-2"
+ to={"/users/" + props.post.author.username}
+ >
+ <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>
+ </Col>
+ {more != null && more.isOpen ? (
+ <>
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
+ onClick={more.toggle}
+ >
+ <Svg
+ src={trashIcon}
+ className="text-danger large-icon-button"
+ onClick={(e: Event) => {
+ toggleDeleteDialog();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ {deleteDialog ? (
+ <TimelinePostDeleteConfirmDialog
+ toggle={() => {
+ toggleDeleteDialog();
+ more.toggle();
+ }}
+ onConfirm={more.onDelete}
+ />
+ ) : null}
+ </>
+ ) : null}
+ </Row>
+ );
+};
+
+export default TimelineItem;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
index 39af412e..559750d2 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
@@ -1,219 +1,219 @@
-import React, { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- Container,
- ListGroup,
- ListGroupItem,
- Modal,
- Row,
- Col,
- Button,
-} from 'reactstrap';
-
-import { User, useAvatar } from '../data/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 (
- <ListGroupItem className="container">
- <Row>
- <Col className="col-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"
- color="danger"
- onClick={() => {
- onRemove(user.username);
- }}
- >
- {t('timeline.member.remove')}
- </Button>
- );
- })()}
- </Row>
- </ListGroupItem>
- );
-};
-
-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
- color="primary"
- className="align-self-center"
- disabled={!addable}
- onClick={() => {
- void edit.onAddUser(u).then((_) => {
- setUserSearchText('');
- setUserSearchState({ type: 'init' });
- });
- }}
- >
- {t('timeline.member.add')}
- </Button>
- </Row>
- </Container>
- </>
- );
- } else if (userSearchState.type === 'error') {
- return (
- <p className="text-danger">{t(userSearchState.data)}</p>
- );
- }
- })()}
- </>
- );
- } else {
- return null;
- }
- })()}
- </Container>
- );
-};
-
-export default TimelineMember;
-
-export interface TimelineMemberDialogProps extends TimelineMemberProps {
- open: boolean;
- onClose: () => void;
-}
-
-export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
- props
-) => {
- return (
- <Modal isOpen={props.open} toggle={props.onClose}>
- <TimelineMember {...props} />
- </Modal>
- );
-};
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Container,
+ ListGroup,
+ ListGroupItem,
+ Modal,
+ Row,
+ Col,
+ Button,
+} from "reactstrap";
+
+import { User, useAvatar } from "../data/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 (
+ <ListGroupItem className="container">
+ <Row>
+ <Col className="col-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"
+ color="danger"
+ onClick={() => {
+ onRemove(user.username);
+ }}
+ >
+ {t("timeline.member.remove")}
+ </Button>
+ );
+ })()}
+ </Row>
+ </ListGroupItem>
+ );
+};
+
+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
+ color="primary"
+ className="align-self-center"
+ disabled={!addable}
+ onClick={() => {
+ void edit.onAddUser(u).then((_) => {
+ setUserSearchText("");
+ setUserSearchState({ type: "init" });
+ });
+ }}
+ >
+ {t("timeline.member.add")}
+ </Button>
+ </Row>
+ </Container>
+ </>
+ );
+ } else if (userSearchState.type === "error") {
+ return (
+ <p className="text-danger">{t(userSearchState.data)}</p>
+ );
+ }
+ })()}
+ </>
+ );
+ } else {
+ return null;
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default TimelineMember;
+
+export interface TimelineMemberDialogProps extends TimelineMemberProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
+ props
+) => {
+ return (
+ <Modal isOpen={props.open} toggle={props.onClose}>
+ <TimelineMember {...props} />
+ </Modal>
+ );
+};
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
index 7d0a8807..0771b40e 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
@@ -1,36 +1,36 @@
-import React from 'react';
-import { useParams } from 'react-router';
-
-import TimelinePageUI from './TimelinePageUI';
-import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
-import { OrdinaryTimelineManageItem } from './TimelineInfoCard';
-import TimelineDeleteDialog from './TimelineDeleteDialog';
-
-const TimelinePage: React.FC = (_) => {
- const { name } = useParams<{ name: string }>();
-
- const [dialog, setDialog] = React.useState<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;
+import React from "react";
+import { useParams } from "react-router";
+
+import TimelinePageUI from "./TimelinePageUI";
+import TimelinePageTemplate from "../timeline/TimelinePageTemplate";
+import { OrdinaryTimelineManageItem } from "./TimelineInfoCard";
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+
+const TimelinePage: React.FC = (_) => {
+ const { name } = useParams<{ name: string }>();
+
+ const [dialog, setDialog] = React.useState<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/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
index 89101f8f..be96a09e 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
@@ -1,191 +1,191 @@
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import { of } from 'rxjs';
-import { catchError } from 'rxjs/operators';
-
-import { ExcludeKey } from '../utilities/type';
-import { pushAlert } from '../common/alert-service';
-import { useUser, userInfoService, UserNotExistError } from '../data/user';
-import {
- timelineService,
- usePostList,
- useTimelineInfo,
-} from '../data/timeline';
-
-import { TimelineDeleteCallback } from './Timeline';
-import { TimelineMemberDialog } from './TimelineMember';
-import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
-import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
-import { TimelinePostSendCallback } from './TimelinePostEdit';
-import { UiLogicError } from '../common';
-
-export interface TimelinePageTemplateProps<TManageItem> {
- name: string;
- onManage: (item: TManageItem) => void;
- UiComponent: React.ComponentType<
- ExcludeKey<TimelinePageTemplateUIProps<TManageItem>, 'CardComponent'>
- >;
- dataVersion?: number;
- 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]
- );
-
- const onMember = React.useCallback(() => {
- setDialog('member');
- }, []);
-
- 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={onMember}
- />
- {dialogElement}
- </>
- );
-}
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { of } from "rxjs";
+import { catchError } from "rxjs/operators";
+
+import { ExcludeKey } from "../utilities/type";
+import { pushAlert } from "../common/alert-service";
+import { useUser, userInfoService, UserNotExistError } from "../data/user";
+import {
+ timelineService,
+ usePostList,
+ useTimelineInfo,
+} from "../data/timeline";
+
+import { TimelineDeleteCallback } from "./Timeline";
+import { TimelineMemberDialog } from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
+import { TimelinePostSendCallback } from "./TimelinePostEdit";
+import { UiLogicError } from "../common";
+
+export interface TimelinePageTemplateProps<TManageItem> {
+ name: string;
+ onManage: (item: TManageItem) => void;
+ UiComponent: React.ComponentType<
+ ExcludeKey<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
+ >;
+ dataVersion?: number;
+ 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]
+ );
+
+ const onMember = React.useCallback(() => {
+ setDialog("member");
+ }, []);
+
+ 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={onMember}
+ />
+ {dialogElement}
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
index 18b3323d..2066ceb1 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
@@ -1,325 +1,325 @@
-import React, { CSSProperties } from 'react';
-import { Spinner } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-import Svg from 'react-inlinesvg';
-import clsx from 'clsx';
-
-import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract.svg';
-import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg';
-
-import { getAlertHost } from '../common/alert-service';
-import { useEventEmiiter, UiLogicError } from '../common';
-import {
- TimelineInfo,
- TimelinePostsWithSyncState,
- timelineService,
-} from '../data/timeline';
-import { userService } from '../data/user';
-
-import Timeline, {
- TimelinePostInfoEx,
- TimelineDeleteCallback,
-} from './Timeline';
-import AppBar from '../common/AppBar';
-import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
-
-type TimelinePostSyncState = 'syncing' | 'synced' | 'offline';
-
-const TimelinePostSyncStateBadge: React.FC<{
- state: TimelinePostSyncState;
- style?: CSSProperties;
- className?: string;
-}> = ({ state, style, className }) => {
- const { t } = useTranslation();
-
- return (
- <div style={style} className={clsx('timeline-sync-state-badge', className)}>
- {(() => {
- switch (state) {
- 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 interface TimelineCardComponentProps<TManageItems> {
- timeline: TimelineInfo;
- onManage?: (item: TManageItems | 'property') => void;
- onMember: () => void;
- className?: string;
- onHeight?: (height: number) => void;
-}
-
-export interface TimelinePageTemplateUIProps<TManageItems> {
- avatarKey?: string | number;
- 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 [cardHeight, setCardHeight] = React.useState<number>(0);
-
- const genCardCollapseLocalStorageKey = (uniqueId: string): string =>
- `timeline.${uniqueId}.cardCollapse`;
-
- const cardCollapseLocalStorageKey =
- timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null;
-
- const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true);
- React.useEffect(() => {
- if (cardCollapseLocalStorageKey != null) {
- const savedCollapse =
- window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true';
- setInfoCardCollapse(savedCollapse);
- }
- }, [cardCollapseLocalStorageKey]);
-
- let body: React.ReactElement;
-
- if (props.error != null) {
- body = <p className="text-danger">{t(props.error)}</p>;
- } else {
- if (timeline != null) {
- let timelineBody: React.ReactElement;
- if (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
- ),
- })
- );
-
- const topHeight: string = infoCardCollapse
- ? 'calc(68px + 1.5em)'
- : `${cardHeight + 60}px`;
-
- const syncState: TimelinePostSyncState = postListState.syncing
- ? 'syncing'
- : postListState.type === 'synced'
- ? 'synced'
- : 'offline';
-
- timelineBody = (
- <div>
- <TimelinePostSyncStateBadge
- style={{ top: topHeight }}
- state={syncState}
- />
- <Timeline
- containerRef={timelineRef}
- posts={posts}
- onDelete={props.onDelete}
- onResize={triggerResizeEvent}
- />
- </div>
- );
- if (props.onPost != null) {
- timelineBody = (
- <>
- {timelineBody}
- <div ref={bottomSpaceRef} className="flex-fix-length" />
- <TimelinePostEdit
- onPost={props.onPost}
- onHeightChange={onPostEditHeightChange}
- timelineUniqueId={timeline.uniqueId}
- />
- </>
- );
- }
- }
- } else {
- timelineBody = (
- <div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
- </div>
- );
- }
- const { CardComponent } = props;
-
- body = (
- <>
- <div
- className="fixed-top mt-appbar info-card-container"
- data-collapse={infoCardCollapse ? 'true' : 'false'}
- >
- <Svg
- src={
- infoCardCollapse
- ? arrowsAngleExpandIcon
- : arrowsAngleContractIcon
- }
- onClick={() => {
- const newState = !infoCardCollapse;
- setInfoCardCollapse(newState);
- window.localStorage.setItem(
- genCardCollapseLocalStorageKey(timeline.uniqueId),
- newState.toString()
- );
- }}
- className="float-right m-1 info-card-collapse-button text-primary icon-button"
- />
- <CardComponent
- timeline={timeline}
- onManage={props.onManage}
- onMember={props.onMember}
- onHeight={setCardHeight}
- className="info-card-content"
- />
- </div>
- {timelineBody}
- </>
- );
- } else {
- body = (
- <div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
- </div>
- );
- }
- }
-
- return (
- <>
- <AppBar />
- <div>
- <div
- style={{ height: 56 + cardHeight }}
- className="timeline-page-top-space flex-fix-length"
- />
- {body}
- </div>
- </>
- );
-}
+import React, { CSSProperties } from "react";
+import { Spinner } from "reactstrap";
+import { useTranslation } from "react-i18next";
+import { fromEvent } from "rxjs";
+import Svg from "react-inlinesvg";
+import clsx from "clsx";
+
+import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg";
+import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg";
+
+import { getAlertHost } from "../common/alert-service";
+import { useEventEmiiter, UiLogicError } from "../common";
+import {
+ TimelineInfo,
+ TimelinePostsWithSyncState,
+ timelineService,
+} from "../data/timeline";
+import { userService } from "../data/user";
+
+import Timeline, {
+ TimelinePostInfoEx,
+ TimelineDeleteCallback,
+} from "./Timeline";
+import AppBar from "../common/AppBar";
+import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
+
+type TimelinePostSyncState = "syncing" | "synced" | "offline";
+
+const TimelinePostSyncStateBadge: React.FC<{
+ state: TimelinePostSyncState;
+ style?: CSSProperties;
+ className?: string;
+}> = ({ state, style, className }) => {
+ const { t } = useTranslation();
+
+ return (
+ <div style={style} className={clsx("timeline-sync-state-badge", className)}>
+ {(() => {
+ switch (state) {
+ 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 interface TimelineCardComponentProps<TManageItems> {
+ timeline: TimelineInfo;
+ onManage?: (item: TManageItems | "property") => void;
+ onMember: () => void;
+ className?: string;
+ onHeight?: (height: number) => void;
+}
+
+export interface TimelinePageTemplateUIProps<TManageItems> {
+ avatarKey?: string | number;
+ 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 [cardHeight, setCardHeight] = React.useState<number>(0);
+
+ const genCardCollapseLocalStorageKey = (uniqueId: string): string =>
+ `timeline.${uniqueId}.cardCollapse`;
+
+ const cardCollapseLocalStorageKey =
+ timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null;
+
+ const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true);
+ React.useEffect(() => {
+ if (cardCollapseLocalStorageKey != null) {
+ const savedCollapse =
+ window.localStorage.getItem(cardCollapseLocalStorageKey) === "true";
+ setInfoCardCollapse(savedCollapse);
+ }
+ }, [cardCollapseLocalStorageKey]);
+
+ let body: React.ReactElement;
+
+ if (props.error != null) {
+ body = <p className="text-danger">{t(props.error)}</p>;
+ } else {
+ if (timeline != null) {
+ let timelineBody: React.ReactElement;
+ if (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
+ ),
+ })
+ );
+
+ const topHeight: string = infoCardCollapse
+ ? "calc(68px + 1.5em)"
+ : `${cardHeight + 60}px`;
+
+ const syncState: TimelinePostSyncState = postListState.syncing
+ ? "syncing"
+ : postListState.type === "synced"
+ ? "synced"
+ : "offline";
+
+ timelineBody = (
+ <div>
+ <TimelinePostSyncStateBadge
+ style={{ top: topHeight }}
+ state={syncState}
+ />
+ <Timeline
+ containerRef={timelineRef}
+ posts={posts}
+ onDelete={props.onDelete}
+ onResize={triggerResizeEvent}
+ />
+ </div>
+ );
+ if (props.onPost != null) {
+ timelineBody = (
+ <>
+ {timelineBody}
+ <div ref={bottomSpaceRef} className="flex-fix-length" />
+ <TimelinePostEdit
+ onPost={props.onPost}
+ onHeightChange={onPostEditHeightChange}
+ timelineUniqueId={timeline.uniqueId}
+ />
+ </>
+ );
+ }
+ }
+ } else {
+ timelineBody = (
+ <div className="full-viewport-center-child">
+ <Spinner color="primary" type="grow" />
+ </div>
+ );
+ }
+ const { CardComponent } = props;
+
+ body = (
+ <>
+ <div
+ className="fixed-top mt-appbar info-card-container"
+ data-collapse={infoCardCollapse ? "true" : "false"}
+ >
+ <Svg
+ src={
+ infoCardCollapse
+ ? arrowsAngleExpandIcon
+ : arrowsAngleContractIcon
+ }
+ onClick={() => {
+ const newState = !infoCardCollapse;
+ setInfoCardCollapse(newState);
+ window.localStorage.setItem(
+ genCardCollapseLocalStorageKey(timeline.uniqueId),
+ newState.toString()
+ );
+ }}
+ className="float-right m-1 info-card-collapse-button text-primary icon-button"
+ />
+ <CardComponent
+ timeline={timeline}
+ onManage={props.onManage}
+ onMember={props.onMember}
+ onHeight={setCardHeight}
+ className="info-card-content"
+ />
+ </div>
+ {timelineBody}
+ </>
+ );
+ } else {
+ body = (
+ <div className="full-viewport-center-child">
+ <Spinner color="primary" type="grow" />
+ </div>
+ );
+ }
+ }
+
+ return (
+ <>
+ <AppBar />
+ <div>
+ <div
+ style={{ height: 56 + cardHeight }}
+ className="timeline-page-top-space flex-fix-length"
+ />
+ {body}
+ </div>
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
index 88cc2226..63751eeb 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
@@ -1,21 +1,21 @@
-import React from 'react';
-
-import { ExcludeKey } from '../utilities/type';
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps,
-} from './TimelinePageTemplateUI';
-import TimelineInfoCard, {
- OrdinaryTimelineManageItem,
-} from './TimelineInfoCard';
-
-export type TimelinePageUIProps = ExcludeKey<
- TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>,
- 'CardComponent'
->;
-
-const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => {
- return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
-};
-
-export default TimelinePageUI;
+import React from "react";
+
+import { ExcludeKey } from "../utilities/type";
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps,
+} from "./TimelinePageTemplateUI";
+import TimelineInfoCard, {
+ OrdinaryTimelineManageItem,
+} from "./TimelineInfoCard";
+
+export type TimelinePageUIProps = ExcludeKey<
+ TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>,
+ "CardComponent"
+>;
+
+const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => {
+ return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
+};
+
+export default TimelinePageUI;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
index d4d626ae..151df40a 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
@@ -1,234 +1,234 @@
-import React from 'react';
-import { Button, Spinner, Row, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import Svg from 'react-inlinesvg';
-
-import textIcon from 'bootstrap-icons/icons/card-text.svg';
-import imageIcon from 'bootstrap-icons/icons/image.svg';
-
-import { pushAlert } from '../common/alert-service';
-import { TimelineCreatePostRequest } from '../data/timeline';
-
-import FileInput from '../common/FileInput';
-import { UiLogicError } from '../common';
-
-interface TimelinePostEditImageProps {
- onSelect: (blob: Blob | null) => void;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
- const { onSelect } = props;
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [error, setError] = React.useState<string | null>(null);
-
- React.useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setFileUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- }
- }, [file]);
-
- const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
- (e) => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- setFileUrl(null);
- } else {
- setFile(files[0]);
- }
- onSelect(null);
- setError(null);
- },
- [onSelect]
- );
-
- const onImgLoad = React.useCallback(() => {
- onSelect(file);
- }, [onSelect, file]);
-
- const onImgError = React.useCallback(() => {
- setError('loadImageError');
- }, []);
-
- return (
- <>
- <FileInput
- labelText={t('chooseImage')}
- onChange={onInputChange}
- accept="image/*"
- className="mx-3 my-1"
- />
- {fileUrl && error == null && (
- <img
- src={fileUrl}
- className="timeline-post-edit-image"
- onLoad={onImgLoad}
- onError={onImgError}
- />
- )}
- {error != null && <div className="text-danger">{t(error)}</div>}
- </>
- );
-};
-
-export type TimelinePostSendCallback = (
- content: 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="container-fluid fixed-bottom bg-light">
- <Row>
- <Col className="px-1 py-1">
- {kind === 'text' ? (
- <textarea
- className="w-100 h-100 timeline-post-edit"
- value={text}
- disabled={state === 'process'}
- onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
- const value = event.currentTarget.value;
- setText(value);
- window.localStorage.setItem(draftLocalStorageKey, value);
- }}
- />
- ) : (
- <TimelinePostEditImage onSelect={onImageSelect} />
- )}
- </Col>
- <Col sm="col-auto align-self-end m-1">
- {(() => {
- if (state === 'input') {
- return (
- <>
- <div className="d-block text-center mt-1 mb-2">
- <Svg
- onLoad={notifyHeightChange}
- src={kind === 'text' ? imageIcon : textIcon}
- className="icon-button"
- onClick={toggleKind}
- />
- </div>
- <Button color="primary" onClick={onSend} disabled={!canSend}>
- {t('timeline.send')}
- </Button>
- </>
- );
- } else {
- return <Spinner />;
- }
- })()}
- </Col>
- </Row>
- </div>
- );
-};
-
-export default TimelinePostEdit;
+import React from "react";
+import { Button, Spinner, Row, Col } from "reactstrap";
+import { useTranslation } from "react-i18next";
+import Svg from "react-inlinesvg";
+
+import textIcon from "bootstrap-icons/icons/card-text.svg";
+import imageIcon from "bootstrap-icons/icons/image.svg";
+
+import { pushAlert } from "../common/alert-service";
+import { TimelineCreatePostRequest } from "../data/timeline";
+
+import FileInput from "../common/FileInput";
+import { UiLogicError } from "../common";
+
+interface TimelinePostEditImageProps {
+ onSelect: (blob: Blob | null) => void;
+}
+
+const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
+ const { onSelect } = props;
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setFileUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }
+ }, [file]);
+
+ const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
+ (e) => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ setFileUrl(null);
+ } else {
+ setFile(files[0]);
+ }
+ onSelect(null);
+ setError(null);
+ },
+ [onSelect]
+ );
+
+ const onImgLoad = React.useCallback(() => {
+ onSelect(file);
+ }, [onSelect, file]);
+
+ const onImgError = React.useCallback(() => {
+ setError("loadImageError");
+ }, []);
+
+ return (
+ <>
+ <FileInput
+ labelText={t("chooseImage")}
+ onChange={onInputChange}
+ accept="image/*"
+ className="mx-3 my-1"
+ />
+ {fileUrl && error == null && (
+ <img
+ src={fileUrl}
+ className="timeline-post-edit-image"
+ onLoad={onImgLoad}
+ onError={onImgError}
+ />
+ )}
+ {error != null && <div className="text-danger">{t(error)}</div>}
+ </>
+ );
+};
+
+export type TimelinePostSendCallback = (
+ content: 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="container-fluid fixed-bottom bg-light">
+ <Row>
+ <Col className="px-1 py-1">
+ {kind === "text" ? (
+ <textarea
+ className="w-100 h-100 timeline-post-edit"
+ value={text}
+ disabled={state === "process"}
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+ const value = event.currentTarget.value;
+ setText(value);
+ window.localStorage.setItem(draftLocalStorageKey, value);
+ }}
+ />
+ ) : (
+ <TimelinePostEditImage onSelect={onImageSelect} />
+ )}
+ </Col>
+ <Col sm="col-auto align-self-end m-1">
+ {(() => {
+ if (state === "input") {
+ return (
+ <>
+ <div className="d-block text-center mt-1 mb-2">
+ <Svg
+ onLoad={notifyHeightChange}
+ src={kind === "text" ? imageIcon : textIcon}
+ className="icon-button"
+ onClick={toggleKind}
+ />
+ </div>
+ <Button color="primary" onClick={onSend} disabled={!canSend}>
+ {t("timeline.send")}
+ </Button>
+ </>
+ );
+ } else {
+ return <Spinner />;
+ }
+ })()}
+ </Col>
+ </Row>
+ </div>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
index 93989884..a0eea775 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
@@ -1,72 +1,72 @@
-import React from 'react';
-
-import {
- TimelineVisibility,
- kTimelineVisibilities,
- TimelineChangePropertyRequest,
-} from '../data/timeline';
-
-import OperationDialog, {
- OperationSelectInputInfoOption,
-} from '../common/OperationDialog';
-
-export interface TimelinePropertyInfo {
- visibility: TimelineVisibility;
- description: string;
-}
-
-export interface TimelinePropertyChangeDialogProps {
- open: boolean;
- close: () => void;
- oldInfo: TimelinePropertyInfo;
- onProcess: (request: 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;
+import React from "react";
+
+import {
+ TimelineVisibility,
+ kTimelineVisibilities,
+ TimelineChangePropertyRequest,
+} from "../data/timeline";
+
+import OperationDialog, {
+ OperationSelectInputInfoOption,
+} from "../common/OperationDialog";
+
+export interface TimelinePropertyInfo {
+ visibility: TimelineVisibility;
+ description: string;
+}
+
+export interface TimelinePropertyChangeDialogProps {
+ open: boolean;
+ close: () => void;
+ oldInfo: TimelinePropertyInfo;
+ onProcess: (request: 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/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
index 667c1da9..79be64d3 100644
--- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
+++ b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
@@ -1,35 +1,35 @@
-.info-card-container
- .info-card-collapse-button
- z-index: 1
- position: relative
-
- .info-card-content
- width: 100%
- position: absolute
- transform-origin: right top
- transition: transform 0.5s
-
- &[data-collapse='true']
- .info-card-content
- transform: scale(0)
-
-.timeline-page-top-space
- transition: height 0.5s
-
-.timeline-sync-state-badge
- position: fixed
- top: 0
- right: 0
- z-index: 1
- 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
+.info-card-container
+ .info-card-collapse-button
+ z-index: 1
+ position: relative
+
+ .info-card-content
+ width: 100%
+ position: absolute
+ transform-origin: right top
+ transition: transform 0.5s
+
+ &[data-collapse='true']
+ .info-card-content
+ transform: scale(0)
+
+.timeline-page-top-space
+ transition: height 0.5s
+
+.timeline-sync-state-badge
+ position: fixed
+ top: 0
+ right: 0
+ z-index: 1
+ 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
diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass
index b224e973..d431a4c6 100644
--- a/Timeline/ClientApp/src/app/timeline/timeline.sass
+++ b/Timeline/ClientApp/src/app/timeline/timeline.sass
@@ -1,131 +1,131 @@
-@use 'sass:color'
-
-.timeline
- display: flex
- flex-direction: column
- z-index: 0
- position: relative
-
-@keyframes timeline-enter-animation-mask-animation
- to
- height: 0
-
-.timeline-enter-animation-mask
- position: absolute
- left: 0
- top: 0
- height: calc(100% + 300px)
- width: 100%
- background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white)
- z-index: 100
- animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable
-
-$timeline-line-width: 7px
-$timeline-line-node-radius: 18px
-$timeline-line-color: $primary
-$timeline-line-color-current: #36c2e6
-
-@keyframes timeline-line-node-noncurrent
- from
- background: $timeline-line-color
-
- to
- background: color.adjust($timeline-line-color, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
-
-
-@keyframes timeline-line-node-current
- from
- background: $timeline-line-color-current
-
- to
- background: color.adjust($timeline-line-color-current, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
-
-.timeline-line
- &-area
- display: flex
- flex-direction: column
- align-items: center
- flex: 0 0 auto
- width: 60px
-
- &-segment
- width: $timeline-line-width
- background: $timeline-line-color
-
- &.start
- height: 20px
- flex: 0 0 auto
-
- &.end
- flex: 1 1 auto
-
- &.current-end
- height: 20px
- flex: 0 0 auto
- background: linear-gradient($timeline-line-color-current, transparent)
-
- &-node-container
- flex: 0 0 auto
- position: relative
- width: $timeline-line-node-radius
- height: $timeline-line-node-radius
-
- &-node
- width: $timeline-line-node-radius + 2
- height: $timeline-line-node-radius + 2
- position: absolute
- left: -1px
- top: -1px
- border-radius: 50%
- box-sizing: border-box
- z-index: 1
- animation: 1s infinite alternate
- animation-name: timeline-line-node-noncurrent
-
-
-.current
- .timeline-line
- &-segment
-
- &.start
- background: linear-gradient($timeline-line-color, $timeline-line-color-current)
-
- &.end
- background: $timeline-line-color-current
-
- &-node
- animation-name: timeline-line-node-current
-
-.timeline-pt-start
- padding-top: 18px
-
-.timeline-item-delete-button
- position: absolute
- right: 0
- bottom: 0
-
-.timeline-content
- white-space: pre-line
-
-.timeline-content-image
- max-width: 60%
- max-height: 200px
-
-
-.timeline-post-edit-image
- max-width: 100px
- max-height: 100px
-
-.mask
- background: change-color($color: white, $alpha: 0.8)
- z-index: 100
-
-textarea.timeline-post-edit
- @extend .border-primary
- @extend .rounded
-
- &:focus
- outline: none
- box-shadow: 0 0 5px 0 $primary
+@use 'sass:color'
+
+.timeline
+ display: flex
+ flex-direction: column
+ z-index: 0
+ position: relative
+
+@keyframes timeline-enter-animation-mask-animation
+ to
+ height: 0
+
+.timeline-enter-animation-mask
+ position: absolute
+ left: 0
+ top: 0
+ height: calc(100% + 300px)
+ width: 100%
+ background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white)
+ z-index: 100
+ animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable
+
+$timeline-line-width: 7px
+$timeline-line-node-radius: 18px
+$timeline-line-color: $primary
+$timeline-line-color-current: #36c2e6
+
+@keyframes timeline-line-node-noncurrent
+ from
+ background: $timeline-line-color
+
+ to
+ background: color.adjust($timeline-line-color, $lightness: +10%)
+ box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
+
+
+@keyframes timeline-line-node-current
+ from
+ background: $timeline-line-color-current
+
+ to
+ background: color.adjust($timeline-line-color-current, $lightness: +10%)
+ box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
+
+.timeline-line
+ &-area
+ display: flex
+ flex-direction: column
+ align-items: center
+ flex: 0 0 auto
+ width: 60px
+
+ &-segment
+ width: $timeline-line-width
+ background: $timeline-line-color
+
+ &.start
+ height: 20px
+ flex: 0 0 auto
+
+ &.end
+ flex: 1 1 auto
+
+ &.current-end
+ height: 20px
+ flex: 0 0 auto
+ background: linear-gradient($timeline-line-color-current, transparent)
+
+ &-node-container
+ flex: 0 0 auto
+ position: relative
+ width: $timeline-line-node-radius
+ height: $timeline-line-node-radius
+
+ &-node
+ width: $timeline-line-node-radius + 2
+ height: $timeline-line-node-radius + 2
+ position: absolute
+ left: -1px
+ top: -1px
+ border-radius: 50%
+ box-sizing: border-box
+ z-index: 1
+ animation: 1s infinite alternate
+ animation-name: timeline-line-node-noncurrent
+
+
+.current
+ .timeline-line
+ &-segment
+
+ &.start
+ background: linear-gradient($timeline-line-color, $timeline-line-color-current)
+
+ &.end
+ background: $timeline-line-color-current
+
+ &-node
+ animation-name: timeline-line-node-current
+
+.timeline-pt-start
+ padding-top: 18px
+
+.timeline-item-delete-button
+ position: absolute
+ right: 0
+ bottom: 0
+
+.timeline-content
+ white-space: pre-line
+
+.timeline-content-image
+ max-width: 60%
+ max-height: 200px
+
+
+.timeline-post-edit-image
+ max-width: 100px
+ max-height: 100px
+
+.mask
+ background: change-color($color: white, $alpha: 0.8)
+ z-index: 100
+
+textarea.timeline-post-edit
+ @extend .border-primary
+ @extend .rounded
+
+ &:focus
+ outline: none
+ box-shadow: 0 0 5px 0 $primary
diff --git a/Timeline/ClientApp/src/app/tsconfig.json b/Timeline/ClientApp/src/app/tsconfig.json
index 17ee69cb..14e6327f 100644
--- a/Timeline/ClientApp/src/app/tsconfig.json
+++ b/Timeline/ClientApp/src/app/tsconfig.json
@@ -1,13 +1,13 @@
-{
- "extends": "../tsconfig.json",
- "compilerOptions": {
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ]
- },
- "include": [
- "."
- ]
-}
+{
+ "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
index 521d38e4..34381682 100644
--- a/Timeline/ClientApp/src/app/typings.d.ts
+++ b/Timeline/ClientApp/src/app/typings.d.ts
@@ -1,24 +1,24 @@
-declare module '*.png' {
- const content: string;
- export default content;
-}
-
-declare module '*.jpeg' {
- const content: string;
- export default content;
-}
-
-declare module '*.jpg' {
- const content: string;
- export default content;
-}
-
-declare module '*.gif' {
- const content: string;
- export default content;
-}
-
-declare module '*.svg' {
- const content: string;
- export default content;
-}
+declare module "*.png" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.jpeg" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.jpg" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.gif" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.svg" {
+ const content: string;
+ export default content;
+}
diff --git a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx
index f7b25252..7d9f9514 100644
--- a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx
+++ b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx
@@ -1,306 +1,306 @@
-import React, { useState, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- Modal,
- ModalHeader,
- Row,
- Button,
- ModalBody,
- ModalFooter,
-} from 'reactstrap';
-import { AxiosError } from 'axios';
-
-import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper';
-import { UiLogicError } from '../common';
-
-export interface ChangeAvatarDialogProps {
- open: boolean;
- close: () => void;
- process: (blob: Blob) => Promise<void>;
-}
-
-const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [clip, setClip] = React.useState<Clip | null>(null);
- const [
- cropImgElement,
- setCropImgElement,
- ] = React.useState<HTMLImageElement | null>(null);
- const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
- const [resultUrl, setResultUrl] = React.useState<string | null>(null);
-
- const [state, setState] = React.useState<
- | 'select'
- | 'crop'
- | 'processcrop'
- | 'preview'
- | 'uploading'
- | 'success'
- | 'error'
- >('select');
-
- const [message, setMessage] = useState<
- string | { type: 'custom'; text: string } | null
- >('userPage.dialogChangeAvatar.prompt.select');
-
- const trueMessage =
- message == null
- ? null
- : typeof message === 'string'
- ? t(message)
- : message.text;
-
- const closeDialog = props.close;
-
- const toggle = React.useCallback((): void => {
- if (!(state === 'uploading')) {
- closeDialog();
- }
- }, [state, closeDialog]);
-
- useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setClip(null);
- setFileUrl(url);
- setState('crop');
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setFileUrl(null);
- setState('select');
- }
- }, [file]);
-
- React.useEffect(() => {
- if (resultBlob != null) {
- const url = URL.createObjectURL(resultBlob);
- setResultUrl(url);
- setState('preview');
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setResultUrl(null);
- }
- }, [resultBlob]);
-
- const onSelectFile = React.useCallback(
- (e: React.ChangeEvent<HTMLInputElement>): void => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- } else {
- setFile(files[0]);
- }
- },
- []
- );
-
- const onCropNext = React.useCallback(() => {
- if (
- cropImgElement == null ||
- clip == null ||
- clip.width === 0 ||
- file == null
- ) {
- throw new UiLogicError();
- }
-
- setState('processcrop');
- void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
- setResultBlob(b);
- });
- }, [cropImgElement, clip, file]);
-
- const onCropPrevious = React.useCallback(() => {
- setFile(null);
- setState('select');
- }, []);
-
- const onPreviewPrevious = React.useCallback(() => {
- setResultBlob(null);
- setState('crop');
- }, []);
-
- const process = props.process;
-
- const upload = React.useCallback(() => {
- if (resultBlob == null) {
- throw new UiLogicError();
- }
-
- setState('uploading');
- process(resultBlob).then(
- () => {
- setState('success');
- },
- (e: unknown) => {
- setState('error');
- setMessage({ type: 'custom', text: (e as AxiosError).message });
- }
- );
- }, [resultBlob, process]);
-
- const createPreviewRow = (): React.ReactElement => {
- if (resultUrl == null) {
- throw new UiLogicError();
- }
- return (
- <Row className="justify-content-center">
- <img
- className="change-avatar-img"
- src={resultUrl}
- alt={t('userPage.dialogChangeAvatar.previewImgAlt')}
- />
- </Row>
- );
- };
-
- return (
- <Modal isOpen={props.open} toggle={toggle}>
- <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader>
- {(() => {
- if (state === 'select') {
- return (
- <>
- <ModalBody className="container">
- <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row>
- <Row>
- <input type="file" accept="image/*" onChange={onSelectFile} />
- </Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'crop') {
- if (fileUrl == null) {
- throw new UiLogicError();
- }
- return (
- <>
- <ModalBody className="container">
- <Row className="justify-content-center">
- <ImageCropper
- clip={clip}
- onChange={setClip}
- imageUrl={fileUrl}
- imageElementCallback={setCropImgElement}
- />
- </Row>
- <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="secondary" onClick={onCropPrevious}>
- {t('operationDialog.previousStep')}
- </Button>
- <Button
- color="primary"
- onClick={onCropNext}
- disabled={
- cropImgElement == null || clip == null || clip.width === 0
- }
- >
- {t('operationDialog.nextStep')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'processcrop') {
- return (
- <>
- <ModalBody className="container">
- <Row>
- {t('userPage.dialogChangeAvatar.prompt.processingCrop')}
- </Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="secondary" onClick={onPreviewPrevious}>
- {t('operationDialog.previousStep')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'preview') {
- return (
- <>
- <ModalBody className="container">
- {createPreviewRow()}
- <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="secondary" onClick={onPreviewPrevious}>
- {t('operationDialog.previousStep')}
- </Button>
- <Button color="primary" onClick={upload}>
- {t('userPage.dialogChangeAvatar.upload')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (state === 'uploading') {
- return (
- <>
- <ModalBody className="container">
- {createPreviewRow()}
- <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row>
- </ModalBody>
- <ModalFooter></ModalFooter>
- </>
- );
- } else if (state === 'success') {
- return (
- <>
- <ModalBody className="container">
- <Row className="p-4 text-success">
- {t('operationDialog.success')}
- </Row>
- </ModalBody>
- <ModalFooter>
- <Button color="success" onClick={toggle}>
- {t('operationDialog.ok')}
- </Button>
- </ModalFooter>
- </>
- );
- } else {
- return (
- <>
- <ModalBody className="container">
- {createPreviewRow()}
- <Row className="text-danger">{trueMessage}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button color="primary" onClick={upload}>
- {t('operationDialog.retry')}
- </Button>
- </ModalFooter>
- </>
- );
- }
- })()}
- </Modal>
- );
-};
-
-export default ChangeAvatarDialog;
+import React, { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Modal,
+ ModalHeader,
+ Row,
+ Button,
+ ModalBody,
+ ModalFooter,
+} from "reactstrap";
+import { AxiosError } from "axios";
+
+import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
+import { UiLogicError } from "../common";
+
+export interface ChangeAvatarDialogProps {
+ open: boolean;
+ close: () => void;
+ process: (blob: Blob) => Promise<void>;
+}
+
+const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [clip, setClip] = React.useState<Clip | null>(null);
+ const [
+ cropImgElement,
+ setCropImgElement,
+ ] = React.useState<HTMLImageElement | null>(null);
+ const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
+ const [resultUrl, setResultUrl] = React.useState<string | null>(null);
+
+ const [state, setState] = React.useState<
+ | "select"
+ | "crop"
+ | "processcrop"
+ | "preview"
+ | "uploading"
+ | "success"
+ | "error"
+ >("select");
+
+ const [message, setMessage] = useState<
+ string | { type: "custom"; text: string } | null
+ >("userPage.dialogChangeAvatar.prompt.select");
+
+ const trueMessage =
+ message == null
+ ? null
+ : typeof message === "string"
+ ? t(message)
+ : message.text;
+
+ const closeDialog = props.close;
+
+ const toggle = React.useCallback((): void => {
+ if (!(state === "uploading")) {
+ closeDialog();
+ }
+ }, [state, closeDialog]);
+
+ useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setClip(null);
+ setFileUrl(url);
+ setState("crop");
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setFileUrl(null);
+ setState("select");
+ }
+ }, [file]);
+
+ React.useEffect(() => {
+ if (resultBlob != null) {
+ const url = URL.createObjectURL(resultBlob);
+ setResultUrl(url);
+ setState("preview");
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setResultUrl(null);
+ }
+ }, [resultBlob]);
+
+ const onSelectFile = React.useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ },
+ []
+ );
+
+ const onCropNext = React.useCallback(() => {
+ if (
+ cropImgElement == null ||
+ clip == null ||
+ clip.width === 0 ||
+ file == null
+ ) {
+ throw new UiLogicError();
+ }
+
+ setState("processcrop");
+ void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+ setResultBlob(b);
+ });
+ }, [cropImgElement, clip, file]);
+
+ const onCropPrevious = React.useCallback(() => {
+ setFile(null);
+ setState("select");
+ }, []);
+
+ const onPreviewPrevious = React.useCallback(() => {
+ setResultBlob(null);
+ setState("crop");
+ }, []);
+
+ const process = props.process;
+
+ const upload = React.useCallback(() => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ process(resultBlob).then(
+ () => {
+ setState("success");
+ },
+ (e: unknown) => {
+ setState("error");
+ setMessage({ type: "custom", text: (e as AxiosError).message });
+ }
+ );
+ }, [resultBlob, process]);
+
+ const createPreviewRow = (): React.ReactElement => {
+ if (resultUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <Row className="justify-content-center">
+ <img
+ className="change-avatar-img"
+ src={resultUrl}
+ alt={t("userPage.dialogChangeAvatar.previewImgAlt")}
+ />
+ </Row>
+ );
+ };
+
+ return (
+ <Modal isOpen={props.open} toggle={toggle}>
+ <ModalHeader> {t("userPage.dialogChangeAvatar.title")}</ModalHeader>
+ {(() => {
+ if (state === "select") {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>{t("userPage.dialogChangeAvatar.prompt.select")}</Row>
+ <Row>
+ <input type="file" accept="image/*" onChange={onSelectFile} />
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === "crop") {
+ if (fileUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="justify-content-center">
+ <ImageCropper
+ clip={clip}
+ onChange={setClip}
+ imageUrl={fileUrl}
+ imageElementCallback={setCropImgElement}
+ />
+ </Row>
+ <Row>{t("userPage.dialogChangeAvatar.prompt.crop")}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button color="secondary" onClick={onCropPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ <Button
+ color="primary"
+ onClick={onCropNext}
+ disabled={
+ cropImgElement == null || clip == null || clip.width === 0
+ }
+ >
+ {t("operationDialog.nextStep")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === "processcrop") {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>
+ {t("userPage.dialogChangeAvatar.prompt.processingCrop")}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === "preview") {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t("userPage.dialogChangeAvatar.prompt.preview")}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t("userPage.dialogChangeAvatar.upload")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === "uploading") {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t("userPage.dialogChangeAvatar.prompt.uploading")}</Row>
+ </ModalBody>
+ <ModalFooter></ModalFooter>
+ </>
+ );
+ } else if (state === "success") {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="p-4 text-success">
+ {t("operationDialog.success")}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="success" onClick={toggle}>
+ {t("operationDialog.ok")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row className="text-danger">{trueMessage}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t("operationDialog.retry")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+ })()}
+ </Modal>
+ );
+};
+
+export default ChangeAvatarDialog;
diff --git a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx
index 393d88f6..251b18c5 100644
--- a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx
+++ b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx
@@ -1,28 +1,28 @@
-import React from 'react';
-
-import OperationDialog from '../common/OperationDialog';
-
-export interface ChangeNicknameDialogProps {
- open: boolean;
- close: () => void;
- onProcess: (newNickname: string) => Promise<void>;
-}
-
-const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = props => {
- return (
- <OperationDialog
- open={props.open}
- title="userPage.dialogChangeNickname.title"
- titleColor="default"
- inputScheme={[
- { type: 'text', label: 'userPage.dialogChangeNickname.inputLabel' }
- ]}
- onProcess={([newNickname]) => {
- return props.onProcess(newNickname as string);
- }}
- close={props.close}
- />
- );
-};
-
-export default ChangeNicknameDialog;
+import React from "react";
+
+import OperationDialog from "../common/OperationDialog";
+
+export interface ChangeNicknameDialogProps {
+ open: boolean;
+ close: () => void;
+ onProcess: (newNickname: string) => Promise<void>;
+}
+
+const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
+ return (
+ <OperationDialog
+ open={props.open}
+ title="userPage.dialogChangeNickname.title"
+ titleColor="default"
+ inputScheme={[
+ { type: "text", label: "userPage.dialogChangeNickname.inputLabel" },
+ ]}
+ onProcess={([newNickname]) => {
+ return props.onProcess(newNickname as string);
+ }}
+ close={props.close}
+ />
+ );
+};
+
+export default ChangeNicknameDialog;
diff --git a/Timeline/ClientApp/src/app/user/Login.tsx b/Timeline/ClientApp/src/app/user/Login.tsx
index 2f2a3188..4f9cd05d 100644
--- a/Timeline/ClientApp/src/app/user/Login.tsx
+++ b/Timeline/ClientApp/src/app/user/Login.tsx
@@ -1,148 +1,148 @@
-import React, { Fragment, useState, useEffect } from 'react';
-import { useHistory } from 'react-router';
-import { useTranslation } from 'react-i18next';
-
-import AppBar from '../common/AppBar';
-
-import { useUser, userService } from '../data/user';
-import {
- Label,
- FormGroup,
- Input,
- Form,
- FormFeedback,
- Spinner,
- Button,
-} from 'reactstrap';
-
-const Login: React.FC = (_) => {
- const { t } = useTranslation();
- const history = useHistory();
- const [username, setUsername] = useState<string>('');
- const [usernameDirty, setUsernameDirty] = useState<boolean>(false);
- const [password, setPassword] = useState<string>('');
- const [passwordDirty, setPasswordDirty] = useState<boolean>(false);
- const [rememberMe, setRememberMe] = useState<boolean>(true);
- const [process, setProcess] = useState<boolean>(false);
- const [error, setError] = useState<string | null>(null);
-
- const user = useUser();
-
- useEffect(() => {
- if (user != null) {
- const id = setTimeout(() => history.push('/'), 3000);
- return () => {
- clearTimeout(id);
- };
- }
- }, [history, user]);
-
- if (user != null) {
- return (
- <>
- <AppBar />
- <p className="mt-appbar">{t('login.alreadyLogin')}</p>
- </>
- );
- }
-
- function onSubmit(event: React.SyntheticEvent): void {
- if (username === '' || password === '') {
- setUsernameDirty(true);
- setPasswordDirty(true);
- return;
- }
-
- setProcess(true);
- userService
- .login(
- {
- username: username,
- password: password,
- },
- rememberMe
- )
- .then(
- () => {
- if (history.length === 0) {
- history.push('/');
- } else {
- history.goBack();
- }
- },
- (e: Error) => {
- setProcess(false);
- setError(e.message);
- }
- );
- event.preventDefault();
- }
-
- return (
- <Fragment>
- <AppBar />
- <div className="container login-container mt-appbar">
- <h1>{t('welcome')}</h1>
- <Form>
- <FormGroup>
- <Label for="username">{t('user.username')}</Label>
- <Input
- id="username"
- disabled={process}
- onChange={(e) => {
- setUsername(e.target.value);
- setUsernameDirty(true);
- }}
- value={username}
- invalid={usernameDirty && username === ''}
- />
- {usernameDirty && username === '' && (
- <FormFeedback>{t('login.emptyUsername')}</FormFeedback>
- )}
- </FormGroup>
- <FormGroup>
- <Label for="password">{t('user.password')}</Label>
- <Input
- id="password"
- type="password"
- disabled={process}
- onChange={(e) => {
- setPassword(e.target.value);
- setPasswordDirty(true);
- }}
- value={password}
- invalid={passwordDirty && password === ''}
- />
- {passwordDirty && password === '' && (
- <FormFeedback>{t('login.emptyPassword')}</FormFeedback>
- )}
- </FormGroup>
- <FormGroup check>
- <Input
- id="remember-me"
- type="checkbox"
- checked={rememberMe}
- onChange={(e) => {
- const v = (e.target as HTMLInputElement).checked;
- setRememberMe(v);
- }}
- />
- <Label for="remember-me">{t('user.rememberMe')}</Label>
- </FormGroup>
- {error ? <p className="text-error">{t(error)}</p> : null}
- <div>
- {process ? (
- <Spinner />
- ) : (
- <Button color="primary" onClick={onSubmit}>
- {t('user.login')}
- </Button>
- )}
- </div>
- </Form>
- </div>
- </Fragment>
- );
-};
-
-export default Login;
+import React, { Fragment, useState, useEffect } from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+
+import AppBar from "../common/AppBar";
+
+import { useUser, userService } from "../data/user";
+import {
+ Label,
+ FormGroup,
+ Input,
+ Form,
+ FormFeedback,
+ Spinner,
+ Button,
+} from "reactstrap";
+
+const Login: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const [username, setUsername] = useState<string>("");
+ const [usernameDirty, setUsernameDirty] = useState<boolean>(false);
+ const [password, setPassword] = useState<string>("");
+ const [passwordDirty, setPasswordDirty] = useState<boolean>(false);
+ const [rememberMe, setRememberMe] = useState<boolean>(true);
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const user = useUser();
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => history.push("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p className="mt-appbar">{t("login.alreadyLogin")}</p>
+ </>
+ );
+ }
+
+ function onSubmit(event: React.SyntheticEvent): void {
+ if (username === "" || password === "") {
+ setUsernameDirty(true);
+ setPasswordDirty(true);
+ return;
+ }
+
+ setProcess(true);
+ userService
+ .login(
+ {
+ username: username,
+ password: password,
+ },
+ rememberMe
+ )
+ .then(
+ () => {
+ if (history.length === 0) {
+ history.push("/");
+ } else {
+ history.goBack();
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ event.preventDefault();
+ }
+
+ return (
+ <Fragment>
+ <AppBar />
+ <div className="container login-container mt-appbar">
+ <h1>{t("welcome")}</h1>
+ <Form>
+ <FormGroup>
+ <Label for="username">{t("user.username")}</Label>
+ <Input
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ invalid={usernameDirty && username === ""}
+ />
+ {usernameDirty && username === "" && (
+ <FormFeedback>{t("login.emptyUsername")}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup>
+ <Label for="password">{t("user.password")}</Label>
+ <Input
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ invalid={passwordDirty && password === ""}
+ />
+ {passwordDirty && password === "" && (
+ <FormFeedback>{t("login.emptyPassword")}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup check>
+ <Input
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ const v = (e.target as HTMLInputElement).checked;
+ setRememberMe(v);
+ }}
+ />
+ <Label for="remember-me">{t("user.rememberMe")}</Label>
+ </FormGroup>
+ {error ? <p className="text-error">{t(error)}</p> : null}
+ <div>
+ {process ? (
+ <Spinner />
+ ) : (
+ <Button color="primary" onClick={onSubmit}>
+ {t("user.login")}
+ </Button>
+ )}
+ </div>
+ </Form>
+ </div>
+ </Fragment>
+ );
+};
+
+export default Login;
diff --git a/Timeline/ClientApp/src/app/user/User.tsx b/Timeline/ClientApp/src/app/user/User.tsx
index 67c9d921..6f61cacf 100644
--- a/Timeline/ClientApp/src/app/user/User.tsx
+++ b/Timeline/ClientApp/src/app/user/User.tsx
@@ -1,77 +1,77 @@
-import React, { useState } from 'react';
-import { useParams } from 'react-router';
-
-import { UiLogicError } from '../common';
-import { useUser, userInfoService } from '../data/user';
-import { changeNickname } from './api';
-
-import UserPage from './UserPage';
-import ChangeNicknameDialog from './ChangeNicknameDialog';
-import ChangeAvatarDialog from './ChangeAvatarDialog';
-import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
-import { PersonalTimelineManageItem } from './UserInfoCard';
-
-const User: React.FC = (_) => {
- const { username } = useParams<{ username: string }>();
-
- const user = useUser();
-
- const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
- const [dataKey, setDataKey] = useState<number>(0);
-
- let dialogElement: React.ReactElement | undefined;
-
- const closeDialogHandler = (): void => {
- setDialog(null);
- };
-
- if (dialog === 'nickname') {
- if (user == null) {
- throw new UiLogicError('Change nickname without login.');
- }
-
- dialogElement = (
- <ChangeNicknameDialog
- open
- close={closeDialogHandler}
- onProcess={(newNickname) => {
- const p = changeNickname(user.token, username, newNickname);
- return p.then((_) => {
- setDataKey(dataKey + 1);
- });
- }}
- />
- );
- } else if (dialog === 'avatar') {
- if (user == null) {
- throw new UiLogicError('Change avatar without login.');
- }
-
- dialogElement = (
- <ChangeAvatarDialog
- open
- close={closeDialogHandler}
- process={(file) => userInfoService.setAvatar(username, file)}
- />
- );
- }
-
- const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
- setDialog(item);
- }, []);
-
- return (
- <>
- <TimelinePageTemplate
- dataVersion={dataKey}
- name={`@${username}`}
- UiComponent={UserPage}
- onManage={onManage}
- notFoundI18nKey="timeline.userNotExist"
- />
- {dialogElement}
- </>
- );
-};
-
-export default User;
+import React, { useState } from "react";
+import { useParams } from "react-router";
+
+import { UiLogicError } from "../common";
+import { useUser, userInfoService } from "../data/user";
+import { changeNickname } from "./api";
+
+import UserPage from "./UserPage";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+import TimelinePageTemplate from "../timeline/TimelinePageTemplate";
+import { PersonalTimelineManageItem } from "./UserInfoCard";
+
+const User: React.FC = (_) => {
+ const { username } = useParams<{ username: string }>();
+
+ const user = useUser();
+
+ const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
+ const [dataKey, setDataKey] = useState<number>(0);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ const closeDialogHandler = (): void => {
+ setDialog(null);
+ };
+
+ if (dialog === "nickname") {
+ if (user == null) {
+ throw new UiLogicError("Change nickname without login.");
+ }
+
+ dialogElement = (
+ <ChangeNicknameDialog
+ open
+ close={closeDialogHandler}
+ onProcess={(newNickname) => {
+ const p = changeNickname(user.token, username, newNickname);
+ return p.then((_) => {
+ setDataKey(dataKey + 1);
+ });
+ }}
+ />
+ );
+ } else if (dialog === "avatar") {
+ if (user == null) {
+ throw new UiLogicError("Change avatar without login.");
+ }
+
+ dialogElement = (
+ <ChangeAvatarDialog
+ open
+ close={closeDialogHandler}
+ process={(file) => userInfoService.setAvatar(username, file)}
+ />
+ );
+ }
+
+ const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
+ setDialog(item);
+ }, []);
+
+ return (
+ <>
+ <TimelinePageTemplate
+ dataVersion={dataKey}
+ name={`@${username}`}
+ UiComponent={UserPage}
+ onManage={onManage}
+ notFoundI18nKey="timeline.userNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default User;
diff --git a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
index 0f321f32..9ce46e3c 100644
--- a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
@@ -1,105 +1,105 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-
-import { timelineVisibilityTooltipTranslationMap } from '../data/timeline';
-import { useAvatar } from '../data/user';
-
-import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI';
-import BlobImage from '../common/BlobImage';
-
-export type PersonalTimelineManageItem = 'avatar' | 'nickname';
-
-export type UserInfoCardProps = TimelineCardComponentProps<
- PersonalTimelineManageItem
->;
-
-const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
- const { onHeight, onManage } = props;
- const { t } = useTranslation();
-
- const avatar = useAvatar(props.timeline.owner.username);
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeight = React.useCallback((): void => {
- if (onHeight) {
- onHeight(containerRef.current.getBoundingClientRect().height);
- }
- }, [onHeight]);
-
- React.useEffect(() => {
- const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
- return () => subscription.unsubscribe();
- });
-
- const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
- false
- );
- const toggleManageDropdown = React.useCallback(
- (): void => setManageDropdownOpen((old) => !old),
- []
- );
-
- return (
- <div
- ref={containerRef}
- className={clsx('rounded border bg-light p-2', props.className)}
- onTransitionEnd={notifyHeight}
- >
- <BlobImage
- blob={avatar}
- onLoad={notifyHeight}
- className="avatar large mr-2 mb-2 rounded-circle float-left"
- />
- <div>
- {props.timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{props.timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{props.timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
- <DropdownToggle outline color="primary">
- {t('timeline.manage')}
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={() => onManage('nickname')}>
- {t('timeline.manageItem.nickname')}
- </DropdownItem>
- <DropdownItem onClick={() => onManage('avatar')}>
- {t('timeline.manageItem.avatar')}
- </DropdownItem>
- <DropdownItem onClick={() => onManage('property')}>
- {t('timeline.manageItem.property')}
- </DropdownItem>
- <DropdownItem onClick={props.onMember}>
- {t('timeline.manageItem.member')}
- </DropdownItem>
- </DropdownMenu>
- </Dropdown>
- ) : (
- <Button color="primary" outline onClick={props.onMember}>
- {t('timeline.memberButton')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default UserInfoCard;
+import React from "react";
+import clsx from "clsx";
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from "reactstrap";
+import { useTranslation } from "react-i18next";
+import { fromEvent } from "rxjs";
+
+import { timelineVisibilityTooltipTranslationMap } from "../data/timeline";
+import { useAvatar } from "../data/user";
+
+import { TimelineCardComponentProps } from "../timeline/TimelinePageTemplateUI";
+import BlobImage from "../common/BlobImage";
+
+export type PersonalTimelineManageItem = "avatar" | "nickname";
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(props.timeline.owner.username);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeight = React.useCallback((): void => {
+ if (onHeight) {
+ onHeight(containerRef.current.getBoundingClientRect().height);
+ }
+ }, [onHeight]);
+
+ React.useEffect(() => {
+ const subscription = fromEvent(window, "resize").subscribe(notifyHeight);
+ return () => subscription.unsubscribe();
+ });
+
+ const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx("rounded border bg-light p-2", props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <BlobImage
+ blob={avatar}
+ onLoad={notifyHeight}
+ className="avatar large mr-2 mb-2 rounded-circle float-left"
+ />
+ <div>
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t("timeline.manage")}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={() => onManage("nickname")}>
+ {t("timeline.manageItem.nickname")}
+ </DropdownItem>
+ <DropdownItem onClick={() => onManage("avatar")}>
+ {t("timeline.manageItem.avatar")}
+ </DropdownItem>
+ <DropdownItem onClick={() => onManage("property")}>
+ {t("timeline.manageItem.property")}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t("timeline.manageItem.member")}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t("timeline.memberButton")}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default UserInfoCard;
diff --git a/Timeline/ClientApp/src/app/user/UserPage.tsx b/Timeline/ClientApp/src/app/user/UserPage.tsx
index 4c9bd747..42f174d9 100644
--- a/Timeline/ClientApp/src/app/user/UserPage.tsx
+++ b/Timeline/ClientApp/src/app/user/UserPage.tsx
@@ -1,19 +1,19 @@
-import React from 'react';
-
-import { ExcludeKey } from '../utilities/type';
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps,
-} from '../timeline/TimelinePageTemplateUI';
-import UserInfoCard, { PersonalTimelineManageItem } from './UserInfoCard';
-
-export type UserPageProps = ExcludeKey<
- TimelinePageTemplateUIProps<PersonalTimelineManageItem>,
- 'CardComponent'
->;
-
-const UserPage: React.FC<UserPageProps> = (props) => {
- return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
-};
-
-export default UserPage;
+import React from "react";
+
+import { ExcludeKey } from "../utilities/type";
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps,
+} from "../timeline/TimelinePageTemplateUI";
+import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard";
+
+export type UserPageProps = ExcludeKey<
+ TimelinePageTemplateUIProps<PersonalTimelineManageItem>,
+ "CardComponent"
+>;
+
+const UserPage: React.FC<UserPageProps> = (props) => {
+ return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
+};
+
+export default UserPage;
diff --git a/Timeline/ClientApp/src/app/user/api.ts b/Timeline/ClientApp/src/app/user/api.ts
index b2026861..e69acbdb 100644
--- a/Timeline/ClientApp/src/app/user/api.ts
+++ b/Timeline/ClientApp/src/app/user/api.ts
@@ -1,10 +1,10 @@
-import { getHttpUserClient } from '../http/user';
-import { User } from '../data/user';
-
-export function changeNickname(
- token: string,
- username: string,
- newNickname: string
-): Promise<User> {
- return getHttpUserClient().patch(username, { nickname: newNickname }, token);
-}
+import { getHttpUserClient } from "../http/user";
+import { User } from "../data/user";
+
+export function changeNickname(
+ token: string,
+ username: string,
+ newNickname: string
+): Promise<User> {
+ return getHttpUserClient().patch(username, { nickname: newNickname }, token);
+}
diff --git a/Timeline/ClientApp/src/app/user/user-page.sass b/Timeline/ClientApp/src/app/user/user-page.sass
index 1e2e802b..ca2d10f5 100644
--- a/Timeline/ClientApp/src/app/user/user-page.sass
+++ b/Timeline/ClientApp/src/app/user/user-page.sass
@@ -1,11 +1,10 @@
-.login-container
- max-width: 600px
-
-.change-avatar-cropper-row
- max-height: 400px
-
-.change-avatar-img
- min-width: 50%
- max-width: 100%
- max-height: 400px
-
+.login-container
+ max-width: 600px
+
+.change-avatar-cropper-row
+ max-height: 400px
+
+.change-avatar-img
+ min-width: 50%
+ max-width: 100%
+ max-height: 400px
diff --git a/Timeline/ClientApp/src/app/utilities/rxjs.ts b/Timeline/ClientApp/src/app/utilities/rxjs.ts
index 1be49164..0730b899 100644
--- a/Timeline/ClientApp/src/app/utilities/rxjs.ts
+++ b/Timeline/ClientApp/src/app/utilities/rxjs.ts
@@ -1,14 +1,14 @@
-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;
- });
-}
+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/type.ts b/Timeline/ClientApp/src/app/utilities/type.ts
index 307e5825..8df9bf0f 100644
--- a/Timeline/ClientApp/src/app/utilities/type.ts
+++ b/Timeline/ClientApp/src/app/utilities/type.ts
@@ -1 +1 @@
-export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
+export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
diff --git a/Timeline/ClientApp/src/app/utilities/url.ts b/Timeline/ClientApp/src/app/utilities/url.ts
index 90923fd2..17ead5b2 100644
--- a/Timeline/ClientApp/src/app/utilities/url.ts
+++ b/Timeline/ClientApp/src/app/utilities/url.ts
@@ -1,52 +1,52 @@
-//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;
-}
+//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/sw/sw.ts b/Timeline/ClientApp/src/sw/sw.ts
index 9d62757a..d6202f36 100644
--- a/Timeline/ClientApp/src/sw/sw.ts
+++ b/Timeline/ClientApp/src/sw/sw.ts
@@ -1,28 +1,28 @@
-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);
-});
+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
index 71fc0bcd..aac99e59 100644
--- a/Timeline/ClientApp/src/sw/tsconfig.json
+++ b/Timeline/ClientApp/src/sw/tsconfig.json
@@ -1,12 +1,12 @@
-{
- "extends": "../tsconfig.json",
- "compilerOptions": {
- "lib": [
- "esnext",
- "webworker"
- ]
- },
- "include": [
- "."
- ]
-}
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "esnext",
+ "webworker"
+ ]
+ },
+ "include": [
+ "."
+ ]
+}
diff --git a/Timeline/ClientApp/src/tsconfig.json b/Timeline/ClientApp/src/tsconfig.json
index 00277635..2820b583 100644
--- a/Timeline/ClientApp/src/tsconfig.json
+++ b/Timeline/ClientApp/src/tsconfig.json
@@ -1,17 +1,17 @@
-{
- "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
- }
-} \ No newline at end of file
+{
+ "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
+ }
+}