From aa89b6cce7701a57b0c377d938788b4c940013d6 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 1 Sep 2020 02:32:06 +0800
Subject: ...

---
 Timeline/ClientApp/src/app/views/about/index.tsx | 172 +++++++++++++++++++++++
 1 file changed, 172 insertions(+)
 create mode 100644 Timeline/ClientApp/src/app/views/about/index.tsx

(limited to 'Timeline/ClientApp/src/app/views/about/index.tsx')

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..21c487da
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/about/index.tsx
@@ -0,0 +1,172 @@
+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 AboutPage: 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 AboutPage;
-- 
cgit v1.2.3


From ac40d9c18e2ac7b4995dac38d5da04a3ea7f1559 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 3 Sep 2020 18:10:02 +0800
Subject: Migrate to react-bootstrap.

---
 Timeline/ClientApp/package.json                    |  2 -
 Timeline/ClientApp/src/app/service-worker.tsx      | 10 ++-
 Timeline/ClientApp/src/app/views/about/index.tsx   |  4 +-
 Timeline/ClientApp/src/app/views/admin/Admin.tsx   | 25 +++---
 .../ClientApp/src/app/views/admin/UserAdmin.tsx    | 47 +++++------
 Timeline/ClientApp/src/app/views/common/AppBar.tsx |  2 +-
 .../ClientApp/src/app/views/common/FileInput.tsx   | 36 ---------
 .../src/app/views/common/OperationDialog.tsx       | 93 ++++++++++------------
 .../src/app/views/common/alert/AlertHost.tsx       |  9 ++-
 Timeline/ClientApp/src/app/views/login/index.tsx   | 53 ++++++------
 .../src/app/views/timeline-common/TimelineItem.tsx | 28 +++----
 .../app/views/timeline-common/TimelineMember.tsx   | 22 ++---
 .../views/timeline-common/TimelinePageTemplate.tsx |  6 +-
 .../timeline-common/TimelinePageTemplateUI.tsx     | 18 ++---
 .../app/views/timeline-common/TimelinePostEdit.tsx | 23 +++---
 .../app/views/timeline-common/timeline-common.sass | 25 ++++--
 .../src/app/views/timeline/TimelineInfoCard.tsx    | 46 ++++-------
 .../ClientApp/src/app/views/timeline/timeline.sass | 14 ----
 .../src/app/views/user/ChangeAvatarDialog.tsx      | 93 ++++++++++------------
 .../ClientApp/src/app/views/user/UserInfoCard.tsx  | 48 ++++-------
 20 files changed, 247 insertions(+), 357 deletions(-)
 delete mode 100644 Timeline/ClientApp/src/app/views/common/FileInput.tsx

(limited to 'Timeline/ClientApp/src/app/views/about/index.tsx')

diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json
index 81232c71..5ae6a608 100644
--- a/Timeline/ClientApp/package.json
+++ b/Timeline/ClientApp/package.json
@@ -27,7 +27,6 @@
     "react-router": "^5.2.0",
     "react-router-bootstrap": "^0.25.0",
     "react-router-dom": "^5.2.0",
-    "reactstrap": "^8.5.1",
     "regenerator-runtime": "^0.13.7",
     "rxjs": "^6.6.2",
     "workbox-precaching": "^5.1.3",
@@ -75,7 +74,6 @@
     "@types/react-router": "^5.1.8",
     "@types/react-router-bootstrap": "^0.24.5",
     "@types/react-router-dom": "^5.1.5",
-    "@types/reactstrap": "^8.5.1",
     "@types/webpack-env": "^1.15.2",
     "@types/xregexp": "^4.3.0",
     "@typescript-eslint/eslint-plugin": "^3.10.1",
diff --git a/Timeline/ClientApp/src/app/service-worker.tsx b/Timeline/ClientApp/src/app/service-worker.tsx
index e629995a..3be54bc1 100644
--- a/Timeline/ClientApp/src/app/service-worker.tsx
+++ b/Timeline/ClientApp/src/app/service-worker.tsx
@@ -1,6 +1,6 @@
 import React from "react";
-import { Button } from "reactstrap";
 import { useTranslation } from "react-i18next";
