diff options
Diffstat (limited to 'Timeline/ClientApp/src/app')
-rw-r--r-- | Timeline/ClientApp/src/app/App.tsx | 99 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/about/About.tsx | 172 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/common/AppBar.tsx | 107 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/common/FileInput.tsx | 41 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/index.sass | 44 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/service-worker.tsx | 12 | ||||
-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.tsx | 221 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx | 110 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/timeline/timeline-ui.sass | 35 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/Login.tsx | 147 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/UserInfoCard.tsx | 104 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/utilities/type.ts | 1 | ||||
-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) | bin | 12038 -> 12038 bytes | |||
-rw-r--r-- | Timeline/ClientApp/src/app/views/about/github.png (renamed from Timeline/ClientApp/src/app/about/github.png) | bin | 4268 -> 4268 bytes | |||
-rw-r--r-- | Timeline/ClientApp/src/app/views/about/index.tsx | 164 | ||||
-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.tsx | 64 | ||||
-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.tsx | 29 | ||||
-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.tsx | 151 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/views/login/login.sass | 2 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/views/settings/index.tsx | 209 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx | 23 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx | 26 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx | 58 | ||||
-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.tsx | 85 | ||||
-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.sass | 0 | ||||
-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.tsx | 80 | ||||
-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 Binary files differindex d890d8d0..d890d8d0 100644 --- a/Timeline/ClientApp/src/app/about/author-avatar.png +++ b/Timeline/ClientApp/src/app/views/about/author-avatar.png diff --git a/Timeline/ClientApp/src/app/about/github.png b/Timeline/ClientApp/src/app/views/about/github.png Binary files differindex ea6ff545..ea6ff545 100644 --- a/Timeline/ClientApp/src/app/about/github.png +++ b/Timeline/ClientApp/src/app/views/about/github.png 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 |