aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/app')
-rw-r--r--Timeline/ClientApp/src/app/App.tsx99
-rw-r--r--Timeline/ClientApp/src/app/about/About.tsx172
-rw-r--r--Timeline/ClientApp/src/app/common/AppBar.tsx107
-rw-r--r--Timeline/ClientApp/src/app/common/FileInput.tsx41
-rw-r--r--Timeline/ClientApp/src/app/index.sass44
-rw-r--r--Timeline/ClientApp/src/app/service-worker.tsx12
-rw-r--r--Timeline/ClientApp/src/app/services/DataHub.ts (renamed from Timeline/ClientApp/src/app/data/DataHub.ts)0
-rw-r--r--Timeline/ClientApp/src/app/services/alert.ts (renamed from Timeline/ClientApp/src/app/common/alert-service.ts)0
-rw-r--r--Timeline/ClientApp/src/app/services/common.ts (renamed from Timeline/ClientApp/src/app/data/common.ts)2
-rw-r--r--Timeline/ClientApp/src/app/services/timeline.ts (renamed from Timeline/ClientApp/src/app/data/timeline.ts)16
-rw-r--r--Timeline/ClientApp/src/app/services/user.ts (renamed from Timeline/ClientApp/src/app/data/user.ts)15
-rw-r--r--Timeline/ClientApp/src/app/settings/Settings.tsx221
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx110
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline-ui.sass35
-rw-r--r--Timeline/ClientApp/src/app/user/Login.tsx147
-rw-r--r--Timeline/ClientApp/src/app/user/UserInfoCard.tsx104
-rw-r--r--Timeline/ClientApp/src/app/utilities/type.ts1
-rw-r--r--Timeline/ClientApp/src/app/views/about/about.sass (renamed from Timeline/ClientApp/src/app/about/about.sass)0
-rw-r--r--Timeline/ClientApp/src/app/views/about/author-avatar.png (renamed from Timeline/ClientApp/src/app/about/author-avatar.png)bin12038 -> 12038 bytes
-rw-r--r--Timeline/ClientApp/src/app/views/about/github.png (renamed from Timeline/ClientApp/src/app/about/github.png)bin4268 -> 4268 bytes
-rw-r--r--Timeline/ClientApp/src/app/views/about/index.tsx164
-rw-r--r--Timeline/ClientApp/src/app/views/admin/Admin.tsx (renamed from Timeline/ClientApp/src/app/admin/Admin.tsx)29
-rw-r--r--Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx (renamed from Timeline/ClientApp/src/app/admin/UserAdmin.tsx)49
-rw-r--r--Timeline/ClientApp/src/app/views/common/AppBar.tsx64
-rw-r--r--Timeline/ClientApp/src/app/views/common/BlobImage.tsx (renamed from Timeline/ClientApp/src/app/common/BlobImage.tsx)4
-rw-r--r--Timeline/ClientApp/src/app/views/common/ImageCropper.tsx (renamed from Timeline/ClientApp/src/app/common/ImageCropper.tsx)4
-rw-r--r--Timeline/ClientApp/src/app/views/common/LoadingButton.tsx29
-rw-r--r--Timeline/ClientApp/src/app/views/common/LoadingPage.tsx (renamed from Timeline/ClientApp/src/app/common/LoadingPage.tsx)4
-rw-r--r--Timeline/ClientApp/src/app/views/common/OperationDialog.tsx (renamed from Timeline/ClientApp/src/app/common/OperationDialog.tsx)125
-rw-r--r--Timeline/ClientApp/src/app/views/common/SearchInput.tsx (renamed from Timeline/ClientApp/src/app/common/SearchInput.tsx)14
-rw-r--r--Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx (renamed from Timeline/ClientApp/src/app/common/TimelineLogo.tsx)0
-rw-r--r--Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx (renamed from Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx)0
-rw-r--r--Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx (renamed from Timeline/ClientApp/src/app/common/AlertHost.tsx)11
-rw-r--r--Timeline/ClientApp/src/app/views/common/alert/alert.sass (renamed from Timeline/ClientApp/src/app/common/alert.sass)0
-rw-r--r--Timeline/ClientApp/src/app/views/common/common.sass (renamed from Timeline/ClientApp/src/app/common/common.sass)0
-rw-r--r--Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx (renamed from Timeline/ClientApp/src/app/home/BoardWithUser.tsx)8
-rw-r--r--Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx (renamed from Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx)6
-rw-r--r--Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx (renamed from Timeline/ClientApp/src/app/home/OfflineBoard.tsx)2
-rw-r--r--Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx (renamed from Timeline/ClientApp/src/app/home/TimelineBoard.tsx)6
-rw-r--r--Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx (renamed from Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx)2
-rw-r--r--Timeline/ClientApp/src/app/views/home/home.sass (renamed from Timeline/ClientApp/src/app/home/home.sass)0
-rw-r--r--Timeline/ClientApp/src/app/views/home/index.tsx (renamed from Timeline/ClientApp/src/app/home/Home.tsx)19
-rw-r--r--Timeline/ClientApp/src/app/views/login/index.tsx151
-rw-r--r--Timeline/ClientApp/src/app/views/login/login.sass2
-rw-r--r--Timeline/ClientApp/src/app/views/settings/index.tsx209
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx23
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx26
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx58
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx (renamed from Timeline/ClientApp/src/app/timeline/Timeline.tsx)8
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelineItem.tsx)91
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelineMember.tsx)27
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx)17
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx)165
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx)35
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx)3
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass (renamed from Timeline/ClientApp/src/app/timeline/timeline.sass)61
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx)3
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx85
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx)7
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/index.tsx (renamed from Timeline/ClientApp/src/app/timeline/TimelinePage.tsx)2
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/timeline.sass0
-rw-r--r--Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx (renamed from Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx)96
-rw-r--r--Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx (renamed from Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx)0
-rw-r--r--Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx80
-rw-r--r--Timeline/ClientApp/src/app/views/user/UserPageUI.tsx (renamed from Timeline/ClientApp/src/app/user/UserPage.tsx)9
-rw-r--r--Timeline/ClientApp/src/app/views/user/index.tsx (renamed from Timeline/ClientApp/src/app/user/User.tsx)17
-rw-r--r--Timeline/ClientApp/src/app/views/user/user.sass (renamed from Timeline/ClientApp/src/app/user/user-page.sass)3
67 files changed, 1334 insertions, 1510 deletions
diff --git a/Timeline/ClientApp/src/app/App.tsx b/Timeline/ClientApp/src/app/App.tsx
index 74deddda..b68eddb6 100644
--- a/Timeline/ClientApp/src/app/App.tsx
+++ b/Timeline/ClientApp/src/app/App.tsx
@@ -2,17 +2,18 @@ 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";
+import AppBar from "./views/common/AppBar";
+import LoadingPage from "./views/common/LoadingPage";
+import Home from "./views/home";
+import Login from "./views/login";
+import Settings from "./views/settings";
+import About from "./views/about";
+import User from "./views/user";
+import TimelinePage from "./views/timeline";
+import AlertHost from "./views/common/alert/AlertHost";
+
+import { dataStorage } from "./services/common";
+import { userService, useRawUser } from "./services/user";
const NoMatch: React.FC = () => {
return (
@@ -25,7 +26,7 @@ const NoMatch: React.FC = () => {
};
const LazyAdmin = React.lazy(
- () => import(/* webpackChunkName: "admin" */ "./admin/Admin")
+ () => import(/* webpackChunkName: "admin" */ "./views/admin/Admin")
);
const App: React.FC = () => {
@@ -38,50 +39,46 @@ const App: React.FC = () => {
void dataStorage.ready().then(() => setLoading(false));
}, []);
- let body;
if (user === undefined || loading) {
- body = <LoadingPage />;
+ return <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} />
+ return (
+ <React.Suspense fallback={<LoadingPage />}>
+ <Router>
+ <AppBar />
+ <Switch>
+ <Route exact path="/">
+ <Home />
+ </Route>
+ <Route exact path="/login">
+ <Login />
+ </Route>
+ <Route path="/settings">
+ <Settings />
+ </Route>
+ <Route path="/about">
+ <About />
+ </Route>
+ <Route path="/timelines/:name">
+ <TimelinePage />
</Route>
- )}
- <Route>
- <NoMatch />
- </Route>
- </Switch>
- </Router>
+ <Route path="/users/:username">
+ <User />
+ </Route>
+ {user && user.administrator && (
+ <Route path="/admin">
+ <LazyAdmin user={user} />
+ </Route>
+ )}
+ <Route>
+ <NoMatch />
+ </Route>
+ </Switch>
+ <AlertHost />
+ </Router>
+ </React.Suspense>
);
}
-
- return (
- <React.Suspense fallback={<LoadingPage />}>
- {body}
- <AlertHost />
- </React.Suspense>
- );
};
export default hot(App);
diff --git a/Timeline/ClientApp/src/app/about/About.tsx b/Timeline/ClientApp/src/app/about/About.tsx
deleted file mode 100644
index 519eef18..00000000
--- a/Timeline/ClientApp/src/app/about/About.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import React from "react";
-import { useTranslation, Trans } from "react-i18next";
-
-import AppBar from "../common/AppBar";
-
-import authorAvatarUrl from "./author-avatar.png";
-import githubLogoUrl from "./github.png";
-
-const frontendCredits: {
- name: string;
- url: string;
-}[] = [
- {
- name: "reactjs",
- url: "https://reactjs.org",
- },
- {
- name: "typescript",
- url: "https://www.typescriptlang.org",
- },
- {
- name: "bootstrap",
- url: "https://getbootstrap.com",
- },
- {
- name: "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/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx
deleted file mode 100644
index 59239696..00000000
--- a/Timeline/ClientApp/src/app/common/AppBar.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from "react";
-import { useHistory, matchPath } from "react-router";
-import { Link, NavLink } from "react-router-dom";
-import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from "reactstrap";
-import { useMediaQuery } from "react-responsive";
-import { useTranslation } from "react-i18next";
-
-import { useUser, 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/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx
deleted file mode 100644
index 3d1bc2b3..00000000
--- a/Timeline/ClientApp/src/app/common/FileInput.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-import { ExcludeKey } from "../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/index.sass b/Timeline/ClientApp/src/app/index.sass
index ef0b03ba..08e03bac 100644
--- a/Timeline/ClientApp/src/app/index.sass
+++ b/Timeline/ClientApp/src/app/index.sass
@@ -1,26 +1,20 @@
@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'
+@import './views/common/common'
+@import './views/common/alert/alert'
+@import './views/home/home'
+@import './views/about/about'
+@import './views/login/login'
+@import './views/timeline-common/timeline-common'
+@import './views/timeline/timeline'
+@import './views/user/user'
body
margin: 0
-#app
- display: flex
- flex-direction: column
-
small
line-height: 1.2
-.width-1px
- width: 1px
-
.flex-fix-length
flex-grow: 0
flex-shrink: 0
@@ -29,30 +23,20 @@ small
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
+ &.large
+ width: 100px
+ &.small
+ width: 40px
.mt-appbar
margin-top: 56px
.icon-button
font-size: 1.4em
-
-.large-icon-button
- font-size: 1.6em
+ &.large
+ font-size: 1.6em
.cursor-pointer
cursor: pointer
diff --git a/Timeline/ClientApp/src/app/service-worker.tsx b/Timeline/ClientApp/src/app/service-worker.tsx
index f71b23b3..3be54bc1 100644
--- a/Timeline/ClientApp/src/app/service-worker.tsx
+++ b/Timeline/ClientApp/src/app/service-worker.tsx
@@ -1,8 +1,8 @@
import React from "react";
-import { Button } from "reactstrap";
import { useTranslation } from "react-i18next";
+import { Button } from "react-bootstrap";
-import { pushAlert } from "./common/alert-service";
+import { pushAlert } from "./services/alert";
if ("serviceWorker" in navigator) {
let isThisTriggerUpgrade = false;
@@ -39,7 +39,11 @@ if ("serviceWorker" in navigator) {
return (
<>
{t("serviceWorker.externalActivatedPrompt")}
- <Button color="success" size="sm" onClick={upgradeReload} outline>
+ <Button
+ variant="outline-success"
+ size="sm"
+ onClick={upgradeReload}
+ >
{t("serviceWorker.reloadNow")}
</Button>
</>
@@ -83,7 +87,7 @@ if ("serviceWorker" in navigator) {
return (
<>
{t("serviceWorker.upgradePrompt")}
- <Button color="success" size="sm" onClick={upgrade} outline>
+ <Button variant="outline-success" size="sm" onClick={upgrade}>
{t("serviceWorker.upgradeNow")}
</Button>
</>
diff --git a/Timeline/ClientApp/src/app/data/DataHub.ts b/Timeline/ClientApp/src/app/services/DataHub.ts
index 93a9b41f..93a9b41f 100644
--- a/Timeline/ClientApp/src/app/data/DataHub.ts
+++ b/Timeline/ClientApp/src/app/services/DataHub.ts
diff --git a/Timeline/ClientApp/src/app/common/alert-service.ts b/Timeline/ClientApp/src/app/services/alert.ts
index e4c0e653..e4c0e653 100644
--- a/Timeline/ClientApp/src/app/common/alert-service.ts
+++ b/Timeline/ClientApp/src/app/services/alert.ts
diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/services/common.ts
index 8d52abe5..3bb6b9d7 100644
--- a/Timeline/ClientApp/src/app/data/common.ts
+++ b/Timeline/ClientApp/src/app/services/common.ts
@@ -1,6 +1,6 @@
import localforage from "localforage";
-import { HttpNetworkError } from "../http/common";
+import { HttpNetworkError } from "@/http/common";
export const dataStorage = localforage.createInstance({
name: "data",
diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/services/timeline.ts
index 3eda35f9..9db76281 100644
--- a/Timeline/ClientApp/src/app/data/timeline.ts
+++ b/Timeline/ClientApp/src/app/services/timeline.ts
@@ -4,7 +4,7 @@ import { Observable, from, combineLatest, of } from "rxjs";
import { map, switchMap, startWith } from "rxjs/operators";
import { uniqBy } from "lodash";
-import { convertError } from "../utilities/rxjs";
+import { convertError } from "@/utilities/rxjs";
import {
TimelineVisibility,
HttpTimelineInfo,
@@ -18,18 +18,18 @@ import {
getHttpTimelineClient,
HttpTimelineNotExistError,
HttpTimelineNameConflictError,
-} from "../http/timeline";
-import { BlobWithEtag, NotModified, HttpForbiddenError } from "../http/common";
-import { HttpUser } from "../http/user";
+} from "@/http/timeline";
+import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common";
+import { HttpUser } from "@/http/user";
+
+export { kTimelineVisibilities } from "@/http/timeline";
+
+export type { TimelineVisibility } from "@/http/timeline";
import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common";
import { DataHub, WithSyncStatus } from "./DataHub";
import { UserAuthInfo, checkLogin, userService, userInfoService } from "./user";
-export { kTimelineVisibilities } from "../http/timeline";
-
-export type { TimelineVisibility } from "../http/timeline";
-
export type TimelineInfo = HttpTimelineInfo;
export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
export type TimelineCreatePostRequest = HttpTimelinePostPostRequest;
diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/services/user.ts
index b8f163eb..f253fc19 100644
--- a/Timeline/ClientApp/src/app/data/user.ts
+++ b/Timeline/ClientApp/src/app/services/user.ts
@@ -2,22 +2,23 @@ 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 { HttpNetworkError, BlobWithEtag, NotModified } from "../http/common";
+import { UiLogicError } from "@/common";
+import { convertError } from "@/utilities/rxjs";
+
+import { HttpNetworkError, BlobWithEtag, NotModified } from "@/http/common";
import {
getHttpTokenClient,
HttpCreateTokenBadCredentialError,
-} from "../http/token";
+} from "@/http/token";
import {
getHttpUserClient,
HttpUserNotExistError,
HttpUser,
-} from "../http/user";
+} from "@/http/user";
-import { DataHub } from "./DataHub";
import { dataStorage, throwIfNotNetworkError } from "./common";
+import { DataHub } from "./DataHub";
+import { pushAlert } from "./alert";
export type User = HttpUser;
diff --git a/Timeline/ClientApp/src/app/settings/Settings.tsx b/Timeline/ClientApp/src/app/settings/Settings.tsx
deleted file mode 100644
index 64851ce2..00000000
--- a/Timeline/ClientApp/src/app/settings/Settings.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-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/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
deleted file mode 100644
index c11c3376..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from "reactstrap";
-import { useTranslation } from "react-i18next";
-import { fromEvent } from "rxjs";
-
-import { useAvatar } from "../data/user";
-import { timelineVisibilityTooltipTranslationMap } from "../data/timeline";
-import BlobImage from "../common/BlobImage";
-
-import { TimelineCardComponentProps } from "./TimelinePageTemplateUI";
-
-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/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
deleted file mode 100644
index 79be64d3..00000000
--- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
+++ /dev/null
@@ -1,35 +0,0 @@
-.info-card-container
- .info-card-collapse-button
- z-index: 1
- position: relative
-
- .info-card-content
- width: 100%
- position: absolute
- transform-origin: right top
- transition: transform 0.5s
-
- &[data-collapse='true']
- .info-card-content
- transform: scale(0)
-
-.timeline-page-top-space
- transition: height 0.5s
-
-.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/user/Login.tsx b/Timeline/ClientApp/src/app/user/Login.tsx
deleted file mode 100644
index db6c43c4..00000000
--- a/Timeline/ClientApp/src/app/user/Login.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import React, { Fragment, useState, useEffect } from "react";
-import { useHistory } from "react-router";
-import { useTranslation } from "react-i18next";
-import {
- Label,
- FormGroup,
- Input,
- Form,
- FormFeedback,
- Spinner,
- Button,
-} from "reactstrap";
-
-import AppBar from "../common/AppBar";
-import { useUser, userService } from "../data/user";
-
-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/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
deleted file mode 100644
index d6e648cc..00000000
--- a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from "reactstrap";
-import { useTranslation } from "react-i18next";
-import { fromEvent } from "rxjs";
-
-import { timelineVisibilityTooltipTranslationMap } 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/utilities/type.ts b/Timeline/ClientApp/src/app/utilities/type.ts
deleted file mode 100644
index 8df9bf0f..00000000
--- a/Timeline/ClientApp/src/app/utilities/type.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type ExcludeKey<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
diff --git a/Timeline/ClientApp/src/app/about/about.sass b/Timeline/ClientApp/src/app/views/about/about.sass
index 3b5840cd..3b5840cd 100644
--- a/Timeline/ClientApp/src/app/about/about.sass
+++ b/Timeline/ClientApp/src/app/views/about/about.sass
diff --git a/Timeline/ClientApp/src/app/about/author-avatar.png b/Timeline/ClientApp/src/app/views/about/author-avatar.png
index d890d8d0..d890d8d0 100644
--- a/Timeline/ClientApp/src/app/about/author-avatar.png
+++ b/Timeline/ClientApp/src/app/views/about/author-avatar.png
Binary files differ
diff --git a/Timeline/ClientApp/src/app/about/github.png b/Timeline/ClientApp/src/app/views/about/github.png
index ea6ff545..ea6ff545 100644
--- a/Timeline/ClientApp/src/app/about/github.png
+++ b/Timeline/ClientApp/src/app/views/about/github.png
Binary files differ
diff --git a/Timeline/ClientApp/src/app/views/about/index.tsx b/Timeline/ClientApp/src/app/views/about/index.tsx
new file mode 100644
index 00000000..e7771cec
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/about/index.tsx
@@ -0,0 +1,164 @@
+import React from "react";
+import { useTranslation, Trans } from "react-i18next";
+
+import authorAvatarUrl from "./author-avatar.png";
+import githubLogoUrl from "./github.png";
+
+const frontendCredits: {
+ name: string;
+ url: string;
+}[] = [
+ {
+ name: "reactjs",
+ url: "https://reactjs.org",
+ },
+ {
+ name: "typescript",
+ url: "https://www.typescriptlang.org",
+ },
+ {
+ name: "bootstrap",
+ url: "https://getbootstrap.com",
+ },
+ {
+ name: "react-bootstrap",
+ url: "https://react-bootstrap.github.io",
+ },
+ {
+ name: "babeljs",
+ url: "https://babeljs.io",
+ },
+ {
+ name: "webpack",
+ url: "https://webpack.js.org",
+ },
+ {
+ name: "sass",
+ url: "https://sass-lang.com",
+ },
+ {
+ name: "eslint",
+ url: "https://eslint.org",
+ },
+ {
+ name: "prettier",
+ url: "https://prettier.io",
+ },
+ {
+ name: "pepjs",
+ url: "https://github.com/jquery/PEP",
+ },
+ {
+ name: "react-inlinesvg",
+ url: "https://github.com/gilbarbara/react-inlinesvg",
+ },
+];
+
+const backendCredits: {
+ name: string;
+ url: string;
+}[] = [
+ {
+ name: "ASP.NET Core",
+ url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core",
+ },
+ { name: "sqlite", url: "https://sqlite.org" },
+ {
+ name: "ImageSharp",
+ url: "https://github.com/SixLabors/ImageSharp",
+ },
+];
+
+const AboutPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+ <div className="mt-appbar px-2 mb-4">
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4 id="author-info">{t("about.author.title")}</h4>
+ <div>
+ <div className="d-flex">
+ <img
+ src={authorAvatarUrl}
+ className="align-self-start avatar large rounded-circle"
+ />
+ <div>
+ <p>
+ <small>{t("about.author.fullname")}</small>
+ <span className="text-primary">杨宇千</span>
+ </p>
+ <p>
+ <small>{t("about.author.nickname")}</small>
+ <span className="text-primary">crupest</span>
+ </p>
+ <p>
+ <small>{t("about.author.introduction")}</small>
+ {t("about.author.introductionContent")}
+ </p>
+ </div>
+ </div>
+ <p>
+ <small>{t("about.author.links")}</small>
+ <a
+ href="https://github.com/crupest"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <img src={githubLogoUrl} className="about-link-icon text-body" />
+ </a>
+ </p>
+ </div>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t("about.site.title")}</h4>
+ <p>
+ <Trans i18nKey="about.site.content">
+ 0<span className="text-primary">1</span>2<b>3</b>4
+ <a href="#author-info">5</a>6
+ </Trans>
+ </p>
+ <p>
+ <a
+ href="https://github.com/crupest/Timeline"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t("about.site.repo")}
+ </a>
+ </p>
+ </div>
+ <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
+ <h4>{t("about.credits.title")}</h4>
+ <p>{t("about.credits.content")}</p>
+ <p>{t("about.credits.frontend")}</p>
+ <ul>
+ {frontendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ <p>{t("about.credits.backend")}</p>
+ <ul>
+ {backendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ </div>
+ </div>
+ );
+};
+
+export default AboutPage;
diff --git a/Timeline/ClientApp/src/app/admin/Admin.tsx b/Timeline/ClientApp/src/app/views/admin/Admin.tsx
index e2f71091..9c0250e7 100644
--- a/Timeline/ClientApp/src/app/admin/Admin.tsx
+++ b/Timeline/ClientApp/src/app/views/admin/Admin.tsx
@@ -1,5 +1,4 @@
import React, { Fragment } from "react";
-import { Nav, NavItem, NavLink } from "reactstrap";
import {
Redirect,
Route,
@@ -7,10 +6,9 @@ import {
useRouteMatch,
useHistory,
} from "react-router";
-import classnames from "classnames";
+import { Nav } from "react-bootstrap";
-import AppBar from "../common/AppBar";
-import { UserWithToken } from "../data/user";
+import { UserWithToken } from "@/services/user";
import UserAdmin from "./UserAdmin";
@@ -35,29 +33,28 @@ const Admin: React.FC<AdminProps> = (props) => {
): 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" })}
+ <Nav variant="tabs">
+ <Nav.Item>
+ <Nav.Link
+ active={tabName === "users"}
onClick={() => {
toggle("users");
}}
>
Users
- </NavLink>
- </NavItem>
- <NavItem>
- <NavLink
- className={classnames({ active: tabName === "more" })}
+ </Nav.Link>
+ </Nav.Item>
+ <Nav.Item>
+ <Nav.Link
+ active={tabName === "more"}
onClick={() => {
toggle("more");
}}
>
More
- </NavLink>
- </NavItem>
+ </Nav.Link>
+ </Nav.Item>
</Nav>
{body}
</Route>
diff --git a/Timeline/ClientApp/src/app/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
index 1bf3bda1..18b77ca8 100644
--- a/Timeline/ClientApp/src/app/admin/UserAdmin.tsx
+++ b/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
@@ -1,19 +1,16 @@
import React, { useState, useEffect } from "react";
+import axios from "axios";
import {
- ListGroupItem,
+ ListGroup,
Row,
Col,
- UncontrolledDropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
+ Dropdown,
Spinner,
Button,
-} from "reactstrap";
-import axios from "axios";
+} from "react-bootstrap";
import OperationDialog from "../common/OperationDialog";
-import { User, UserWithToken } from "../data/user";
+import { User, UserWithToken } from "@/services/user";
const apiBaseUrl = "/api";
@@ -101,7 +98,7 @@ const UserItem: React.FC<UserCardProps> = (props) => {
};
return (
- <ListGroupItem className="container">
+ <ListGroup.Item className="container">
<Row className="align-items-center">
<Col>
<p className="mb-0 text-primary">{user.username}</p>
@@ -112,31 +109,31 @@ const UserItem: React.FC<UserCardProps> = (props) => {
</small>
</Col>
<Col className="col-auto">
- <UncontrolledDropdown>
- <DropdownToggle color="warning" className="text-light" caret>
+ <Dropdown>
+ <Dropdown.Toggle variant="warning" className="text-light">
Manage
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={createClickCallback(kChangeUsername)}>
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={createClickCallback(kChangeUsername)}>
Change Username
- </DropdownItem>
- <DropdownItem onClick={createClickCallback(kChangePassword)}>
+ </Dropdown.Item>
+ <Dropdown.Item onClick={createClickCallback(kChangePassword)}>
Change Password
- </DropdownItem>
- <DropdownItem onClick={createClickCallback(kChangePermission)}>
+ </Dropdown.Item>
+ <Dropdown.Item onClick={createClickCallback(kChangePermission)}>
Change Permission
- </DropdownItem>
- <DropdownItem
+ </Dropdown.Item>
+ <Dropdown.Item
className="text-danger"
onClick={createClickCallback(kDelete)}
>
Delete
- </DropdownItem>
- </DropdownMenu>
- </UncontrolledDropdown>
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
</Col>
</Row>
- </ListGroupItem>
+ </ListGroup.Item>
);
};
@@ -441,7 +438,7 @@ const UserAdmin: React.FC<UserAdminProps> = (props) => {
return (
<>
<Button
- color="success"
+ variant="success"
onClick={() =>
setDialog({
type: "create",
@@ -456,7 +453,7 @@ const UserAdmin: React.FC<UserAdminProps> = (props) => {
</>
);
} else {
- return <Spinner />;
+ return <Spinner animation="border" />;
}
};
diff --git a/Timeline/ClientApp/src/app/views/common/AppBar.tsx b/Timeline/ClientApp/src/app/views/common/AppBar.tsx
new file mode 100644
index 00000000..ee4ead8f
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/common/AppBar.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { LinkContainer } from "react-router-bootstrap";
+import { Navbar, Nav } from "react-bootstrap";
+
+import { useUser, useAvatar } from "@/services/user";
+
+import TimelineLogo from "./TimelineLogo";
+import BlobImage from "./BlobImage";
+
+const AppBar: React.FC = (_) => {
+ const user = useUser();
+ const avatar = useAvatar(user?.username);
+
+ const { t } = useTranslation();
+
+ const isAdministrator = user && user.administrator;
+
+ return (
+ <Navbar bg="primary" variant="dark" expand="md" sticky="top">
+ <LinkContainer to="/">
+ <Navbar.Brand className="d-flex align-items-center">
+ <TimelineLogo style={{ height: "1em" }} />
+ Timeline
+ </Navbar.Brand>
+ </LinkContainer>
+
+ <Navbar.Toggle />
+ <Navbar.Collapse>
+ <Nav className="mr-auto">
+ <LinkContainer to="/settings">
+ <Nav.Link>{t("nav.settings")}</Nav.Link>
+ </LinkContainer>
+
+ <LinkContainer to="/about">
+ <Nav.Link>{t("nav.about")}</Nav.Link>
+ </LinkContainer>
+
+ {isAdministrator && (
+ <LinkContainer to="/admin">
+ <Nav.Link>Administration</Nav.Link>
+ </LinkContainer>
+ )}
+ </Nav>
+ <Nav className="ml-auto mr-2">
+ {user != null ? (
+ <LinkContainer to={`/users/${user.username}`}>
+ <BlobImage
+ className="avatar small rounded-circle bg-white"
+ blob={avatar}
+ />
+ </LinkContainer>
+ ) : (
+ <LinkContainer to="/login">
+ <Nav.Link>{t("nav.login")}</Nav.Link>
+ </LinkContainer>
+ )}
+ </Nav>
+ </Navbar.Collapse>
+ </Navbar>
+ );
+};
+
+export default AppBar;
diff --git a/Timeline/ClientApp/src/app/common/BlobImage.tsx b/Timeline/ClientApp/src/app/views/common/BlobImage.tsx
index 8602f550..0dd25c52 100644
--- a/Timeline/ClientApp/src/app/common/BlobImage.tsx
+++ b/Timeline/ClientApp/src/app/views/common/BlobImage.tsx
@@ -1,9 +1,7 @@
import React from "react";
-import { ExcludeKey } from "../utilities/type";
-
const BlobImage: React.FC<
- ExcludeKey<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
+ Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
blob?: Blob | unknown;
}
> = (props) => {
diff --git a/Timeline/ClientApp/src/app/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx
index cd510969..b9db8b99 100644
--- a/Timeline/ClientApp/src/app/common/ImageCropper.tsx
+++ b/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx
@@ -1,7 +1,7 @@
-import * as React from "react";
+import React from "react";
import clsx from "clsx";
-import { UiLogicError } from "../common";
+import { UiLogicError } from "@/common";
export interface Clip {
left: number;
diff --git a/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx b/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx
new file mode 100644
index 00000000..154334a7
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { Button, ButtonProps, Spinner } from "react-bootstrap";
+
+const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({
+ loading,
+ variant,
+ disabled,
+ ...otherProps
+}) => {
+ return (
+ <Button
+ variant={variant != null ? `outline-${variant}` : "outline-primary"}
+ disabled={disabled || loading}
+ {...otherProps}
+ >
+ {otherProps.children}
+ {loading ? (
+ <Spinner
+ className="ml-1"
+ variant={variant}
+ animation="grow"
+ size="sm"
+ />
+ ) : null}
+ </Button>
+ );
+};
+
+export default LoadingButton;
diff --git a/Timeline/ClientApp/src/app/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx
index a849126d..590fafa0 100644
--- a/Timeline/ClientApp/src/app/common/LoadingPage.tsx
+++ b/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx
@@ -1,10 +1,10 @@
import React from "react";
-import { Spinner } from "reactstrap";
+import { Spinner } from "react-bootstrap";
const LoadingPage: React.FC = () => {
return (
<div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
- <Spinner style={{ height: "2.5rem", width: "2.5rem" }} color="primary" />
+ <Spinner variant="primary" animation="border" />
</div>
);
};
diff --git a/Timeline/ClientApp/src/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
index bca4580c..841392a6 100644
--- a/Timeline/ClientApp/src/app/common/OperationDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
@@ -1,29 +1,10 @@
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>
- );
-};
+import { Form, Button, Modal } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
+
+import LoadingButton from "./LoadingButton";
interface DefaultErrorPromptProps {
error?: string;
@@ -64,7 +45,7 @@ export interface OperationTextInputInfo {
initValue?: string;
textFieldProps?: Omit<
React.InputHTMLAttributes<HTMLInputElement>,
- "type" | "value" | "onChange"
+ "type" | "value" | "onChange" | "aria-relevant"
>;
helperText?: string;
validator?: OperationInputValidator<string>;
@@ -167,7 +148,9 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
};
let body: React.ReactNode;
- if (step === "input") {
+ if (step === "input" || step === "process") {
+ const process = step === "process";
+
let inputPrompt =
typeof props.inputPrompt === "function"
? props.inputPrompt()
@@ -233,7 +216,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
body = (
<>
- <ModalBody>
+ <Modal.Body>
{inputPrompt}
{inputScheme.map((item, index) => {
const value = values[index];
@@ -242,9 +225,9 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
if (item.type === "text") {
return (
- <FormGroup key={index}>
- {item.label && <Label>{t(item.label)}</Label>}
- <Input
+ <Form.Group key={index}>
+ {item.label && <Form.Label>{t(item.label)}</Form.Label>}
+ <Form.Control
type={item.password === true ? "password" : "text"}
value={value as string}
onChange={(e) => {
@@ -258,39 +241,44 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
)
);
}}
- invalid={error != null}
- {...item.textFieldProps}
+ isInvalid={error != null}
+ disabled={process}
/>
- {error != null && <FormFeedback>{error}</FormFeedback>}
- {item.helperText && <FormText>{t(item.helperText)}</FormText>}
- </FormGroup>
+ {error != null && (
+ <Form.Control.Feedback type="invalid">
+ {error}
+ </Form.Control.Feedback>
+ )}
+ {item.helperText && (
+ <Form.Text>{t(item.helperText)}</Form.Text>
+ )}
+ </Form.Group>
);
} else if (item.type === "bool") {
return (
- <FormGroup check key={index}>
- <Input
+ <Form.Group key={index}>
+ <Form.Check<"input">
type="checkbox"
- value={value as string}
- onChange={(e) => {
- updateValue(
- index,
- (e.target as HTMLInputElement).checked
- );
+ checked={value as boolean}
+ onChange={(event) => {
+ updateValue(index, event.currentTarget.checked);
}}
+ label={t(item.label)}
+ disabled={process}
/>
- <Label check>{t(item.label)}</Label>
- </FormGroup>
+ </Form.Group>
);
} else if (item.type === "select") {
return (
- <FormGroup key={index}>
- <Label>{t(item.label)}</Label>
- <Input
- type="select"
+ <Form.Group key={index}>
+ <Form.Label>{t(item.label)}</Form.Label>
+ <Form.Control
+ as="select"
value={value as string}
onChange={(event) => {
updateValue(index, event.target.value);
}}
+ disabled={process}
>
{item.options.map((option, i) => {
return (
@@ -300,18 +288,19 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
</option>
);
})}
- </Input>
- </FormGroup>
+ </Form.Control>
+ </Form.Group>
);
}
})}
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={close}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="outline-secondary" onClick={close}>
{t("operationDialog.cancel")}
</Button>
- <Button
- color="primary"
+ <LoadingButton
+ variant="primary"
+ loading={process}
disabled={testErrorInfo(inputError)}
onClick={() => {
if (validateAll()) {
@@ -320,16 +309,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
}}
>
{t("operationDialog.confirm")}
- </Button>
- </ModalFooter>
+ </LoadingButton>
+ </Modal.Footer>
</>
);
- } else if (step === "process") {
- body = (
- <ModalBody>
- {props.processPrompt?.() ?? <DefaultProcessPrompt />}
- </ModalBody>
- );
} else {
let content: React.ReactNode;
const result = step;
@@ -345,12 +328,12 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
}
body = (
<>
- <ModalBody>{content}</ModalBody>
- <ModalFooter>
- <Button color="primary" onClick={close}>
+ <Modal.Body>{content}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="primary" onClick={close}>
{t("operationDialog.ok")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
}
@@ -358,8 +341,8 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
const title = typeof props.title === "string" ? t(props.title) : props.title;
return (
- <Modal isOpen={props.open} toggle={close}>
- <ModalHeader
+ <Modal show={props.open} onHide={close}>
+ <Modal.Header
className={
props.titleColor != null
? "text-" +
@@ -372,7 +355,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
}
>
{title}
- </ModalHeader>
+ </Modal.Header>
{body}
</Modal>
);
diff --git a/Timeline/ClientApp/src/app/common/SearchInput.tsx b/Timeline/ClientApp/src/app/views/common/SearchInput.tsx
index 5a0b0eaa..9833d515 100644
--- a/Timeline/ClientApp/src/app/common/SearchInput.tsx
+++ b/Timeline/ClientApp/src/app/views/common/SearchInput.tsx
@@ -1,7 +1,7 @@
import React, { useCallback } from "react";
import clsx from "clsx";
-import { Spinner, Input, Button } from "reactstrap";
import { useTranslation } from "react-i18next";
+import { Spinner, Form, Button } from "react-bootstrap";
export interface SearchInputProps {
value: string;
@@ -36,9 +36,9 @@ const SearchInput: React.FC<SearchInputProps> = (props) => {
);
return (
- <div className={clsx("form-inline my-2", props.className)}>
- <Input
- className="mr-sm-2"
+ <Form inline className={clsx("my-2", props.className)}>
+ <Form.Control
+ className="mr-sm-2 flex-grow-1"
value={props.value}
onChange={onInputChange}
onKeyPress={onInputKeyPress}
@@ -49,14 +49,14 @@ const SearchInput: React.FC<SearchInputProps> = (props) => {
</div>
<div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
{props.loading ? (
- <Spinner />
+ <Spinner variant="primary" animation="border" />
) : (
- <Button outline color="primary" onClick={props.onButtonClick}>
+ <Button variant="outline-primary" onClick={props.onButtonClick}>
{props.buttonText ?? t("search")}
</Button>
)}
</div>
- </div>
+ </Form>
);
};
diff --git a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx
index 27d188fc..27d188fc 100644
--- a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx
diff --git a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx
index 29f6a69f..29f6a69f 100644
--- a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx
diff --git a/Timeline/ClientApp/src/app/common/AlertHost.tsx b/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
index bfcf5c00..c74f18e2 100644
--- a/Timeline/ClientApp/src/app/common/AlertHost.tsx
+++ b/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
@@ -1,15 +1,15 @@
import React, { useCallback } from "react";
-import { Alert } from "reactstrap";
import without from "lodash/without";
import concat from "lodash/concat";
import { useTranslation } from "react-i18next";
+import { Alert } from "react-bootstrap";
import {
alertService,
AlertInfoEx,
kAlertHostId,
AlertInfo,
-} from "./alert-service";
+} from "@/services/alert";
interface AutoCloseAlertProps {
alert: AlertInfo;
@@ -37,7 +37,12 @@ export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
}, [dismissTime, props.close]);
return (
- <Alert className="m-3" color={alert.type ?? "primary"} toggle={props.close}>
+ <Alert
+ className="m-3"
+ variant={alert.type ?? "primary"}
+ onClose={props.close}
+ dismissible
+ >
{(() => {
const { message } = alert;
if (typeof message === "function") {
diff --git a/Timeline/ClientApp/src/app/common/alert.sass b/Timeline/ClientApp/src/app/views/common/alert/alert.sass
index 5b6e65c2..5b6e65c2 100644
--- a/Timeline/ClientApp/src/app/common/alert.sass
+++ b/Timeline/ClientApp/src/app/views/common/alert/alert.sass
diff --git a/Timeline/ClientApp/src/app/common/common.sass b/Timeline/ClientApp/src/app/views/common/common.sass
index 15d34d7c..15d34d7c 100644
--- a/Timeline/ClientApp/src/app/common/common.sass
+++ b/Timeline/ClientApp/src/app/views/common/common.sass
diff --git a/Timeline/ClientApp/src/app/home/BoardWithUser.tsx b/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx
index 22a4667c..dcd39cbe 100644
--- a/Timeline/ClientApp/src/app/home/BoardWithUser.tsx
+++ b/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx
@@ -1,10 +1,10 @@
import React from "react";
-import { Row, Col } from "reactstrap";
+import { Row, Col } from "react-bootstrap";
import { useTranslation } from "react-i18next";
-import { UserWithToken } from "../data/user";
-import { TimelineInfo } from "../data/timeline";
-import { getHttpTimelineClient } from "../http/timeline";
+import { UserWithToken } from "@/services/user";
+import { TimelineInfo } from "@/services/timeline";
+import { getHttpTimelineClient } from "@/http/timeline";
import TimelineBoard from "./TimelineBoard";
import OfflineBoard from "./OfflineBoard";
diff --git a/Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx b/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx
index 972c1b25..ebfddb50 100644
--- a/Timeline/ClientApp/src/app/home/BoardWithoutUser.tsx
+++ b/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx
@@ -1,8 +1,8 @@
import React from "react";
-import { Row, Col } from "reactstrap";
+import { Row, Col } from "react-bootstrap";
-import { TimelineInfo } from "../data/timeline";
-import { getHttpTimelineClient } from "../http/timeline";
+import { TimelineInfo } from "@/services/timeline";
+import { getHttpTimelineClient } from "@/http/timeline";
import TimelineBoard from "./TimelineBoard";
import OfflineBoard from "./OfflineBoard";
diff --git a/Timeline/ClientApp/src/app/home/OfflineBoard.tsx b/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx
index fbd37efd..fc05bd74 100644
--- a/Timeline/ClientApp/src/app/home/OfflineBoard.tsx
+++ b/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { Link } from "react-router-dom";
import { Trans } from "react-i18next";
-import { getAllCachedTimelineNames } from "../data/timeline";
+import { getAllCachedTimelineNames } from "@/services/timeline";
import UserTimelineLogo from "../common/UserTimelineLogo";
import TimelineLogo from "../common/TimelineLogo";
diff --git a/Timeline/ClientApp/src/app/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx
index 21dcac3f..a3d176e1 100644
--- a/Timeline/ClientApp/src/app/home/TimelineBoard.tsx
+++ b/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx
@@ -1,10 +1,10 @@
import React from "react";
import clsx from "clsx";
import { Link } from "react-router-dom";
-import { Spinner } from "reactstrap";
import { Trans } from "react-i18next";
+import { Spinner } from "react-bootstrap";
-import { TimelineInfo } from "../data/timeline";
+import { TimelineInfo } from "@/services/timeline";
import TimelineLogo from "../common/TimelineLogo";
import UserTimelineLogo from "../common/UserTimelineLogo";
@@ -25,7 +25,7 @@ const TimelineBoard: React.FC<TimelineBoardProps> = (props) => {
if (timelines === "loading") {
return (
<div className="d-flex flex-grow-1 justify-content-center align-items-center">
- <Spinner color="primary" />
+ <Spinner variant="primary" animation="border" />
</div>
);
} else if (timelines === "offline") {
diff --git a/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx
index 911dd60c..d9467719 100644
--- a/Timeline/ClientApp/src/app/home/TimelineCreateDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { useHistory } from "react-router";
-import { validateTimelineName, timelineService } from "../data/timeline";
+import { validateTimelineName, timelineService } from "@/services/timeline";
import OperationDialog from "../common/OperationDialog";
interface TimelineCreateDialogProps {
diff --git a/Timeline/ClientApp/src/app/home/home.sass b/Timeline/ClientApp/src/app/views/home/home.sass
index f5d6ffc3..f5d6ffc3 100644
--- a/Timeline/ClientApp/src/app/home/home.sass
+++ b/Timeline/ClientApp/src/app/views/home/home.sass
diff --git a/Timeline/ClientApp/src/app/home/Home.tsx b/Timeline/ClientApp/src/app/views/home/index.tsx
index 910c9a01..760adcea 100644
--- a/Timeline/ClientApp/src/app/home/Home.tsx
+++ b/Timeline/ClientApp/src/app/views/home/index.tsx
@@ -1,17 +1,16 @@
import React from "react";
import { useHistory } from "react-router";
-import { Row, Container, Button, Col } from "reactstrap";
import { useTranslation } from "react-i18next";
+import { Row, Container, Button, Col } from "react-bootstrap";
-import { useUser } from "../data/user";
-import AppBar from "../common/AppBar";
+import { useUser } from "@/services/user";
import SearchInput from "../common/SearchInput";
import BoardWithoutUser from "./BoardWithoutUser";
import BoardWithUser from "./BoardWithUser";
import TimelineCreateDialog from "./TimelineCreateDialog";
-const Home: React.FC = () => {
+const HomePage: React.FC = () => {
const history = useHistory();
const { t } = useTranslation();
@@ -34,10 +33,9 @@ const Home: React.FC = () => {
return (
<>
- <AppBar />
- <Container fluid style={{ marginTop: "56px" }}>
- <Row>
- <Col>
+ <Container fluid>
+ <Row className="justify-content-center">
+ <Col xs={12} sm={10} md={8} lg={6}>
<SearchInput
className="justify-content-center"
value={navText}
@@ -48,8 +46,7 @@ const Home: React.FC = () => {
additionalButton={
user != null && (
<Button
- color="success"
- outline
+ variant="outline-success"
onClick={() => {
setDialog("create");
}}
@@ -99,4 +96,4 @@ const Home: React.FC = () => {
);
};
-export default Home;
+export default HomePage;
diff --git a/Timeline/ClientApp/src/app/views/login/index.tsx b/Timeline/ClientApp/src/app/views/login/index.tsx
new file mode 100644
index 00000000..265c2172
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/login/index.tsx
@@ -0,0 +1,151 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Container, Form } from "react-bootstrap";
+
+import { useUser, userService } from "@/services/user";
+
+import AppBar from "../common/AppBar";
+import LoadingButton from "../common/LoadingButton";
+
+const LoginPage: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const [username, setUsername] = React.useState<string>("");
+ const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false);
+ const [password, setPassword] = React.useState<string>("");
+ const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false);
+ const [rememberMe, setRememberMe] = React.useState<boolean>(true);
+ const [process, setProcess] = React.useState<boolean>(false);
+ const [error, setError] = React.useState<string | null>(null);
+
+ const user = useUser();
+
+ React.useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => history.push("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p className="mt-appbar">{t("login.alreadyLogin")}</p>
+ </>
+ );
+ }
+
+ const submit = (): void => {
+ if (username === "" || password === "") {
+ setUsernameDirty(true);
+ setPasswordDirty(true);
+ return;
+ }
+
+ setProcess(true);
+ userService
+ .login(
+ {
+ username: username,
+ password: password,
+ },
+ rememberMe
+ )
+ .then(
+ () => {
+ if (history.length === 0) {
+ history.push("/");
+ } else {
+ history.goBack();
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ };
+
+ const onEnterPressInPassword: React.KeyboardEventHandler = (e) => {
+ if (e.key === "Enter") {
+ submit();
+ }
+ };
+
+ return (
+ <Container fluid className="login-container mt-2">
+ <h1 className="text-center">{t("welcome")}</h1>
+ <Form>
+ <Form.Group>
+ <Form.Label htmlFor="username">{t("user.username")}</Form.Label>
+ <Form.Control
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ isInvalid={usernameDirty && username === ""}
+ />
+ {usernameDirty && username === "" && (
+ <Form.Control.Feedback type="invalid">
+ {t("login.emptyUsername")}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ <Form.Group>
+ <Form.Label htmlFor="password">{t("user.password")}</Form.Label>
+ <Form.Control
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ onKeyDown={onEnterPressInPassword}
+ isInvalid={passwordDirty && password === ""}
+ />
+ {passwordDirty && password === "" && (
+ <Form.Control.Feedback type="invalid">
+ {t("login.emptyPassword")}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ <Form.Group>
+ <Form.Check<"input">
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ setRememberMe(e.target.checked);
+ }}
+ label={t("user.rememberMe")}
+ />
+ </Form.Group>
+ {error ? <p className="text-danger">{t(error)}</p> : null}
+ <div className="text-right">
+ <LoadingButton
+ loading={process}
+ variant="primary"
+ onClick={(e) => {
+ submit();
+ e.preventDefault();
+ }}
+ disabled={username === "" || password === "" ? true : undefined}
+ >
+ {t("user.login")}
+ </LoadingButton>
+ </div>
+ </Form>
+ </Container>
+ );
+};
+
+export default LoginPage;
diff --git a/Timeline/ClientApp/src/app/views/login/login.sass b/Timeline/ClientApp/src/app/views/login/login.sass
new file mode 100644
index 00000000..0bf385f5
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/login/login.sass
@@ -0,0 +1,2 @@
+.login-container
+ max-width: 600px
diff --git a/Timeline/ClientApp/src/app/views/settings/index.tsx b/Timeline/ClientApp/src/app/views/settings/index.tsx
new file mode 100644
index 00000000..964e7442
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/settings/index.tsx
@@ -0,0 +1,209 @@
+import React, { useState } from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap";
+
+import { useUser, userService } from "@/services/user";
+import OperationDialog, {
+ OperationInputErrorInfo,
+} from "../common/OperationDialog";
+
+interface ChangePasswordDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
+ const history = useHistory();
+ const { t } = useTranslation();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ open={props.open}
+ title={t("settings.dialogChangePassword.title")}
+ titleColor="dangerous"
+ inputPrompt={t("settings.dialogChangePassword.prompt")}
+ inputScheme={[
+ {
+ type: "text",
+ label: t("settings.dialogChangePassword.inputOldPassword"),
+ password: true,
+ validator: (v) =>
+ v === ""
+ ? "settings.dialogChangePassword.errorEmptyOldPassword"
+ : null,
+ },
+ {
+ type: "text",
+ label: t("settings.dialogChangePassword.inputNewPassword"),
+ password: true,
+ validator: (v, values) => {
+ const error: OperationInputErrorInfo = {};
+ error[1] =
+ v === ""
+ ? "settings.dialogChangePassword.errorEmptyNewPassword"
+ : null;
+ if (v === values[2]) {
+ error[2] = null;
+ } else {
+ if (values[2] !== "") {
+ error[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ }
+ return error;
+ },
+ },
+ {
+ type: "text",
+ label: t("settings.dialogChangePassword.inputRetypeNewPassword"),
+ password: true,
+ validator: (v, values) =>
+ v !== values[1]
+ ? "settings.dialogChangePassword.errorRetypeNotMatch"
+ : null,
+ },
+ ]}
+ onProcess={async ([oldPassword, newPassword]) => {
+ await userService
+ .changePassword(oldPassword as string, newPassword as string)
+ .toPromise();
+ await userService.logout();
+ setRedirect(true);
+ }}
+ close={() => {
+ props.close();
+ if (redirect) {
+ history.push("/login");
+ }
+ }}
+ />
+ );
+};
+
+const ConfirmLogoutDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal show centered onHide={toggle}>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {t("settings.dialogConfirmLogout.title")}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{t("settings.dialogConfirmLogout.prompt")}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={toggle}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="danger" onClick={onConfirm}>
+ {t("operationDialog.confirm")}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+const SettingsPage: React.FC = (_) => {
+ const { i18n, t } = useTranslation();
+ const user = useUser();
+ const history = useHistory();
+
+ const [dialog, setDialog] = useState<null | "changepassword" | "logout">(
+ null
+ );
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <Container fluid>
+ {user ? (
+ <>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ onClick={() => {
+ history.push(`/users/${user.username}`);
+ }}
+ >
+ {t("settings.gotoSelf")}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => setDialog("changepassword")}
+ >
+ {t("settings.changePassword")}
+ </h5>
+ </Col>
+ </Row>
+ <Row className="border-bottom p-3 cursor-pointer">
+ <Col xs="12">
+ <h5
+ className="text-danger"
+ onClick={() => {
+ setDialog("logout");
+ }}
+ >
+ {t("settings.logout")}
+ </h5>
+ </Col>
+ </Row>
+ </>
+ ) : null}
+ <Row className="align-items-center border-bottom p-3">
+ <Col xs="12" sm="auto">
+ <h5>{t("settings.languagePrimary")}</h5>
+ <p>{t("settings.languageSecondary")}</p>
+ </Col>
+ <Col xs="auto" className="ml-auto">
+ <Form.Control
+ as="select"
+ value={language}
+ onChange={(e) => {
+ void i18n.changeLanguage(e.target.value);
+ }}
+ >
+ <option value="zh">中文</option>
+ <option value="en">English</option>
+ </Form.Control>
+ </Col>
+ </Row>
+ {(() => {
+ switch (dialog) {
+ case "changepassword":
+ return (
+ <ChangePasswordDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ );
+ case "logout":
+ return (
+ <ConfirmLogoutDialog
+ toggle={() => setDialog(null)}
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ history.push("/");
+ });
+ }}
+ />
+ );
+ default:
+ return null;
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default SettingsPage;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx b/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx
new file mode 100644
index 00000000..3c52150f
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import clsx from "clsx";
+import Svg from "react-inlinesvg";
+import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg";
+import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg";
+
+const CollapseButton: React.FC<{
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}> = ({ collapse, onClick, className, style }) => {
+ return (
+ <Svg
+ src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon}
+ onClick={onClick}
+ className={clsx("text-primary icon-button", className)}
+ style={style}
+ />
+ );
+};
+
+export default CollapseButton;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx
new file mode 100644
index 00000000..a8de20aa
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import clsx from "clsx";
+
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
+import CollapseButton from "../timeline-common/CollapseButton";
+
+const InfoCardTemplate: React.FC<
+ Pick<
+ TimelineCardComponentProps<"">,
+ "collapse" | "toggleCollapse" | "syncStatus" | "className"
+ > & { children: React.ReactElement[] }
+> = ({ collapse, toggleCollapse, syncStatus, className, children }) => {
+ return (
+ <div className={clsx("cru-card p-2 clearfix", className)}>
+ <div className="float-right d-flex align-items-center">
+ <SyncStatusBadge status={syncStatus} className="mr-2" />
+ <CollapseButton collapse={collapse} onClick={toggleCollapse} />
+ </div>
+
+ <div style={{ display: collapse ? "none" : "block" }}>{children}</div>
+ </div>
+ );
+};
+
+export default InfoCardTemplate;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx b/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx
new file mode 100644
index 00000000..e67cfb43
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx
@@ -0,0 +1,58 @@
+import React from "react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+
+import { UiLogicError } from "@/common";
+
+export type TimelineSyncStatus = "syncing" | "synced" | "offline";
+
+const SyncStatusBadge: React.FC<{
+ status: TimelineSyncStatus;
+ style?: React.CSSProperties;
+ className?: string;
+}> = ({ status, style, className }) => {
+ const { t } = useTranslation();
+
+ return (
+ <div style={style} className={clsx("timeline-sync-state-badge", className)}>
+ {(() => {
+ switch (status) {
+ case "syncing": {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-warning" />
+ <span className="text-warning">
+ {t("timeline.postSyncState.syncing")}
+ </span>
+ </>
+ );
+ }
+ case "synced": {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-success" />
+ <span className="text-success">
+ {t("timeline.postSyncState.synced")}
+ </span>
+ </>
+ );
+ }
+ case "offline": {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-danger" />
+ <span className="text-danger">
+ {t("timeline.postSyncState.offline")}
+ </span>
+ </>
+ );
+ }
+ default:
+ throw new UiLogicError("Unknown sync state.");
+ }
+ })()}
+ </div>
+ );
+};
+
+export default SyncStatusBadge;
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx
index 780588d1..fd051d45 100644
--- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx
@@ -1,7 +1,7 @@
import React from "react";
import clsx from "clsx";
-import { TimelinePostInfo } from "../data/timeline";
+import { TimelinePostInfo } from "@/services/timeline";
import TimelineItem from "./TimelineItem";
@@ -51,11 +51,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
}, [posts, onDelete]);
return (
- <div
- ref={props.containerRef}
- className={clsx("container-fluid timeline", props.className)}
- >
- <div className="timeline-enter-animation-mask" />
+ <div ref={props.containerRef} className={clsx("timeline", props.className)}>
{(() => {
const length = posts.length;
return posts.map((post, i) => {
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
index 33f0741e..2b6dcd0a 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
@@ -1,23 +1,16 @@
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 { Modal, Button } from "react-bootstrap";
+
+import { useAvatar } from "@/services/user";
+import { TimelinePostInfo } from "@/services/timeline";
import BlobImage from "../common/BlobImage";
-import { useAvatar } from "../data/user";
-import { TimelinePostInfo } from "../data/timeline";
const TimelinePostDeleteConfirmDialog: React.FC<{
toggle: () => void;
@@ -27,16 +20,18 @@ const TimelinePostDeleteConfirmDialog: React.FC<{
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}>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {t("timeline.post.deleteDialog.title")}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={toggle}>
{t("operationDialog.cancel")}
</Button>
<Button
- color="danger"
+ variant="danger"
onClick={() => {
onConfirm();
toggle();
@@ -44,7 +39,7 @@ const TimelinePostDeleteConfirmDialog: React.FC<{
>
{t("operationDialog.confirm")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</Modal>
);
};
@@ -79,51 +74,45 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
);
return (
- <Row
+ <div
className={clsx(
- "position-relative flex-nowrap",
+ "timeline-item position-relative",
current && "current",
props.className
)}
onClick={props.onClick}
style={props.style}
>
- <Col className="timeline-line-area">
+ <div className="timeline-line-area">
<div className="timeline-line-segment start"></div>
<div className="timeline-line-node-container">
<div className="timeline-line-node"></div>
</div>
<div className="timeline-line-segment end"></div>
{current && <div className="timeline-line-segment current-end" />}
- </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>
+ </div>
+ <div className="timeline-content-area">
+ <div>
+ <span className="mr-2">
+ <span className="text-primary white-space-no-wrap mr-2">
+ {props.post.time.toLocaleString(i18n.languages)}
+ </span>
+ <small className="text-dark">{props.post.author.nickname}</small>
+ </span>
{more != null ? (
- <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>
+ <Svg
+ src={chevronDownIcon}
+ className="text-info icon-button"
+ onClick={(e: Event) => {
+ more.toggle();
+ e.stopPropagation();
+ }}
+ />
) : null}
- </Row>
- <div className="row d-block timeline-content">
+ </div>
+ <div className="timeline-content">
<Link
- className="float-right float-sm-left mx-2"
+ className="float-left m-2"
to={"/users/" + props.post.author.username}
>
<BlobImage
@@ -147,7 +136,7 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
}
})()}
</div>
- </Col>
+ </div>
{more != null && more.isOpen ? (
<>
<div
@@ -156,7 +145,7 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
>
<Svg
src={trashIcon}
- className="text-danger large-icon-button"
+ className="text-danger icon-button large"
onClick={(e: Event) => {
toggleDeleteDialog();
e.stopPropagation();
@@ -174,7 +163,7 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
) : null}
</>
) : null}
- </Row>
+ </div>
);
};
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
index f334c6e9..67a8543a 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
@@ -1,16 +1,9 @@
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 { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
+
+import { User, useAvatar } from "@/services/user";
+
import SearchInput from "../common/SearchInput";
import BlobImage from "../common/BlobImage";
@@ -24,9 +17,9 @@ const TimelineMemberItem: React.FC<{
const avatar = useAvatar(user.username);
return (
- <ListGroupItem className="container">
+ <ListGroup.Item className="container">
<Row>
- <Col className="col-auto">
+ <Col xs="auto">
<BlobImage blob={avatar} className="avatar small" />
</Col>
<Col>
@@ -45,7 +38,7 @@ const TimelineMemberItem: React.FC<{
return (
<Button
className="align-self-center"
- color="danger"
+ variant="danger"
onClick={() => {
onRemove(user.username);
}}
@@ -55,7 +48,7 @@ const TimelineMemberItem: React.FC<{
);
})()}
</Row>
- </ListGroupItem>
+ </ListGroup.Item>
);
};
@@ -168,7 +161,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
</Row>
</Col>
<Button
- color="primary"
+ variant="primary"
className="align-self-center"
disabled={!addable}
onClick={() => {
@@ -211,7 +204,7 @@ export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
props
) => {
return (
- <Modal isOpen={props.open} toggle={props.onClose}>
+ <Modal show centered onHide={props.onClose}>
<TimelineMember {...props} />
</Modal>
);
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 62470e63..d5c91622 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -3,15 +3,14 @@ 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 { UiLogicError } from "@/common";
+import { pushAlert } from "@/services/alert";
+import { useUser, userInfoService, UserNotExistError } from "@/services/user";
import {
timelineService,
usePostList,
useTimelineInfo,
-} from "../data/timeline";
-import { UiLogicError } from "../common";
+} from "@/services/timeline";
import { TimelineDeleteCallback } from "./Timeline";
import { TimelineMemberDialog } from "./TimelineMember";
@@ -23,7 +22,7 @@ export interface TimelinePageTemplateProps<TManageItem> {
name: string;
onManage: (item: TManageItem) => void;
UiComponent: React.ComponentType<
- ExcludeKey<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
+ Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
>;
notFoundI18nKey: string;
}
@@ -161,10 +160,6 @@ export default function TimelinePageTemplate<TManageItem>(
[onManageProp]
);
- const onMember = React.useCallback(() => {
- setDialog("member");
- }, []);
-
return (
<>
<UiComponent
@@ -182,7 +177,7 @@ export default function TimelinePageTemplate<TManageItem>(
? onManage
: undefined
}
- onMember={onMember}
+ onMember={() => setDialog("member")}
/>
{dialogElement}
</>
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index e6514478..58fd024b 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -1,85 +1,33 @@
-import React, { CSSProperties } from "react";
-import { Spinner } from "reactstrap";
+import React from "react";
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 { Spinner } from "react-bootstrap";
-import { getAlertHost } from "../common/alert-service";
-import { useEventEmiiter, UiLogicError } from "../common";
+import { getAlertHost } from "@/services/alert";
+import { useEventEmiiter, UiLogicError } from "@/common";
import {
TimelineInfo,
TimelinePostsWithSyncState,
timelineService,
-} from "../data/timeline";
-import { userService } from "../data/user";
-import AppBar from "../common/AppBar";
+} from "@/services/timeline";
+import { userService } from "@/services/user";
import Timeline, {
TimelinePostInfoEx,
TimelineDeleteCallback,
} from "./Timeline";
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>
- );
-};
+import { TimelineSyncStatus } from "./SyncStatusBadge";
+import clsx from "clsx";
export interface TimelineCardComponentProps<TManageItems> {
timeline: TimelineInfo;
onManage?: (item: TManageItems | "property") => void;
onMember: () => void;
className?: string;
- onHeight?: (height: number) => void;
+ collapse: boolean;
+ syncStatus: TimelineSyncStatus;
+ toggleCollapse: () => void;
}
export interface TimelinePageTemplateUIProps<TManageItems> {
@@ -174,8 +122,6 @@ export default function TimelinePageTemplateUI<TManageItems>(
}
}, [getResizeEvent, triggerResizeEvent, timeline, postListState]);
- const [cardHeight, setCardHeight] = React.useState<number>(0);
-
const genCardCollapseLocalStorageKey = (uniqueId: string): string =>
`timeline.${uniqueId}.cardCollapse`;
@@ -220,29 +166,13 @@ export default function TimelinePageTemplateUI<TManageItems>(
})
);
- 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>
+ <Timeline
+ containerRef={timelineRef}
+ posts={posts}
+ onDelete={props.onDelete}
+ onResize={triggerResizeEvent}
+ />
);
if (props.onPost != null) {
timelineBody = (
@@ -250,6 +180,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
{timelineBody}
<div ref={bottomSpaceRef} className="flex-fix-length" />
<TimelinePostEdit
+ className="fixed-bottom"
onPost={props.onPost}
onHeightChange={onPostEditHeightChange}
timelineUniqueId={timeline.uniqueId}
@@ -261,40 +192,43 @@ export default function TimelinePageTemplateUI<TManageItems>(
} else {
timelineBody = (
<div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
+ <Spinner variant="primary" animation="grow" />
</div>
);
}
+
const { CardComponent } = props;
+ const syncStatus: TimelineSyncStatus =
+ postListState == null || postListState.syncing
+ ? "syncing"
+ : postListState.type === "synced"
+ ? "synced"
+ : "offline";
body = (
<>
<div
- className="fixed-top mt-appbar info-card-container"
- data-collapse={infoCardCollapse ? "true" : "false"}
+ className={clsx(
+ "timeline-template-info-card",
+ infoCardCollapse && "my-collapse"
+ )}
>
- <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"
+ syncStatus={syncStatus}
+ collapse={infoCardCollapse}
+ toggleCollapse={() => {
+ const newState = !infoCardCollapse;
+ setInfoCardCollapse(newState);
+ if (timeline != null) {
+ window.localStorage.setItem(
+ genCardCollapseLocalStorageKey(timeline.uniqueId),
+ newState.toString()
+ );
+ }
+ }}
/>
</div>
{timelineBody}
@@ -303,22 +237,11 @@ export default function TimelinePageTemplateUI<TManageItems>(
} else {
body = (
<div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
+ <Spinner variant="primary" animation="grow" />
</div>
);
}
}
- return (
- <>
- <AppBar />
- <div>
- <div
- style={{ height: 56 + cardHeight }}
- className="timeline-page-top-space flex-fix-length"
- />
- {body}
- </div>
- </>
- );
+ return body;
}
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
index b30dc8d3..dfa2f879 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
@@ -1,14 +1,15 @@
import React from "react";
-import { Button, Spinner, Row, Col } from "reactstrap";
+import clsx from "clsx";
import { useTranslation } from "react-i18next";
import Svg from "react-inlinesvg";
+import { Button, Spinner, Row, Col, Form } from "react-bootstrap";
import textIcon from "bootstrap-icons/icons/card-text.svg";
import imageIcon from "bootstrap-icons/icons/image.svg";
-import { pushAlert } from "../common/alert-service";
-import { TimelineCreatePostRequest } from "../data/timeline";
-import FileInput from "../common/FileInput";
-import { UiLogicError } from "../common";
+import { UiLogicError } from "@/common";
+
+import { pushAlert } from "@/services/alert";
+import { TimelineCreatePostRequest } from "@/services/timeline";
interface TimelinePostEditImageProps {
onSelect: (blob: Blob | null) => void;
@@ -57,11 +58,11 @@ const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
return (
<>
- <FileInput
- labelText={t("chooseImage")}
+ <Form.File
+ label={t("chooseImage")}
onChange={onInputChange}
accept="image/*"
- className="mx-3 my-1"
+ className="mx-3 my-1 d-inline-block"
/>
{fileUrl && error == null && (
<img
@@ -183,11 +184,15 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
}, []);
return (
- <div ref={containerRef} className="container-fluid fixed-bottom bg-light">
+ <div
+ ref={containerRef}
+ className={clsx("container-fluid bg-light", props.className)}
+ >
<Row>
<Col className="px-1 py-1">
{kind === "text" ? (
- <textarea
+ <Form.Control
+ as="textarea"
className="w-100 h-100 timeline-post-edit"
value={text}
disabled={state === "process"}
@@ -201,7 +206,7 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
<TimelinePostEditImage onSelect={onImageSelect} />
)}
</Col>
- <Col sm="col-auto align-self-end m-1">
+ <Col xs="auto" className="align-self-end m-1">
{(() => {
if (state === "input") {
return (
@@ -214,13 +219,17 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
onClick={toggleKind}
/>
</div>
- <Button color="primary" onClick={onSend} disabled={!canSend}>
+ <Button
+ variant="primary"
+ onClick={onSend}
+ disabled={!canSend}
+ >
{t("timeline.send")}
</Button>
</>
);
} else {
- return <Spinner />;
+ return <Spinner variant="primary" animation="border" />;
}
})()}
</Col>
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
index bb0e3ea2..87638f31 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -4,7 +4,8 @@ import {
TimelineVisibility,
kTimelineVisibilities,
TimelineChangePropertyRequest,
-} from "../data/timeline";
+} from "@/services/timeline";
+
import OperationDialog, {
OperationSelectInputInfoOption,
} from "../common/OperationDialog";
diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
index d431a4c6..79df9249 100644
--- a/Timeline/ClientApp/src/app/timeline/timeline.sass
+++ b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
@@ -1,24 +1,11 @@
@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
+ &-item
+ display: flex
$timeline-line-width: 7px
$timeline-line-node-radius: 18px
@@ -33,7 +20,6 @@ $timeline-line-color-current: #36c2e6
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
@@ -84,7 +70,6 @@ $timeline-line-color-current: #36c2e6
animation: 1s infinite alternate
animation-name: timeline-line-node-noncurrent
-
.current
.timeline-line
&-segment
@@ -98,8 +83,9 @@ $timeline-line-color-current: #36c2e6
&-node
animation-name: timeline-line-node-current
-.timeline-pt-start
+.timeline-content-area
padding-top: 18px
+ flex-grow: 1
.timeline-item-delete-button
position: absolute
@@ -113,7 +99,6 @@ $timeline-line-color-current: #36c2e6
max-width: 60%
max-height: 200px
-
.timeline-post-edit-image
max-width: 100px
max-height: 100px
@@ -122,10 +107,34 @@ $timeline-line-color-current: #36c2e6
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
+.timeline-page-top-space
+ transition: height 0.5s
+
+.timeline-sync-state-badge
+ font-size: 0.8em
+ padding: 3px 8px
+ border-radius: 5px
+ background: #e8fbff
+
+.timeline-sync-state-badge-pin
+ display: inline-block
+ width: 0.4em
+ height: 0.4em
+ border-radius: 50%
+ vertical-align: middle
+ margin-right: 0.6em
+
+.timeline-template-info-card
+ position: sticky
+ z-index: 1
+ top: 56px
+ padding: 0.5em
+
+ @include media-breakpoint-down(sm)
+ padding-bottom: 0
+
+ &.my-collapse
+ float: right
+
+ @include media-breakpoint-up(sm)
+ float: right
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx
index 5ebbf9df..894b8195 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx
@@ -2,8 +2,9 @@ import React from "react";
import { useHistory } from "react-router";
import { Trans } from "react-i18next";
+import { timelineService } from "@/services/timeline";
+
import OperationDialog from "../common/OperationDialog";
-import { timelineService } from "../data/timeline";
interface TimelineDeleteDialog {
open: boolean;
diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..d764a275
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,85 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Dropdown, Button } from "react-bootstrap";
+
+import { useAvatar } from "@/services/user";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+
+import BlobImage from "../common/BlobImage";
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import InfoCardTemplate from "../timeline-common/InfoCardTemplate";
+
+export type OrdinaryTimelineManageItem = "delete";
+
+export type TimelineInfoCardProps = TimelineCardComponentProps<
+ OrdinaryTimelineManageItem
+>;
+
+const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
+ const {
+ timeline,
+ onMember,
+ onManage,
+ collapse,
+ syncStatus,
+ toggleCollapse,
+ } = props;
+
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(timeline?.owner?.username);
+
+ return (
+ <InfoCardTemplate
+ className={props.className}
+ syncStatus={syncStatus}
+ collapse={collapse}
+ toggleCollapse={toggleCollapse}
+ >
+ <h3 className="text-primary mx-3 d-inline-block align-middle">
+ {timeline.name}
+ </h3>
+ <div className="d-inline-block align-middle">
+ <BlobImage blob={avatar} className="avatar small rounded-circle" />
+ {timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown>
+ <Dropdown.Toggle variant="outline-primary">
+ {t("timeline.manage")}
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={() => onManage("property")}>
+ {t("timeline.manageItem.property")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={onMember}>
+ {t("timeline.manageItem.member")}
+ </Dropdown.Item>
+ <Dropdown.Divider />
+ <Dropdown.Item
+ className="text-danger"
+ onClick={() => onManage("delete")}
+ >
+ {t("timeline.manageItem.delete")}
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ ) : (
+ <Button variant="outline-primary" onClick={onMember}>
+ {t("timeline.memberButton")}
+ </Button>
+ )}
+ </div>
+ </InfoCardTemplate>
+ );
+};
+
+export default TimelineInfoCard;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx
index 63751eeb..67ea699e 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx
@@ -1,15 +1,14 @@
import React from "react";
-import { ExcludeKey } from "../utilities/type";
-
import TimelinePageTemplateUI, {
TimelinePageTemplateUIProps,
-} from "./TimelinePageTemplateUI";
+} from "../timeline-common/TimelinePageTemplateUI";
+
import TimelineInfoCard, {
OrdinaryTimelineManageItem,
} from "./TimelineInfoCard";
-export type TimelinePageUIProps = ExcludeKey<
+export type TimelinePageUIProps = Omit<
TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>,
"CardComponent"
>;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/views/timeline/index.tsx
index 21d52db1..225a1a59 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline/index.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { useParams } from "react-router";
-import TimelinePageTemplate from "../timeline/TimelinePageTemplate";
+import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
import TimelinePageUI from "./TimelinePageUI";
import { OrdinaryTimelineManageItem } from "./TimelineInfoCard";
diff --git a/Timeline/ClientApp/src/app/views/timeline/timeline.sass b/Timeline/ClientApp/src/app/views/timeline/timeline.sass
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/timeline/timeline.sass
diff --git a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
index 7d9f9514..ffa2218b 100644
--- a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
@@ -1,17 +1,11 @@
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 { Modal, Row, Button } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
-import { UiLogicError } from "../common";
export interface ChangeAvatarDialogProps {
open: boolean;
@@ -55,7 +49,7 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
const closeDialog = props.close;
- const toggle = React.useCallback((): void => {
+ const close = React.useCallback((): void => {
if (!(state === "uploading")) {
closeDialog();
}
@@ -162,23 +156,25 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
};
return (
- <Modal isOpen={props.open} toggle={toggle}>
- <ModalHeader> {t("userPage.dialogChangeAvatar.title")}</ModalHeader>
+ <Modal show={props.open} onHide={close}>
+ <Modal.Header>
+ <Modal.Title> {t("userPage.dialogChangeAvatar.title")}</Modal.Title>
+ </Modal.Header>
{(() => {
if (state === "select") {
return (
<>
- <ModalBody className="container">
+ <Modal.Body 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}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
{t("operationDialog.cancel")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
} else if (state === "crop") {
@@ -187,7 +183,7 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
}
return (
<>
- <ModalBody className="container">
+ <Modal.Body className="container">
<Row className="justify-content-center">
<ImageCropper
clip={clip}
@@ -197,12 +193,12 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
/>
</Row>
<Row>{t("userPage.dialogChangeAvatar.prompt.crop")}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
{t("operationDialog.cancel")}
</Button>
- <Button color="secondary" onClick={onCropPrevious}>
+ <Button variant="secondary" onClick={onCropPrevious}>
{t("operationDialog.previousStep")}
</Button>
<Button
@@ -214,87 +210,87 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
>
{t("operationDialog.nextStep")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
} else if (state === "processcrop") {
return (
<>
- <ModalBody className="container">
+ <Modal.Body className="container">
<Row>
{t("userPage.dialogChangeAvatar.prompt.processingCrop")}
</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
{t("operationDialog.cancel")}
</Button>
- <Button color="secondary" onClick={onPreviewPrevious}>
+ <Button variant="secondary" onClick={onPreviewPrevious}>
{t("operationDialog.previousStep")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
} else if (state === "preview") {
return (
<>
- <ModalBody className="container">
+ <Modal.Body className="container">
{createPreviewRow()}
<Row>{t("userPage.dialogChangeAvatar.prompt.preview")}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
{t("operationDialog.cancel")}
</Button>
- <Button color="secondary" onClick={onPreviewPrevious}>
+ <Button variant="secondary" onClick={onPreviewPrevious}>
{t("operationDialog.previousStep")}
</Button>
- <Button color="primary" onClick={upload}>
+ <Button variant="primary" onClick={upload}>
{t("userPage.dialogChangeAvatar.upload")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
} else if (state === "uploading") {
return (
<>
- <ModalBody className="container">
+ <Modal.Body className="container">
{createPreviewRow()}
<Row>{t("userPage.dialogChangeAvatar.prompt.uploading")}</Row>
- </ModalBody>
- <ModalFooter></ModalFooter>
+ </Modal.Body>
+ <Modal.Footer></Modal.Footer>
</>
);
} else if (state === "success") {
return (
<>
- <ModalBody className="container">
+ <Modal.Body className="container">
<Row className="p-4 text-success">
{t("operationDialog.success")}
</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="success" onClick={toggle}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="success" onClick={close}>
{t("operationDialog.ok")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
} else {
return (
<>
- <ModalBody className="container">
+ <Modal.Body className="container">
{createPreviewRow()}
<Row className="text-danger">{trueMessage}</Row>
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
{t("operationDialog.cancel")}
</Button>
- <Button color="primary" onClick={upload}>
+ <Button variant="primary" onClick={upload}>
{t("operationDialog.retry")}
</Button>
- </ModalFooter>
+ </Modal.Footer>
</>
);
}
diff --git a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx
index 251b18c5..251b18c5 100644
--- a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx
diff --git a/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
new file mode 100644
index 00000000..4cf11e62
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Dropdown, Button } from "react-bootstrap";
+
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+import { useAvatar } from "@/services/user";
+
+import BlobImage from "../common/BlobImage";
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import InfoCardTemplate from "../timeline-common/InfoCardTemplate";
+
+export type PersonalTimelineManageItem = "avatar" | "nickname";
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const {
+ timeline,
+ onMember,
+ onManage,
+ syncStatus,
+ collapse,
+ toggleCollapse,
+ } = props;
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(timeline?.owner?.username);
+
+ return (
+ <InfoCardTemplate
+ className={props.className}
+ syncStatus={syncStatus}
+ collapse={collapse}
+ toggleCollapse={toggleCollapse}
+ >
+ <div>
+ <BlobImage blob={avatar} className="avatar" />
+ {timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {onManage != null ? (
+ <Dropdown>
+ <Dropdown.Toggle variant="outline-primary">
+ {t("timeline.manage")}
+ </Dropdown.Toggle>
+ <Dropdown.Menu>
+ <Dropdown.Item onClick={() => onManage("nickname")}>
+ {t("timeline.manageItem.nickname")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={() => onManage("avatar")}>
+ {t("timeline.manageItem.avatar")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={() => onManage("property")}>
+ {t("timeline.manageItem.property")}
+ </Dropdown.Item>
+ <Dropdown.Item onClick={onMember}>
+ {t("timeline.manageItem.member")}
+ </Dropdown.Item>
+ </Dropdown.Menu>
+ </Dropdown>
+ ) : (
+ <Button variant="outline-primary" onClick={onMember}>
+ {t("timeline.memberButton")}
+ </Button>
+ )}
+ </div>
+ </InfoCardTemplate>
+ );
+};
+
+export default UserInfoCard;
diff --git a/Timeline/ClientApp/src/app/user/UserPage.tsx b/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx
index ab498f30..d405399c 100644
--- a/Timeline/ClientApp/src/app/user/UserPage.tsx
+++ b/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx
@@ -1,19 +1,18 @@
import React from "react";
-import { ExcludeKey } from "../utilities/type";
import TimelinePageTemplateUI, {
TimelinePageTemplateUIProps,
-} from "../timeline/TimelinePageTemplateUI";
+} from "../timeline-common/TimelinePageTemplateUI";
import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard";
-export type UserPageProps = ExcludeKey<
+export type UserPageUIProps = Omit<
TimelinePageTemplateUIProps<PersonalTimelineManageItem>,
"CardComponent"
>;
-const UserPage: React.FC<UserPageProps> = (props) => {
+const UserPageUI: React.FC<UserPageUIProps> = (props) => {
return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
};
-export default UserPage;
+export default UserPageUI;
diff --git a/Timeline/ClientApp/src/app/user/User.tsx b/Timeline/ClientApp/src/app/views/user/index.tsx
index db0a6f76..7c0b1563 100644
--- a/Timeline/ClientApp/src/app/user/User.tsx
+++ b/Timeline/ClientApp/src/app/views/user/index.tsx
@@ -1,16 +1,17 @@
import React, { useState } from "react";
import { useParams } from "react-router";
-import { UiLogicError } from "../common";
-import { useUser, userInfoService } from "../data/user";
-import TimelinePageTemplate from "../timeline/TimelinePageTemplate";
+import { UiLogicError } from "@/common";
+import { useUser, userInfoService } from "@/services/user";
-import UserPage from "./UserPage";
+import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
+
+import UserPageUI from "./UserPageUI";
+import { PersonalTimelineManageItem } from "./UserInfoCard";
import ChangeNicknameDialog from "./ChangeNicknameDialog";
import ChangeAvatarDialog from "./ChangeAvatarDialog";
-import { PersonalTimelineManageItem } from "./UserInfoCard";
-const User: React.FC = (_) => {
+const UserPage: React.FC = (_) => {
const { username } = useParams<{ username: string }>();
const user = useUser();
@@ -59,7 +60,7 @@ const User: React.FC = (_) => {
<>
<TimelinePageTemplate
name={`@${username}`}
- UiComponent={UserPage}
+ UiComponent={UserPageUI}
onManage={onManage}
notFoundI18nKey="timeline.userNotExist"
/>
@@ -68,4 +69,4 @@ const User: React.FC = (_) => {
);
};
-export default User;
+export default UserPage;
diff --git a/Timeline/ClientApp/src/app/user/user-page.sass b/Timeline/ClientApp/src/app/views/user/user.sass
index ca2d10f5..5b7fcae7 100644
--- a/Timeline/ClientApp/src/app/user/user-page.sass
+++ b/Timeline/ClientApp/src/app/views/user/user.sass
@@ -1,6 +1,3 @@
-.login-container
- max-width: 600px
-
.change-avatar-cropper-row
max-height: 400px