+import { Button } from "react-bootstrap";
 
 import { pushAlert } from "./services/alert";
 
@@ -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/views/about/index.tsx b/Timeline/ClientApp/src/app/views/about/index.tsx
index 21c487da..78cffb5f 100644
--- a/Timeline/ClientApp/src/app/views/about/index.tsx
+++ b/Timeline/ClientApp/src/app/views/about/index.tsx
@@ -23,8 +23,8 @@ const frontendCredits: {
     url: "https://getbootstrap.com",
   },
   {
-    name: "reactstrap",
-    url: "https://reactstrap.github.io",
+    name: "react-bootstrap",
+    url: "https://react-bootstrap.github.io",
   },
   {
     name: "babeljs",
diff --git a/Timeline/ClientApp/src/app/views/admin/Admin.tsx b/Timeline/ClientApp/src/app/views/admin/Admin.tsx
index 51dc5a3c..e0f59b0f 100644
--- a/Timeline/ClientApp/src/app/views/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,7 +6,7 @@ import {
   useRouteMatch,
   useHistory,
 } from "react-router";
-import classnames from "classnames";
+import { Nav } from "react-bootstrap";
 
 import AppBar from "../common/AppBar";
 import { UserWithToken } from "@/services/user";
@@ -37,27 +36,27 @@ const Admin: React.FC<AdminProps> = (props) => {
       <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/views/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
index bde6b3af..18b77ca8 100644
--- a/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
+++ b/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
@@ -1,16 +1,13 @@
 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 "@/services/user";
@@ -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
index 464747c0..ee4ead8f 100644
--- a/Timeline/ClientApp/src/app/views/common/AppBar.tsx
+++ b/Timeline/ClientApp/src/app/views/common/AppBar.tsx
@@ -17,7 +17,7 @@ const AppBar: React.FC = (_) => {
   const isAdministrator = user && user.administrator;
 
   return (
-    <Navbar bg="primary" variant="dark" expand="md">
+    <Navbar bg="primary" variant="dark" expand="md" sticky="top">
       <LinkContainer to="/">
         <Navbar.Brand className="d-flex align-items-center">
           <TimelineLogo style={{ height: "1em" }} />
diff --git a/Timeline/ClientApp/src/app/views/common/FileInput.tsx b/Timeline/ClientApp/src/app/views/common/FileInput.tsx
deleted file mode 100644
index 7b053d5c..00000000
--- a/Timeline/ClientApp/src/app/views/common/FileInput.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-export interface FileInputProps
-  extends Omit<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/views/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
index 402ffbec..6f97eb15 100644
--- a/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
@@ -1,26 +1,13 @@
 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 { Spinner, Container, Form, Button, Modal } from "react-bootstrap";
 
 import { UiLogicError } from "@/common";
 
 const DefaultProcessPrompt: React.FC = (_) => {
   return (
     <Container className="justify-content-center align-items-center">
-      <Spinner />
+      <Spinner animation="border" variant="success" />
     </Container>
   );
 };
@@ -233,7 +220,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
 
     body = (
       <>
-        <ModalBody>
+        <Modal.Body>
           {inputPrompt}
           {inputScheme.map((item, index) => {
             const value = values[index];
@@ -242,9 +229,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,35 +245,35 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
                         )
                       );
                     }}
-                    invalid={error != null}
-                    {...item.textFieldProps}
+                    isInvalid={error != null}
                   />
-                  {error != null && <FormFeedback>{error}</FormFeedback>}
-                  {item.helperText && <FormText>{t(item.helperText)}</FormText>}
-                </FormGroup>
+                  {error != null && (
+                    <Form.Control.Feedback>{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)}
                   />
-                  <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);
@@ -300,18 +287,18 @@ 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="secondary" onClick={close}>
             {t("operationDialog.cancel")}
           </Button>
           <Button
-            color="primary"
+            variant="primary"
             disabled={testErrorInfo(inputError)}
             onClick={() => {
               if (validateAll()) {
@@ -321,14 +308,14 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
           >
             {t("operationDialog.confirm")}
           </Button>
-        </ModalFooter>
+        </Modal.Footer>
       </>
     );
   } else if (step === "process") {
     body = (
-      <ModalBody>
+      <Modal.Body>
         {props.processPrompt?.() ?? <DefaultProcessPrompt />}
-      </ModalBody>
+      </Modal.Body>
     );
   } else {
     let content: React.ReactNode;
@@ -345,12 +332,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>
       </>
     );
   }
@@ -359,7 +346,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
 
   return (
     <Modal isOpen={props.open} toggle={close}>
-      <ModalHeader
+      <Modal.Header
         className={
           props.titleColor != null
             ? "text-" +
@@ -372,7 +359,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => {
         }
       >
         {title}
-      </ModalHeader>
+      </Modal.Header>
       {body}
     </Modal>
   );
diff --git a/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx b/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
index 31c0fb86..c74f18e2 100644
--- a/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
+++ b/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
@@ -1,8 +1,8 @@
 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,
@@ -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/views/login/index.tsx b/Timeline/ClientApp/src/app/views/login/index.tsx
index e53d0002..5d1e8f06 100644
--- a/Timeline/ClientApp/src/app/views/login/index.tsx
+++ b/Timeline/ClientApp/src/app/views/login/index.tsx
@@ -1,15 +1,7 @@
 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 { Form, Spinner, Button } from "react-bootstrap";
 
 import { useUser, userService } from "@/services/user";
 
@@ -84,9 +76,9 @@ const LoginPage: React.FC = (_) => {
       <div className="container login-container mt-appbar">
         <h1>{t("welcome")}</h1>
         <Form>
-          <FormGroup>
-            <Label for="username">{t("user.username")}</Label>
-            <Input
+          <Form.Group>
+            <Form.Label htmlFor="username">{t("user.username")}</Form.Label>
+            <Form.Control
               id="username"
               disabled={process}
               onChange={(e) => {
@@ -94,15 +86,17 @@ const LoginPage: React.FC = (_) => {
                 setUsernameDirty(true);
               }}
               value={username}
-              invalid={usernameDirty && username === ""}
+              isInvalid={usernameDirty && username === ""}
             />
             {usernameDirty && username === "" && (
-              <FormFeedback>{t("login.emptyUsername")}</FormFeedback>
+              <Form.Control.Feedback>
+                {t("login.emptyUsername")}
+              </Form.Control.Feedback>
             )}
-          </FormGroup>
-          <FormGroup>
-            <Label for="password">{t("user.password")}</Label>
-            <Input
+          </Form.Group>
+          <Form.Group>
+            <Form.Label htmlFor="password">{t("user.password")}</Form.Label>
+            <Form.Control
               id="password"
               type="password"
               disabled={process}
@@ -111,30 +105,31 @@ const LoginPage: React.FC = (_) => {
                 setPasswordDirty(true);
               }}
               value={password}
-              invalid={passwordDirty && password === ""}
+              isInvalid={passwordDirty && password === ""}
             />
             {passwordDirty && password === "" && (
-              <FormFeedback>{t("login.emptyPassword")}</FormFeedback>
+              <Form.Control.Feedback>
+                {t("login.emptyPassword")}
+              </Form.Control.Feedback>
             )}
-          </FormGroup>
-          <FormGroup check>
-            <Input
+          </Form.Group>
+          <Form.Group>
+            <Form.Check<"input">
               id="remember-me"
               type="checkbox"
               checked={rememberMe}
               onChange={(e) => {
-                const v = (e.target as HTMLInputElement).checked;
-                setRememberMe(v);
+                setRememberMe(e.target.checked);
               }}
+              label={t("user.rememberMe")}
             />
-            <Label for="remember-me">{t("user.rememberMe")}</Label>
-          </FormGroup>
+          </Form.Group>
           {error ? <p className="text-error">{t(error)}</p> : null}
           <div>
             {process ? (
-              <Spinner />
+              <Spinner animation="border" />
             ) : (
-              <Button color="primary" onClick={onSubmit}>
+              <Button variant="primary" onClick={onSubmit}>
                 {t("user.login")}
               </Button>
             )}
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
index f2441612..ce371015 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
@@ -1,19 +1,11 @@
 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 { Row, Col, Modal, Button } from "react-bootstrap";
 
 import { useAvatar } from "@/services/user";
 import { TimelinePostInfo } from "@/services/timeline";
@@ -28,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();
@@ -45,7 +39,7 @@ const TimelinePostDeleteConfirmDialog: React.FC<{
         >
           {t("operationDialog.confirm")}
         </Button>
-      </ModalFooter>
+      </Modal.Footer>
     </Modal>
   );
 };
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
index 99605922..67a8543a 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
@@ -1,14 +1,6 @@
 import React, { useState } from "react";
 import { useTranslation } from "react-i18next";
-import {
-  Container,
-  ListGroup,
-  ListGroupItem,
-  Modal,
-  Row,
-  Col,
-  Button,
-} from "reactstrap";
+import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
 
 import { User, useAvatar } from "@/services/user";
 
@@ -25,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>
@@ -46,7 +38,7 @@ const TimelineMemberItem: React.FC<{
           return (
             <Button
               className="align-self-center"
-              color="danger"
+              variant="danger"
               onClick={() => {
                 onRemove(user.username);
               }}
@@ -56,7 +48,7 @@ const TimelineMemberItem: React.FC<{
           );
         })()}
       </Row>
-    </ListGroupItem>
+    </ListGroup.Item>
   );
 };
 
@@ -169,7 +161,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
                             </Row>
                           </Col>
                           <Button
-                            color="primary"
+                            variant="primary"
                             className="align-self-center"
                             disabled={!addable}
                             onClick={() => {
@@ -212,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/views/timeline-common/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 1b03d5c7..d5c91622 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -160,10 +160,6 @@ export default function TimelinePageTemplate<TManageItem>(
     [onManageProp]
   );
 
-  const onMember = React.useCallback(() => {
-    setDialog("member");
-  }, []);
-
   return (
     <>
       <UiComponent
@@ -181,7 +177,7 @@ export default function TimelinePageTemplate<TManageItem>(
             ? onManage
             : undefined
         }
-        onMember={onMember}
+        onMember={() => setDialog("member")}
       />
       {dialogElement}
     </>
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index 7af11efa..e25ed962 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -1,9 +1,9 @@
 import React, { CSSProperties } from "react";
-import { Spinner } from "reactstrap";
+import clsx from "clsx";
 import { useTranslation } from "react-i18next";
 import { fromEvent } from "rxjs";
 import Svg from "react-inlinesvg";
-import clsx from "clsx";
+import { Spinner } from "react-bootstrap";
 import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg";
 import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg";
 
@@ -262,7 +262,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
       } else {
         timelineBody = (
           <div className="full-viewport-center-child">
-            <Spinner color="primary" type="grow" />
+            <Spinner variant="primary" animation="grow" />
           </div>
         );
       }
@@ -271,7 +271,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
       body = (
         <>
           <div
-            className="fixed-top mt-appbar info-card-container"
+            className="info-card-container"
             data-collapse={infoCardCollapse ? "true" : "false"}
           >
             <Svg
@@ -304,7 +304,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
     } else {
       body = (
         <div className="full-viewport-center-child">
-          <Spinner color="primary" type="grow" />
+          <Spinner variant="primary" animation="grow" />
         </div>
       );
     }
@@ -313,13 +313,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
   return (
     <>
       <AppBar />
-      <div>
-        <div
-          style={{ height: 56 + cardHeight }}
-          className="timeline-page-top-space flex-fix-length"
-        />
-        {body}
-      </div>
+      {body}
     </>
   );
 }
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
index 6a8bb000..42f83b52 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
@@ -1,7 +1,7 @@
 import React from "react";
-import { Button, Spinner, Row, Col } from "reactstrap";
 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";
 
@@ -10,8 +10,6 @@ import { UiLogicError } from "@/common";
 import { pushAlert } from "@/services/alert";
 import { TimelineCreatePostRequest } from "@/services/timeline";
 
-import FileInput from "../common/FileInput";
-
 interface TimelinePostEditImageProps {
   onSelect: (blob: Blob | null) => void;
 }
@@ -59,11 +57,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
@@ -189,7 +187,8 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
       <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"}
@@ -203,7 +202,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 (
@@ -216,13 +215,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/views/timeline-common/timeline-common.sass b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
index 960c992d..1862de02 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
+++ b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
@@ -119,14 +119,6 @@ $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
 
@@ -147,3 +139,20 @@ textarea.timeline-post-edit
   border-radius: 50%
   vertical-align: middle
   margin-right: 0.6em
+
+.info-card-container
+  position: sticky
+  z-index: 1
+
+  .info-card-collapse-button
+    z-index: 1
+    position: relative
+
+  .info-card-content
+    width: 100%
+    transform-origin: right top
+    transition: transform 0.5s
+
+  &[data-collapse='true']
+    .info-card-content
+      transform: scale(0)
diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
index e3e89057..bf5c3105 100644
--- a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
@@ -1,14 +1,8 @@
 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 { Dropdown, Button } from "react-bootstrap";
 
 import { useAvatar } from "@/services/user";
 import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
@@ -23,7 +17,7 @@ export type TimelineInfoCardProps = TimelineCardComponentProps<
 >;
 
 const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
-  const { onHeight, onManage } = props;
+  const { onHeight, onMember, onManage } = props;
 
   const { t } = useTranslation();
 
@@ -43,18 +37,10 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
     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)}
+      className={clsx("rounded border p-2 bg-light clearfix", props.className)}
       onTransitionEnd={notifyHeight}
     >
       <h3 className="text-primary mx-3 d-inline-block align-middle">
@@ -77,28 +63,28 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
       </small>
       <div className="text-right mt-2">
         {onManage != null ? (
-          <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
-            <DropdownToggle outline color="primary">
+          <Dropdown>
+            <Dropdown.Toggle variant="outline-primary">
               {t("timeline.manage")}
-            </DropdownToggle>
-            <DropdownMenu>
-              <DropdownItem onClick={() => onManage("property")}>
+            </Dropdown.Toggle>
+            <Dropdown.Menu>
+              <Dropdown.Item onClick={() => onManage("property")}>
                 {t("timeline.manageItem.property")}
-              </DropdownItem>
-              <DropdownItem onClick={props.onMember}>
+              </Dropdown.Item>
+              <Dropdown.Item onClick={onMember}>
                 {t("timeline.manageItem.member")}
-              </DropdownItem>
-              <DropdownItem divider />
-              <DropdownItem
+              </Dropdown.Item>
+              <Dropdown.Divider />
+              <Dropdown.Item
                 className="text-danger"
                 onClick={() => onManage("delete")}
               >
                 {t("timeline.manageItem.delete")}
-              </DropdownItem>
-            </DropdownMenu>
+              </Dropdown.Item>
+            </Dropdown.Menu>
           </Dropdown>
         ) : (
-          <Button color="primary" outline onClick={props.onMember}>
+          <Button variant="outline-primary" onClick={onMember}>
             {t("timeline.memberButton")}
           </Button>
         )}
diff --git a/Timeline/ClientApp/src/app/views/timeline/timeline.sass b/Timeline/ClientApp/src/app/views/timeline/timeline.sass
index 0eeec73a..e69de29b 100644
--- a/Timeline/ClientApp/src/app/views/timeline/timeline.sass
+++ b/Timeline/ClientApp/src/app/views/timeline/timeline.sass
@@ -1,14 +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)
diff --git a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
index 1dd2ee8b..ffa2218b 100644
--- a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
+++ b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
@@ -1,14 +1,7 @@
 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";
 
@@ -56,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();
     }
@@ -163,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") {
@@ -188,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}
@@ -198,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
@@ -215,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/views/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
index 1a111877..f1878b5c 100644
--- a/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
@@ -1,14 +1,8 @@
 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 { Dropdown, Button } from "react-bootstrap";
 
 import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
 import { useAvatar } from "@/services/user";
@@ -42,24 +36,16 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
     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)}
+      className={clsx("rounded border bg-light p-2 clearfix", props.className)}
       onTransitionEnd={notifyHeight}
     >
       <BlobImage
         blob={avatar}
         onLoad={notifyHeight}
-        className="avatar large mr-2 mb-2 rounded-circle float-left"
+        className="avatar large mr-2 rounded-circle float-left"
       />
       <div>
         {props.timeline.owner.nickname}
@@ -73,27 +59,27 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
       </small>
       <div className="text-right mt-2">
         {onManage != null ? (
-          <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
-            <DropdownToggle outline color="primary">
+          <Dropdown>
+            <Dropdown.Toggle variant="outline-primary">
               {t("timeline.manage")}
-            </DropdownToggle>
-            <DropdownMenu>
-              <DropdownItem onClick={() => onManage("nickname")}>
+            </Dropdown.Toggle>
+            <Dropdown.Menu>
+              <Dropdown.Item onClick={() => onManage("nickname")}>
                 {t("timeline.manageItem.nickname")}
-              </DropdownItem>
-              <DropdownItem onClick={() => onManage("avatar")}>
+              </Dropdown.Item>
+              <Dropdown.Item onClick={() => onManage("avatar")}>
                 {t("timeline.manageItem.avatar")}
-              </DropdownItem>
-              <DropdownItem onClick={() => onManage("property")}>
+              </Dropdown.Item>
+              <Dropdown.Item onClick={() => onManage("property")}>
                 {t("timeline.manageItem.property")}
-              </DropdownItem>
-              <DropdownItem onClick={props.onMember}>
+              </Dropdown.Item>
+              <Dropdown.Item onClick={props.onMember}>
                 {t("timeline.manageItem.member")}
-              </DropdownItem>
-            </DropdownMenu>
+              </Dropdown.Item>
+            </Dropdown.Menu>
           </Dropdown>
         ) : (
-          <Button color="primary" outline onClick={props.onMember}>
+          <Button variant="outline-primary" onClick={props.onMember}>
             {t("timeline.memberButton")}
           </Button>
         )}
-- 
cgit v1.2.3


From 880ed701d5ae3f3b21b30d3b9e2db5d98d6b9513 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 3 Sep 2020 18:56:40 +0800
Subject: Move AppBar to App .

---
 Timeline/ClientApp/src/app/App.tsx                 |  75 +++++----
 Timeline/ClientApp/src/app/views/about/index.tsx   | 156 +++++++++----------
 Timeline/ClientApp/src/app/views/admin/Admin.tsx   |   2 -
 Timeline/ClientApp/src/app/views/home/index.tsx    |   2 -
 Timeline/ClientApp/src/app/views/login/index.tsx   | 151 +++++++++---------
 .../ClientApp/src/app/views/settings/index.tsx     | 170 ++++++++++-----------
 .../timeline-common/TimelinePageTemplateUI.tsx     |   7 +-
 7 files changed, 268 insertions(+), 295 deletions(-)

(limited to 'Timeline/ClientApp/src/app/views/about/index.tsx')

diff --git a/Timeline/ClientApp/src/app/App.tsx b/Timeline/ClientApp/src/app/App.tsx
index b64414b7..b68eddb6 100644
--- a/Timeline/ClientApp/src/app/App.tsx
+++ b/Timeline/ClientApp/src/app/App.tsx
@@ -11,6 +11,7 @@ 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";
 
@@ -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/views/about/index.tsx b/Timeline/ClientApp/src/app/views/about/index.tsx
index 78cffb5f..e7771cec 100644
--- a/Timeline/ClientApp/src/app/views/about/index.tsx
+++ b/Timeline/ClientApp/src/app/views/about/index.tsx
@@ -1,8 +1,6 @@
 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";
 
@@ -75,97 +73,91 @@ const AboutPage: 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 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>
-            <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>
+            <small>{t("about.author.links")}</small>
             <a
-              href="https://github.com/crupest/Timeline"
+              href="https://github.com/crupest"
               target="_blank"
               rel="noopener noreferrer"
             >
-              {t("about.site.repo")}
+              <img src={githubLogoUrl} className="about-link-icon text-body" />
             </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>
-    </>
+      <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>
   );
 };
 
diff --git a/Timeline/ClientApp/src/app/views/admin/Admin.tsx b/Timeline/ClientApp/src/app/views/admin/Admin.tsx
index e0f59b0f..9c0250e7 100644
--- a/Timeline/ClientApp/src/app/views/admin/Admin.tsx
+++ b/Timeline/ClientApp/src/app/views/admin/Admin.tsx
@@ -8,7 +8,6 @@ import {
 } from "react-router";
 import { Nav } from "react-bootstrap";
 
-import AppBar from "../common/AppBar";
 import { UserWithToken } from "@/services/user";
 
 import UserAdmin from "./UserAdmin";
@@ -34,7 +33,6 @@ const Admin: React.FC<AdminProps> = (props) => {
   ): React.ReactNode => {
     return (
       <Route path={`${match.path}/${name}`}>
-        <AppBar />
         <div style={{ height: 56 }} className="flex-fix-length" />
         <Nav variant="tabs">
           <Nav.Item>
diff --git a/Timeline/ClientApp/src/app/views/home/index.tsx b/Timeline/ClientApp/src/app/views/home/index.tsx
index e2e3e6f9..11672c10 100644
--- a/Timeline/ClientApp/src/app/views/home/index.tsx
+++ b/Timeline/ClientApp/src/app/views/home/index.tsx
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
 import { Row, Container, Button, Col } from "react-bootstrap";
 
 import { useUser } from "@/services/user";
-import AppBar from "../common/AppBar";
 import SearchInput from "../common/SearchInput";
 
 import BoardWithoutUser from "./BoardWithoutUser";
@@ -34,7 +33,6 @@ const HomePage: React.FC = () => {
 
   return (
     <>
-      <AppBar />
       <Container fluid>
         <Row>
           <Col>
diff --git a/Timeline/ClientApp/src/app/views/login/index.tsx b/Timeline/ClientApp/src/app/views/login/index.tsx
index 545fc05a..a3071264 100644
--- a/Timeline/ClientApp/src/app/views/login/index.tsx
+++ b/Timeline/ClientApp/src/app/views/login/index.tsx
@@ -1,7 +1,7 @@
-import React, { Fragment, useState, useEffect } from "react";
+import React from "react";
 import { useHistory } from "react-router";
 import { useTranslation } from "react-i18next";
-import { Form, Spinner, Button } from "react-bootstrap";
+import { Form } from "react-bootstrap";
 
 import { useUser, userService } from "@/services/user";
 
@@ -11,17 +11,17 @@ import LoadingButton from "../common/LoadingButton";
 const LoginPage: 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 [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();
 
-  useEffect(() => {
+  React.useEffect(() => {
     if (user != null) {
       const id = setTimeout(() => history.push("/"), 3000);
       return () => {
@@ -72,73 +72,70 @@ const LoginPage: React.FC = (_) => {
   }
 
   return (
-    <Fragment>
-      <AppBar />
-      <div className="container login-container mt-appbar">
-        <h1>{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}
-              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={onSubmit}
-              disabled={username === "" || password === "" ? true : undefined}
-            >
-              {t("user.login")}
-            </LoadingButton>
-          </div>
-        </Form>
-      </div>
-    </Fragment>
+    <div className="container login-container mt-appbar">
+      <h1>{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}
+            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={onSubmit}
+            disabled={username === "" || password === "" ? true : undefined}
+          >
+            {t("user.login")}
+          </LoadingButton>
+        </div>
+      </Form>
+    </div>
   );
 };
 
diff --git a/Timeline/ClientApp/src/app/views/settings/index.tsx b/Timeline/ClientApp/src/app/views/settings/index.tsx
index 123e1353..964e7442 100644
--- a/Timeline/ClientApp/src/app/views/settings/index.tsx
+++ b/Timeline/ClientApp/src/app/views/settings/index.tsx
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
 import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap";
 
 import { useUser, userService } from "@/services/user";
-import AppBar from "../common/AppBar";
 import OperationDialog, {
   OperationInputErrorInfo,
 } from "../common/OperationDialog";
@@ -121,92 +120,89 @@ const SettingsPage: React.FC = (_) => {
   const language = i18n.language.slice(0, 2);
 
   return (
-    <>
-      <AppBar />
-      <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>
-    </>
+    <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>
   );
 };
 
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index e25ed962..c33bc0e5 100644
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -310,10 +310,5 @@ export default function TimelinePageTemplateUI<TManageItems>(
     }
   }
 
-  return (
-    <>
-      <AppBar />
-      {body}
-    </>
-  );
+  return body;
 }
-- 
cgit v1.2.3

