aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/App.tsx56
-rw-r--r--FrontEnd/src/common.ts3
-rw-r--r--FrontEnd/src/components/AppBar.css96
-rw-r--r--FrontEnd/src/components/AppBar.tsx106
-rw-r--r--FrontEnd/src/components/BlobImage.tsx41
-rw-r--r--FrontEnd/src/components/Card.css20
-rw-r--r--FrontEnd/src/components/Card.tsx39
-rw-r--r--FrontEnd/src/components/Icon.css4
-rw-r--r--FrontEnd/src/components/Icon.tsx28
-rw-r--r--FrontEnd/src/components/ImageCropper.css (renamed from FrontEnd/src/views/common/ImageCropper.css)18
-rw-r--r--FrontEnd/src/components/ImageCropper.tsx323
-rw-r--r--FrontEnd/src/components/LoadFailReload.tsx (renamed from FrontEnd/src/views/common/LoadFailReload.tsx)0
-rw-r--r--FrontEnd/src/components/Page.css8
-rw-r--r--FrontEnd/src/components/Page.tsx17
-rw-r--r--FrontEnd/src/components/SearchInput.css (renamed from FrontEnd/src/views/common/SearchInput.css)4
-rw-r--r--FrontEnd/src/components/SearchInput.tsx50
-rw-r--r--FrontEnd/src/components/Skeleton.css20
-rw-r--r--FrontEnd/src/components/Skeleton.tsx22
-rw-r--r--FrontEnd/src/components/Spinner.css (renamed from FrontEnd/src/views/common/Spinner.css)0
-rw-r--r--FrontEnd/src/components/Spinner.tsx46
-rw-r--r--FrontEnd/src/components/TimelineLogo.tsx (renamed from FrontEnd/src/views/common/TimelineLogo.tsx)0
-rw-r--r--FrontEnd/src/components/alert/AlertHost.tsx82
-rw-r--r--FrontEnd/src/components/alert/AlertService.ts114
-rw-r--r--FrontEnd/src/components/alert/alert.css21
-rw-r--r--FrontEnd/src/components/alert/index.ts8
-rw-r--r--FrontEnd/src/components/breakpoints.ts3
-rw-r--r--FrontEnd/src/components/button/Button.css64
-rw-r--r--FrontEnd/src/components/button/Button.tsx (renamed from FrontEnd/src/views/common/button/Button.tsx)9
-rw-r--r--FrontEnd/src/components/button/ButtonRow.css0
-rw-r--r--FrontEnd/src/components/button/ButtonRow.tsx62
-rw-r--r--FrontEnd/src/components/button/ButtonRowV2.tsx146
-rw-r--r--FrontEnd/src/components/button/FlatButton.css27
-rw-r--r--FrontEnd/src/components/button/FlatButton.tsx (renamed from FrontEnd/src/views/common/button/FlatButton.tsx)9
-rw-r--r--FrontEnd/src/components/button/IconButton.css30
-rw-r--r--FrontEnd/src/components/button/IconButton.tsx (renamed from FrontEnd/src/views/common/button/IconButton.tsx)7
-rw-r--r--FrontEnd/src/components/button/LoadingButton.css13
-rw-r--r--FrontEnd/src/components/button/LoadingButton.tsx39
-rw-r--r--FrontEnd/src/components/button/index.tsx15
-rw-r--r--FrontEnd/src/components/common.ts22
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.tsx54
-rw-r--r--FrontEnd/src/components/dialog/Dialog.css39
-rw-r--r--FrontEnd/src/components/dialog/Dialog.tsx55
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.css20
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.tsx95
-rw-r--r--FrontEnd/src/components/dialog/DialogProvider.tsx95
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.css30
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.tsx52
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.css4
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.tsx221
-rw-r--r--FrontEnd/src/components/dialog/index.tsx12
-rw-r--r--FrontEnd/src/components/hooks/index.ts5
-rw-r--r--FrontEnd/src/components/hooks/responsive.ts7
-rw-r--r--FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts24
-rw-r--r--FrontEnd/src/components/hooks/useClickOutside.ts (renamed from FrontEnd/src/utilities/hooks/useClickOutside.ts)2
-rw-r--r--FrontEnd/src/components/hooks/useScrollToBottom.ts (renamed from FrontEnd/src/utilities/hooks/useScrollToBottom.ts)9
-rw-r--r--FrontEnd/src/components/hooks/useWindowLeave.ts22
-rw-r--r--FrontEnd/src/components/index.css49
-rw-r--r--FrontEnd/src/components/input/InputGroup.css54
-rw-r--r--FrontEnd/src/components/input/InputGroup.tsx463
-rw-r--r--FrontEnd/src/components/input/index.ts11
-rw-r--r--FrontEnd/src/components/list/ListContainer.css4
-rw-r--r--FrontEnd/src/components/list/ListContainer.tsx23
-rw-r--r--FrontEnd/src/components/list/ListItemContainer.css7
-rw-r--r--FrontEnd/src/components/list/ListItemContainer.tsx23
-rw-r--r--FrontEnd/src/components/list/index.ts4
-rw-r--r--FrontEnd/src/components/menu/Menu.css36
-rw-r--r--FrontEnd/src/components/menu/Menu.tsx62
-rw-r--r--FrontEnd/src/components/menu/PopupMenu.css7
-rw-r--r--FrontEnd/src/components/menu/PopupMenu.tsx72
-rw-r--r--FrontEnd/src/components/tab/TabBar.css32
-rw-r--r--FrontEnd/src/components/tab/TabBar.tsx69
-rw-r--r--FrontEnd/src/components/tab/TabPages.css3
-rw-r--r--FrontEnd/src/components/tab/TabPages.tsx61
-rw-r--r--FrontEnd/src/components/tab/index.ts2
-rw-r--r--FrontEnd/src/components/theme.css201
-rw-r--r--FrontEnd/src/components/user/UserAvatar.tsx22
-rw-r--r--FrontEnd/src/http/bookmark.ts2
-rw-r--r--FrontEnd/src/http/timeline.ts2
-rw-r--r--FrontEnd/src/index.css29
-rw-r--r--FrontEnd/src/index.tsx7
-rw-r--r--FrontEnd/src/locales/en/translation.json23
-rw-r--r--FrontEnd/src/locales/zh/translation.json2
-rw-r--r--FrontEnd/src/migrating/admin/Admin.tsx (renamed from FrontEnd/src/views/admin/Admin.tsx)0
-rw-r--r--FrontEnd/src/migrating/admin/AdminNav.tsx (renamed from FrontEnd/src/views/admin/AdminNav.tsx)0
-rw-r--r--FrontEnd/src/migrating/admin/MoreAdmin.tsx (renamed from FrontEnd/src/views/admin/MoreAdmin.tsx)0
-rw-r--r--FrontEnd/src/migrating/admin/UserAdmin.tsx (renamed from FrontEnd/src/views/admin/UserAdmin.tsx)39
-rw-r--r--FrontEnd/src/migrating/admin/index.css (renamed from FrontEnd/src/views/admin/index.css)0
-rw-r--r--FrontEnd/src/migrating/admin/index.tsx (renamed from FrontEnd/src/views/admin/index.tsx)0
-rw-r--r--FrontEnd/src/migrating/center/CenterBoards.tsx (renamed from FrontEnd/src/views/center/CenterBoards.tsx)10
-rw-r--r--FrontEnd/src/migrating/center/TimelineBoard.tsx (renamed from FrontEnd/src/views/center/TimelineBoard.tsx)2
-rw-r--r--FrontEnd/src/migrating/center/TimelineCreateDialog.tsx (renamed from FrontEnd/src/views/center/TimelineCreateDialog.tsx)6
-rw-r--r--FrontEnd/src/migrating/center/index.css (renamed from FrontEnd/src/views/center/index.css)0
-rw-r--r--FrontEnd/src/migrating/center/index.tsx (renamed from FrontEnd/src/views/center/index.tsx)2
-rw-r--r--FrontEnd/src/pages/404/index.css7
-rw-r--r--FrontEnd/src/pages/404/index.tsx5
-rw-r--r--FrontEnd/src/pages/about/index.css7
-rw-r--r--FrontEnd/src/pages/about/index.tsx87
-rw-r--r--FrontEnd/src/pages/home/index.css13
-rw-r--r--FrontEnd/src/pages/home/index.tsx12
-rw-r--r--FrontEnd/src/pages/loading/index.css7
-rw-r--r--FrontEnd/src/pages/loading/index.tsx11
-rw-r--r--FrontEnd/src/pages/login/index.css14
-rw-r--r--FrontEnd/src/pages/login/index.tsx127
-rw-r--r--FrontEnd/src/pages/register/index.css (renamed from FrontEnd/src/views/register/index.css)0
-rw-r--r--FrontEnd/src/pages/register/index.tsx130
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.css22
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx276
-rw-r--r--FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx26
-rw-r--r--FrontEnd/src/pages/setting/ChangePasswordDialog.tsx70
-rw-r--r--FrontEnd/src/pages/setting/index.css76
-rw-r--r--FrontEnd/src/pages/setting/index.tsx297
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.css (renamed from FrontEnd/src/views/timeline/ConnectionStatusBadge.css)16
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx (renamed from FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx)21
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.css42
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.tsx (renamed from FrontEnd/src/views/timeline/Timeline.tsx)113
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.css9
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.tsx13
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx54
-rw-r--r--FrontEnd/src/pages/timeline/TimelineInfoCard.css63
-rw-r--r--FrontEnd/src/pages/timeline/TimelineInfoCard.tsx208
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.css20
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.tsx (renamed from FrontEnd/src/views/timeline/TimelineMember.tsx)119
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostCard.css9
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostCard.tsx22
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContainer.css3
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContainer.tsx20
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostList.css10
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostList.tsx (renamed from FrontEnd/src/views/timeline/TimelinePostListView.tsx)21
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.css37
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.tsx123
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx79
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.css5
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx36
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css24
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx199
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css12
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx29
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css35
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx193
-rw-r--r--FrontEnd/src/pages/timeline/index.tsx (renamed from FrontEnd/src/views/timeline/index.tsx)13
-rw-r--r--FrontEnd/src/pages/timeline/view/ImagePostView.css0
-rw-r--r--FrontEnd/src/pages/timeline/view/ImagePostView.tsx38
-rw-r--r--FrontEnd/src/pages/timeline/view/MarkdownPostView.css4
-rw-r--r--FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx59
-rw-r--r--FrontEnd/src/pages/timeline/view/PlainTextPostView.css0
-rw-r--r--FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx50
-rw-r--r--FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx37
-rw-r--r--FrontEnd/src/palette.ts167
-rw-r--r--FrontEnd/src/services/TimelinePostBuilder.ts125
-rw-r--r--FrontEnd/src/services/alert.ts6
-rw-r--r--FrontEnd/src/services/timeline.ts199
-rw-r--r--FrontEnd/src/services/user.ts23
-rw-r--r--FrontEnd/src/utilities/array.ts41
-rw-r--r--FrontEnd/src/utilities/base64.ts21
-rw-r--r--FrontEnd/src/utilities/geometry.ts292
-rw-r--r--FrontEnd/src/utilities/hooks.ts5
-rw-r--r--FrontEnd/src/utilities/hooks/mediaQuery.ts5
-rw-r--r--FrontEnd/src/utilities/index.ts27
-rw-r--r--FrontEnd/src/views/about/author-avatar.pngbin12038 -> 0 bytes
-rw-r--r--FrontEnd/src/views/about/github.pngbin4268 -> 0 bytes
-rw-r--r--FrontEnd/src/views/about/index.css4
-rw-r--r--FrontEnd/src/views/about/index.tsx143
-rw-r--r--FrontEnd/src/views/common/AppBar.css95
-rw-r--r--FrontEnd/src/views/common/AppBar.tsx81
-rw-r--r--FrontEnd/src/views/common/BlobImage.tsx27
-rw-r--r--FrontEnd/src/views/common/Card.css15
-rw-r--r--FrontEnd/src/views/common/Card.tsx27
-rw-r--r--FrontEnd/src/views/common/ImageCropper.tsx306
-rw-r--r--FrontEnd/src/views/common/LoadingPage.tsx13
-rw-r--r--FrontEnd/src/views/common/SearchInput.tsx79
-rw-r--r--FrontEnd/src/views/common/Skeleton.css14
-rw-r--r--FrontEnd/src/views/common/Skeleton.tsx32
-rw-r--r--FrontEnd/src/views/common/Spinner.tsx43
-rw-r--r--FrontEnd/src/views/common/alert/AlertHost.tsx113
-rw-r--r--FrontEnd/src/views/common/alert/alert.css33
-rw-r--r--FrontEnd/src/views/common/button/Button.css51
-rw-r--r--FrontEnd/src/views/common/button/FlatButton.css18
-rw-r--r--FrontEnd/src/views/common/button/IconButton.css10
-rw-r--r--FrontEnd/src/views/common/button/LoadingButton.tsx40
-rw-r--r--FrontEnd/src/views/common/button/index.tsx6
-rw-r--r--FrontEnd/src/views/common/dialog/ConfirmDialog.tsx43
-rw-r--r--FrontEnd/src/views/common/dialog/Dialog.css55
-rw-r--r--FrontEnd/src/views/common/dialog/Dialog.tsx51
-rw-r--r--FrontEnd/src/views/common/dialog/FullPageDialog.css44
-rw-r--r--FrontEnd/src/views/common/dialog/FullPageDialog.tsx53
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.css25
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.tsx531
-rw-r--r--FrontEnd/src/views/common/index.css293
-rw-r--r--FrontEnd/src/views/common/input/InputPanel.css25
-rw-r--r--FrontEnd/src/views/common/input/InputPanel.tsx257
-rw-r--r--FrontEnd/src/views/common/menu/Menu.css24
-rw-r--r--FrontEnd/src/views/common/menu/Menu.tsx72
-rw-r--r--FrontEnd/src/views/common/menu/PopupMenu.css6
-rw-r--r--FrontEnd/src/views/common/menu/PopupMenu.tsx71
-rw-r--r--FrontEnd/src/views/common/tab/TabPages.tsx71
-rw-r--r--FrontEnd/src/views/common/tab/Tabs.css33
-rw-r--r--FrontEnd/src/views/common/tab/Tabs.tsx62
-rw-r--r--FrontEnd/src/views/common/user/UserAvatar.tsx19
-rw-r--r--FrontEnd/src/views/home/TimelineListView.tsx97
-rw-r--r--FrontEnd/src/views/home/WebsiteIntroduction.tsx77
-rw-r--r--FrontEnd/src/views/home/index.css42
-rw-r--r--FrontEnd/src/views/home/index.tsx78
-rw-r--r--FrontEnd/src/views/login/index.css8
-rw-r--r--FrontEnd/src/views/login/index.tsx159
-rw-r--r--FrontEnd/src/views/register/index.tsx140
-rw-r--r--FrontEnd/src/views/search/index.css15
-rw-r--r--FrontEnd/src/views/search/index.tsx131
-rw-r--r--FrontEnd/src/views/settings/ChangeAvatarDialog.tsx354
-rw-r--r--FrontEnd/src/views/settings/ChangeNicknameDialog.tsx34
-rw-r--r--FrontEnd/src/views/settings/ChangePasswordDialog.tsx69
-rw-r--r--FrontEnd/src/views/settings/index.css31
-rw-r--r--FrontEnd/src/views/settings/index.tsx338
-rw-r--r--FrontEnd/src/views/timeline/CollapseButton.tsx21
-rw-r--r--FrontEnd/src/views/timeline/MarkdownPostEdit.css21
-rw-r--r--FrontEnd/src/views/timeline/MarkdownPostEdit.tsx215
-rw-r--r--FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx42
-rw-r--r--FrontEnd/src/views/timeline/Timeline.css244
-rw-r--r--FrontEnd/src/views/timeline/TimelineCard.tsx167
-rw-r--r--FrontEnd/src/views/timeline/TimelineDateLabel.tsx19
-rw-r--r--FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx61
-rw-r--r--FrontEnd/src/views/timeline/TimelineEmptyItem.tsx25
-rw-r--r--FrontEnd/src/views/timeline/TimelineLine.tsx51
-rw-r--r--FrontEnd/src/views/timeline/TimelineLoading.tsx16
-rw-r--r--FrontEnd/src/views/timeline/TimelineMember.css8
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostContentView.tsx187
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEdit.css10
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEdit.tsx267
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEditCard.tsx31
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx18
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostView.tsx159
-rw-r--r--FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx88
231 files changed, 6781 insertions, 6808 deletions
diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index cfdab229..58463d08 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -1,51 +1,35 @@
-import * as React from "react";
+import { Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
-import AppBar from "./views/common/AppBar";
-import LoadingPage from "./views/common/LoadingPage";
-import Center from "./views/center";
-import Home from "./views/home";
-import Login from "./views/login";
-import Register from "./views/register";
-import Settings from "./views/settings";
-import About from "./views/about";
-import TimelinePage from "./views/timeline";
-import Search from "./views/search";
-import Admin from "./views/admin";
-import AlertHost from "./views/common/alert/AlertHost";
-
-import { useUser } from "./services/user";
-
-const NoMatch: React.FC = () => {
- return <div>Ah-oh, 404!</div>;
-};
-
-function App(): JSX.Element {
- const user = useUser();
+import AppBar from "./components/AppBar";
+import NotFoundPage from "./pages/404";
+import HomePage from "./pages/home";
+import AboutPage from "./pages/about";
+import SettingPage from "./pages/setting";
+import LoginPage from "./pages/login";
+import RegisterPage from "./pages/register";
+import TimelinePage from "./pages/timeline";
+import LoadingPage from "./pages/loading";
+import { AlertHost } from "./components/alert";
+export default function App() {
return (
- <React.Suspense fallback={<LoadingPage />}>
+ <Suspense fallback={<LoadingPage />}>
<BrowserRouter>
<AppBar />
<div style={{ height: 56 }} />
<Routes>
- <Route index element={user == null ? <Home /> : <Center />} />
- <Route path="home" element={<Home />} />
- <Route path="center" element={<Center />} />
- <Route path="login" element={<Login />} />
- <Route path="register" element={<Register />} />
- <Route path="settings" element={<Settings />} />
- <Route path="about" element={<About />} />
- <Route path="search" element={<Search />} />
- <Route path="admin/*" element={<Admin />} />
+ <Route path="login" element={<LoginPage />} />
+ <Route path="register" element={<RegisterPage />} />
+ <Route path="settings" element={<SettingPage />} />
+ <Route path="about" element={<AboutPage />} />
<Route path=":owner" element={<TimelinePage />} />
<Route path=":owner/:timeline" element={<TimelinePage />} />
- <Route element={<NoMatch />} />
+ <Route path="" element={<HomePage />} />
+ <Route path="*" element={<NotFoundPage />} />
</Routes>
<AlertHost />
</BrowserRouter>
- </React.Suspense>
+ </Suspense>
);
}
-
-export default App;
diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts
index 965f9933..1ca796c3 100644
--- a/FrontEnd/src/common.ts
+++ b/FrontEnd/src/common.ts
@@ -3,8 +3,7 @@
// This error should never occur. If it does, it indicates there is some logic bug in codes.
export class UiLogicError extends Error {}
-export const highlightTimelineUsername = "crupest";
-
export type { I18nText } from "./i18n";
+export type { I18nText as Text } from "./i18n";
export { c, convertI18nText } from "./i18n";
export { default as useC } from "./utilities/hooks/use-c";
diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css
new file mode 100644
index 00000000..38497478
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.css
@@ -0,0 +1,96 @@
+.app-bar {
+ height: 56px;
+ position: fixed;
+ z-index: 1030;
+ top: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--cru-primary-color);
+}
+
+.app-bar {
+ display: flex;
+}
+
+.app-bar > * {
+ background-color: var(--cru-primary-color);
+}
+
+.app-bar .app-bar-brand {
+ display: flex;
+ align-items: center;
+}
+
+.app-bar .app-bar-brand-icon {
+ height: 2em;
+}
+
+.app-bar .app-bar-space {
+ flex-grow: 1;
+}
+
+.app-bar .app-bar-user-area {
+ display: flex;
+}
+
+.app-bar a {
+ background-color: var(--cru-primary-color);
+ color: var(--cru-push-button-text-color);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ padding: 0 1em;
+ transition: all 0.5s;
+}
+
+.app-bar a:hover {
+ background-color: var(--cru-clickable-primary-hover-color);
+}
+
+.app-bar a:focus {
+ background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar a:active {
+ background-color: var(--cru-clickable-primary-active-color);
+}
+
+/* the current page */
+.app-bar a.active {
+ background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar .app-bar-avatar img {
+ width: 45px;
+ height: 45px;
+ background-color: white;
+ border-radius: 50%;
+}
+
+.app-bar.desktop .app-bar-link-area {
+ display: flex;
+}
+
+.app-bar.mobile .app-bar-link-area {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 100%;
+ translate: 0 -100%;
+ transition: transform 0.5s;
+}
+
+.app-bar.mobile a {
+ height: 56px;
+}
+
+.app-bar.mobile.expand .app-bar-link-area {
+ transform: translateY(100%);
+}
+
+.app-bar .toggler {
+ font-size: 2em;
+ padding-right: 0.5em;
+}
+
diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx
new file mode 100644
index 00000000..d40c8105
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.tsx
@@ -0,0 +1,106 @@
+import { useState } from "react";
+import classnames from "classnames";
+import { Link, NavLink } from "react-router-dom";
+
+import { useUser } from "~src/services/user";
+
+import { I18nText, useC } from "./common";
+import { useMobile } from "./hooks";
+import TimelineLogo from "./TimelineLogo";
+import { IconButton } from "./button";
+import UserAvatar from "./user/UserAvatar";
+
+import "./AppBar.css";
+
+function AppBarNavLink({
+ link,
+ className,
+ label,
+ onClick,
+ children,
+}: {
+ link: string;
+ className?: string;
+ label?: I18nText;
+ onClick?: () => void;
+ children?: React.ReactNode;
+}) {
+ if (label != null && children != null) {
+ throw new Error("AppBarNavLink: label and children cannot be both set");
+ }
+
+ const c = useC();
+
+ return (
+ <NavLink
+ to={link}
+ className={({ isActive }) => classnames(className, isActive && "active")}
+ onClick={onClick}
+ >
+ {children != null ? children : c(label)}
+ </NavLink>
+ );
+}
+
+export default function AppBar() {
+ const isMobile = useMobile();
+
+ const [isCollapse, setIsCollapse] = useState<boolean>(true);
+ const collapse = isMobile ? () => setIsCollapse(true) : undefined;
+ const toggleCollapse = () => setIsCollapse(!isCollapse);
+
+ const user = useUser();
+ const hasAdministrationPermission = user && user.hasAdministrationPermission;
+
+ return (
+ <nav
+ className={classnames(
+ isMobile ? "mobile" : "desktop",
+ "app-bar",
+ isCollapse || "expand",
+ )}
+ >
+ <Link to="/" className="app-bar-brand" onClick={collapse}>
+ <TimelineLogo className="app-bar-brand-icon" />
+ Timeline
+ </Link>
+
+ <div className="app-bar-link-area">
+ <AppBarNavLink
+ link="/settings"
+ label="nav.settings"
+ onClick={collapse}
+ />
+ <AppBarNavLink link="/about" label="nav.about" onClick={collapse} />
+ {hasAdministrationPermission && (
+ <AppBarNavLink
+ link="/admin"
+ label="nav.administration"
+ onClick={collapse}
+ />
+ )}
+ </div>
+
+ <div className="app-bar-space" />
+
+ <div className="app-bar-user-area">
+ {user != null ? (
+ <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}>
+ <UserAvatar username={user.username} />
+ </AppBarNavLink>
+ ) : (
+ <AppBarNavLink link="/login" label="nav.login" onClick={collapse} />
+ )}
+ </div>
+
+ {isMobile && (
+ <IconButton
+ icon="list"
+ color="light"
+ className="toggler"
+ onClick={toggleCollapse}
+ />
+ )}
+ </nav>
+ );
+}
diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
new file mode 100644
index 00000000..047a13b4
--- /dev/null
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -0,0 +1,41 @@
+import {
+ useState,
+ useEffect,
+ useMemo,
+ Ref,
+ ComponentPropsWithoutRef,
+} from "react";
+
+type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
+ imgRef?: Ref<HTMLImageElement>;
+ src?: Blob | string | null;
+ keyBySrc?: boolean;
+};
+
+export default function BlobImage(props: BlobImageProps) {
+ const { imgRef, src, keyBySrc, ...otherProps } = props;
+
+ const [url, setUrl] = useState<string | null | undefined>(undefined);
+
+ useEffect(() => {
+ if (src instanceof Blob) {
+ const url = URL.createObjectURL(src);
+ setUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setUrl(src);
+ }
+ }, [src]);
+
+ const key = useMemo(() => {
+ if (keyBySrc) {
+ return url == null ? undefined : btoa(url);
+ } else {
+ return undefined;
+ }
+ }, [url, keyBySrc]);
+
+ return <img key={key} ref={imgRef} {...otherProps} src={url ?? undefined} />;
+}
diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css
new file mode 100644
index 00000000..6d655eb9
--- /dev/null
+++ b/FrontEnd/src/components/Card.css
@@ -0,0 +1,20 @@
+.cru-card {
+ border-radius: var(--cru-card-border-radius);
+ transition: all 0.3s;
+}
+
+.cru-card-background-none {
+ background-color: transparent;
+}
+
+.cru-card-background-solid {
+ background-color: var(--cru-background-color);
+}
+
+.cru-card-background-grayscale {
+ background-color: var(--cru-container-background-color);
+}
+
+.cru-card-border-color {
+ border: 2px solid var(--cru-card-border-color);
+}
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx
new file mode 100644
index 00000000..5d3ef630
--- /dev/null
+++ b/FrontEnd/src/components/Card.tsx
@@ -0,0 +1,39 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Card.css";
+
+interface CardProps extends ComponentPropsWithoutRef<"div"> {
+ containerRef?: Ref<HTMLDivElement>;
+ color?: ThemeColor;
+ border?: "color" | "none";
+ background?: "color" | "solid" | "grayscale" | "none";
+}
+
+export default function Card({
+ color,
+ background,
+ border,
+ className,
+ children,
+ containerRef,
+ ...otherProps
+}: CardProps) {
+ return (
+ <div
+ ref={containerRef}
+ className={classNames(
+ "cru-card",
+ `cru-card-${color ?? "primary"}`,
+ `cru-card-border-${border ?? "color"}`,
+ `cru-card-background-${background ?? "solid"}`,
+ className,
+ )}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css
new file mode 100644
index 00000000..3c83b0e9
--- /dev/null
+++ b/FrontEnd/src/components/Icon.css
@@ -0,0 +1,4 @@
+.cru-icon {
+ color: var(--cru-theme-color);
+ font-size: 1.4rem;
+}
diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx
new file mode 100644
index 00000000..e5cf598e
--- /dev/null
+++ b/FrontEnd/src/components/Icon.tsx
@@ -0,0 +1,28 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Icon.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+ icon: string;
+ color?: ThemeColor;
+ size?: string | number;
+}
+
+export default function Icon(props: IconButtonProps) {
+ const { icon, color, size, style, className, ...otherProps } = props;
+
+ return (
+ <i
+ style={size != null ? { ...style, fontSize: size } : style}
+ className={classNames(
+ `cru-theme-${color ?? "primary"}`,
+ `bi-${icon} cru-icon`,
+ className,
+ )}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/views/common/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css
index 2c4d0a8c..03d2038f 100644
--- a/FrontEnd/src/views/common/ImageCropper.css
+++ b/FrontEnd/src/components/ImageCropper.css
@@ -1,18 +1,16 @@
-.image-cropper-container {
+.cru-image-cropper-container {
position: relative;
box-sizing: border-box;
+ display: flex;
user-select: none;
}
-.image-cropper-container img {
- position: absolute;
- left: 0;
- top: 0;
+.cru-image-cropper-container img {
width: 100%;
height: 100%;
}
-.image-cropper-mask-container {
+.cru-image-cropper-mask-container {
position: absolute;
left: 0;
top: 0;
@@ -21,18 +19,16 @@
overflow: hidden;
}
-.image-cropper-mask {
+.cru-image-cropper-mask {
position: absolute;
box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
touch-action: none;
}
-.image-cropper-handler {
+.cru-image-cropper-handler {
position: absolute;
- width: 26px;
- height: 26px;
border: black solid 2px;
border-radius: 50%;
background: white;
touch-action: none;
-}
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
new file mode 100644
index 00000000..4dfdd0cd
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -0,0 +1,323 @@
+import { useState, useRef, PointerEvent } from "react";
+import classnames from "classnames";
+
+import { UiLogicError, geometry } from "./common";
+
+import BlobImage from "./BlobImage";
+
+import "./ImageCropper.css";
+
+const { Rect } = geometry;
+
+type Rect = geometry.Rect;
+type Movement = geometry.Movement;
+
+export function crop(
+ image: HTMLImageElement,
+ clip: Rect,
+ mimeType: string,
+): Promise<Blob> {
+ return new Promise((resolve, reject) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = clip.width;
+ canvas.height = clip.height;
+ const context = canvas.getContext("2d");
+
+ if (context == null) throw new Error("Failed to create context.");
+
+ context.drawImage(
+ image,
+ clip.left,
+ clip.top,
+ clip.width,
+ clip.height,
+ 0,
+ 0,
+ clip.width,
+ clip.height,
+ );
+
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ reject(new Error("canvas.toBlob returns null"));
+ } else {
+ resolve(blob);
+ }
+ }, mimeType);
+ });
+}
+
+interface ImageInfo {
+ element: HTMLImageElement;
+ width: number;
+ height: number;
+ ratio: number;
+ landscape: boolean;
+ rect: Rect;
+}
+
+export interface CropConstraint {
+ ratio?: number;
+ // minClipWidth?: number;
+ // minClipHeight?: number;
+ // maxClipWidth?: number;
+ // maxClipHeight?: number;
+}
+
+function generateImageInfo(imageElement: HTMLImageElement): ImageInfo {
+ const { naturalWidth, naturalHeight } = imageElement;
+ const imageRatio = naturalHeight / naturalWidth;
+
+ return {
+ element: imageElement,
+ width: naturalWidth,
+ height: naturalHeight,
+ ratio: imageRatio,
+ landscape: imageRatio < 1,
+ rect: new Rect(0, 0, naturalWidth, naturalHeight),
+ };
+}
+
+interface ImageCropperProps {
+ clip: Rect;
+ image: Blob | string | null;
+ imageElementCallback: (element: HTMLImageElement | null) => void;
+ onImageLoad: () => void;
+ onMove: (movement: Movement, originalClip: Rect) => void;
+ onResize: (movement: Movement, originalClip: Rect) => void;
+ containerClassName?: string;
+}
+
+export function useImageCrop(
+ file: File | null,
+ options?: {
+ constraint?: CropConstraint;
+ },
+): {
+ clip: Rect;
+ setClip: (clip: Rect) => void;
+ canCrop: boolean;
+ crop: () => Promise<Blob>;
+ imageCropperProps: ImageCropperProps;
+} {
+ const targetRatio = options?.constraint?.ratio;
+
+ const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
+ null,
+ );
+ const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
+ const [clip, setClip] = useState<Rect>(Rect.empty);
+
+ if (imageElement == null && imageInfo != null) {
+ setImageInfo(null);
+ setClip(Rect.empty);
+ }
+
+ const canCrop = file != null && imageElement != null && imageInfo != null;
+
+ return {
+ clip,
+ setClip,
+ canCrop,
+ crop() {
+ if (!canCrop) throw new UiLogicError();
+ return crop(imageElement, clip, file.type);
+ },
+ imageCropperProps: {
+ clip,
+ image: file,
+ imageElementCallback: setImageElement,
+ onMove: (movement, originalClip) => {
+ if (imageInfo == null) return;
+ const newClip = geometry.adjustRectToContainer(
+ originalClip.copy().move(movement),
+ imageInfo.rect,
+ "move",
+ {
+ targetRatio,
+ },
+ );
+ setClip(newClip);
+ },
+ onResize: (movement, originalClip) => {
+ if (imageInfo == null) return;
+ const newClip = geometry.adjustRectToContainer(
+ originalClip.copy().expand(movement),
+ imageInfo.rect,
+ "resize",
+ { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" },
+ );
+ setClip(newClip);
+ },
+ onImageLoad: () => {
+ if (imageElement == null) throw new UiLogicError();
+ const image = generateImageInfo(imageElement);
+ setImageInfo(image);
+ setClip(
+ geometry.adjustRectToContainer(Rect.max, image.rect, "both", {
+ targetRatio,
+ }),
+ );
+ },
+ },
+ };
+}
+
+interface PointerState {
+ x: number;
+ y: number;
+ pointerId: number;
+ originalClip: Rect;
+}
+
+const imageCropperHandlerSize = 15;
+
+export function ImageCropper(props: ImageCropperProps) {
+ function convertClipToElement(
+ clip: Rect,
+ imageElement: HTMLImageElement,
+ ): Rect {
+ const xRatio = imageElement.clientWidth / imageElement.naturalWidth;
+ const yRatio = imageElement.clientHeight / imageElement.naturalHeight;
+ return Rect.from({
+ left: xRatio * clip.left,
+ top: yRatio * clip.top,
+ width: xRatio * clip.width,
+ height: yRatio * clip.height,
+ });
+ }
+
+ function convertMovementFromElement(
+ move: Movement,
+ imageElement: HTMLImageElement,
+ ): Movement {
+ const xRatio = imageElement.naturalWidth / imageElement.clientWidth;
+ const yRatio = imageElement.naturalHeight / imageElement.clientHeight;
+ return {
+ x: xRatio * move.x,
+ y: yRatio * move.y,
+ };
+ }
+
+ const {
+ clip,
+ image,
+ imageElementCallback,
+ onImageLoad,
+ onMove,
+ onResize,
+ containerClassName,
+ } = props;
+
+ const pointerStateRef = useRef<PointerState | null>(null);
+ const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
+ null,
+ );
+
+ const clipInElement: Rect =
+ imageElement != null
+ ? convertClipToElement(clip, imageElement)
+ : Rect.empty;
+
+ const actOnMovement = (
+ e: PointerEvent,
+ change: (movement: Movement, originalClip: Rect) => void,
+ ) => {
+ if (
+ imageElement == null ||
+ pointerStateRef.current == null ||
+ pointerStateRef.current.pointerId != e.pointerId
+ ) {
+ return;
+ }
+
+ const { x, y, originalClip } = pointerStateRef.current;
+
+ const movement = {
+ x: e.clientX - x,
+ y: e.clientY - y,
+ };
+
+ change(convertMovementFromElement(movement, imageElement), originalClip);
+ };
+
+ const onPointerDown = (e: PointerEvent) => {
+ if (imageElement == null || pointerStateRef.current != null) return;
+
+ e.currentTarget.setPointerCapture(e.pointerId);
+
+ pointerStateRef.current = {
+ x: e.clientX,
+ y: e.clientY,
+ pointerId: e.pointerId,
+ originalClip: clip,
+ };
+ };
+
+ const onPointerUp = (e: PointerEvent) => {
+ if (
+ pointerStateRef.current == null ||
+ pointerStateRef.current.pointerId != e.pointerId
+ ) {
+ return;
+ }
+
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ pointerStateRef.current = null;
+ };
+
+ const onMaskPointerMove = (e: PointerEvent) => {
+ actOnMovement(e, onMove);
+ };
+
+ const onResizeHandlerPointerMove = (e: PointerEvent) => {
+ actOnMovement(e, onResize);
+ };
+
+ return (
+ <div
+ className={classnames("cru-image-cropper-container", containerClassName)}
+ >
+ <BlobImage
+ imgRef={(element) => {
+ setImageElement(element);
+ imageElementCallback(element);
+ }}
+ src={image}
+ onLoad={onImageLoad}
+ />
+ <div className="cru-image-cropper-mask-container">
+ <div
+ className="cru-image-cropper-mask"
+ style={
+ clipInElement == null
+ ? undefined
+ : {
+ left: clipInElement.left,
+ top: clipInElement.top,
+ width: clipInElement.width,
+ height: clipInElement.height,
+ }
+ }
+ onPointerMove={onMaskPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="cru-image-cropper-handler"
+ style={{
+ left:
+ clipInElement.left + clipInElement.width - imageCropperHandlerSize,
+ top:
+ clipInElement.top + clipInElement.height - imageCropperHandlerSize,
+ width: imageCropperHandlerSize * 2,
+ height: imageCropperHandlerSize * 2,
+ }}
+ onPointerMove={onResizeHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
index 81ba1f67..81ba1f67 100644
--- a/FrontEnd/src/views/common/LoadFailReload.tsx
+++ b/FrontEnd/src/components/LoadFailReload.tsx
diff --git a/FrontEnd/src/components/Page.css b/FrontEnd/src/components/Page.css
new file mode 100644
index 00000000..b22d83af
--- /dev/null
+++ b/FrontEnd/src/components/Page.css
@@ -0,0 +1,8 @@
+.cru-page {
+ padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+ padding-top: 0;
+}
+
diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx
new file mode 100644
index 00000000..8c9febcc
--- /dev/null
+++ b/FrontEnd/src/components/Page.tsx
@@ -0,0 +1,17 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./Page.css";
+
+interface PageProps extends ComponentPropsWithoutRef<"div"> {
+ noTopPadding?: boolean;
+ pageRef?: Ref<HTMLDivElement>;
+}
+
+export default function Page({ noTopPadding, pageRef, className, children }: PageProps) {
+ return (
+ <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}>
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/views/common/SearchInput.css b/FrontEnd/src/components/SearchInput.css
index f0503016..818b2917 100644
--- a/FrontEnd/src/views/common/SearchInput.css
+++ b/FrontEnd/src/components/SearchInput.css
@@ -1,8 +1,8 @@
.cru-search-input {
display: flex;
- flex-wrap: wrap;
+ gap: 1em;
}
.cru-search-input-input {
width: 100%;
-}
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx
new file mode 100644
index 00000000..b1de6227
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.tsx
@@ -0,0 +1,50 @@
+import classNames from "classnames";
+
+import { useC, Text } from "./common";
+import { LoadingButton } from "./button";
+
+import "./SearchInput.css";
+
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ loading?: boolean;
+ className?: string;
+ buttonText?: Text;
+}
+
+export default function SearchInput({
+ value,
+ onChange,
+ onButtonClick,
+ loading,
+ className,
+ buttonText,
+}: SearchInputProps) {
+ const c = useC();
+
+ return (
+ <div className={classNames("cru-search-input", className)}>
+ <input
+ type="search"
+ className="cru-search-input-input"
+ value={value}
+ onChange={(event) => {
+ const { value } = event.currentTarget;
+ onChange(value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ onButtonClick();
+ event.preventDefault();
+ }
+ }}
+ />
+
+ <LoadingButton loading={loading} onClick={onButtonClick}>
+ {c(buttonText ?? "search")}
+ </LoadingButton>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css
new file mode 100644
index 00000000..0f78d3b5
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.css
@@ -0,0 +1,20 @@
+.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: hsl(0 0% 90%);
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+@media (prefers-color-scheme: dark) {
+ .cru-skeleton-line {
+ background-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-skeleton-line:last-child {
+ width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx
new file mode 100644
index 00000000..03f80df5
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.tsx
@@ -0,0 +1,22 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { range } from "~src/utilities";
+
+import "./Skeleton.css";
+
+interface SkeletonProps extends ComponentPropsWithoutRef<"div"> {
+ lineNumber?: number;
+}
+
+export default function Skeleton(props: SkeletonProps) {
+ const { lineNumber, className, ...otherProps } = props;
+
+ return (
+ <div className={classNames(className, "cru-skeleton")} {...otherProps}>
+ {range(lineNumber ?? 3).map((i) => (
+ <div key={i} className="cru-skeleton-line" />
+ ))}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/views/common/Spinner.css b/FrontEnd/src/components/Spinner.css
index a1de68d2..a1de68d2 100644
--- a/FrontEnd/src/views/common/Spinner.css
+++ b/FrontEnd/src/components/Spinner.css
diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx
new file mode 100644
index 00000000..50ccf0b2
--- /dev/null
+++ b/FrontEnd/src/components/Spinner.tsx
@@ -0,0 +1,46 @@
+import { CSSProperties, ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import "./Spinner.css";
+
+const sizeMap: Record<string, string> = {
+ sm: "18px",
+ md: "30px",
+ lg: "42px",
+};
+
+function calculateSize(size: SpinnerProps["size"]) {
+ if (size == null) {
+ return "1em";
+ }
+ if (typeof size === "number") {
+ return size;
+ }
+ if (size in sizeMap) {
+ return sizeMap[size];
+ }
+ return size;
+}
+
+export interface SpinnerProps extends ComponentPropsWithoutRef<"span"> {
+ size?: number | string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export default function Spinner(props: SpinnerProps) {
+ const { size, className, style, ...otherProps } = props;
+ const calculatedSize = calculateSize(size);
+
+ return (
+ <span
+ className={classNames("cru-spinner", className)}
+ style={{
+ width: calculatedSize,
+ height: calculatedSize,
+ ...style,
+ }}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/components/TimelineLogo.tsx
index e06ed0f5..e06ed0f5 100644
--- a/FrontEnd/src/views/common/TimelineLogo.tsx
+++ b/FrontEnd/src/components/TimelineLogo.tsx
diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx
new file mode 100644
index 00000000..59f8f27c
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertHost.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useState } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, useC, Text } from "../common";
+import IconButton from "../button/IconButton";
+
+import { alertService, AlertInfoWithId } from "./AlertService";
+
+import "./alert.css";
+
+interface AutoCloseAlertProps {
+ color: ThemeColor;
+ message: Text;
+ onDismiss?: () => void;
+ onIn?: () => void;
+ onOut?: () => void;
+}
+
+function Alert({
+ color,
+ message,
+ onDismiss,
+ onIn,
+ onOut,
+}: AutoCloseAlertProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames("cru-alert", `cru-theme-${color}`)}
+ onPointerEnter={onIn}
+ onPointerLeave={onOut}
+ >
+ <div className="cru-alert-message">{c(message)}</div>
+ <IconButton
+ icon="x"
+ color="danger"
+ className="cru-alert-close-button"
+ onClick={onDismiss}
+ />
+ </div>
+ );
+}
+
+export default function AlertHost() {
+ const [alerts, setAlerts] = useState<AlertInfoWithId[]>([]);
+
+ useEffect(() => {
+ const listener = (alerts: AlertInfoWithId[]) => {
+ setAlerts(alerts);
+ };
+
+ alertService.registerListener(listener);
+
+ return () => {
+ alertService.unregisterListener(listener);
+ };
+ }, []);
+
+ return (
+ <div className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <Alert
+ key={alert.id}
+ message={alert.message}
+ color={alert.color ?? "primary"}
+ onIn={() => {
+ alertService.clearDismissTimer(alert.id);
+ }}
+ onOut={() => {
+ alertService.resetDismissTimer(alert.id);
+ }}
+ onDismiss={() => {
+ alertService.dismiss(alert.id);
+ }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/alert/AlertService.ts b/FrontEnd/src/components/alert/AlertService.ts
new file mode 100644
index 00000000..b9cda752
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertService.ts
@@ -0,0 +1,114 @@
+import { ThemeColor, Text } from "../common";
+
+const defaultDismissTime = 5000;
+
+export interface AlertInfo {
+ color?: ThemeColor;
+ message: Text;
+ dismissTime?: number | "never";
+}
+
+export interface AlertInfoWithId extends AlertInfo {
+ id: number;
+}
+
+interface AlertServiceAlert extends AlertInfoWithId {
+ timerId: number | null;
+}
+
+export type AlertsListener = (alerts: AlertInfoWithId[]) => void;
+
+export class AlertService {
+ private listeners: AlertsListener[] = [];
+ private alerts: AlertServiceAlert[] = [];
+ private currentId = 1;
+
+ getAlert(alertId?: number | null | undefined): AlertServiceAlert | null {
+ for (const alert of this.alerts) {
+ if (alert.id === alertId) return alert;
+ }
+ return null;
+ }
+
+ registerListener(listener: AlertsListener): void {
+ this.listeners.push(listener);
+ listener(this.alerts);
+ }
+
+ unregisterListener(listener: AlertsListener): void {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+
+ notify() {
+ for (const listener of this.listeners) {
+ listener(this.alerts);
+ }
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertServiceAlert = {
+ ...alert,
+ id: this.currentId++,
+ timerId: null,
+ };
+
+ this.alerts = [...this.alerts, newAlert];
+ this._resetDismissTimer(newAlert);
+
+ this.notify();
+ }
+
+ private _dismiss(alert: AlertServiceAlert) {
+ if (alert.timerId != null) {
+ window.clearTimeout(alert.timerId);
+ }
+ this.alerts = this.alerts.filter((a) => a !== alert);
+ this.notify();
+ }
+
+ dismiss(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._dismiss(alert);
+ }
+ }
+
+ private _clearDismissTimer(alert: AlertServiceAlert) {
+ if (alert.timerId != null) {
+ window.clearTimeout(alert.timerId);
+ alert.timerId = null;
+ }
+ }
+
+ clearDismissTimer(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._clearDismissTimer(alert);
+ }
+ }
+
+ private _resetDismissTimer(
+ alert: AlertServiceAlert,
+ dismissTime?: number | null | undefined,
+ ) {
+ this._clearDismissTimer(alert);
+
+ const realDismissTime =
+ dismissTime ?? alert.dismissTime ?? defaultDismissTime;
+
+ if (typeof realDismissTime === "number") {
+ alert.timerId = window.setTimeout(() => {
+ this._dismiss(alert);
+ }, realDismissTime);
+ }
+ }
+
+ resetDismissTimer(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._resetDismissTimer(alert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css
new file mode 100644
index 00000000..948256de
--- /dev/null
+++ b/FrontEnd/src/components/alert/alert.css
@@ -0,0 +1,21 @@
+.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-theme-color) 2px solid;
+ color: var(--cru-text-primary-color);
+ background-color: var(--cru-container-background-color);
+
+ margin: 1em;
+ padding: 0.5em 1em;
+
+ display: flex;
+ align-items: center;
+}
+
+.cru-alert-close-button {
+ margin-left: auto;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/alert/index.ts b/FrontEnd/src/components/alert/index.ts
new file mode 100644
index 00000000..1be0c2ec
--- /dev/null
+++ b/FrontEnd/src/components/alert/index.ts
@@ -0,0 +1,8 @@
+import { alertService, AlertInfo } from "./AlertService";
+import { default as AlertHost } from "./AlertHost";
+
+export { alertService, AlertHost };
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts
new file mode 100644
index 00000000..fb281610
--- /dev/null
+++ b/FrontEnd/src/components/breakpoints.ts
@@ -0,0 +1,3 @@
+export const breakpoints = {
+ sm: 576,
+} as const;
diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css
new file mode 100644
index 00000000..1da70f0e
--- /dev/null
+++ b/FrontEnd/src/components/button/Button.css
@@ -0,0 +1,64 @@
+.cru-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.3s;
+ border-radius: 0.2em;
+ border: 1px solid;
+ cursor: pointer;
+}
+
+.cru-button:not(.outline) {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+}
+
+.cru-button:not(.outline):hover {
+ background-color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button:not(.outline):focus {
+ background-color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button:not(.outline):active {
+ background-color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button:not(.outline):disabled {
+ color: var(--cru-push-button-disabled-text-color);
+ background-color: var(--cru-push-button-disabled-color);
+ border-color: var(--cru-push-button-disabled-color);
+ cursor: auto;
+}
+
+
+.cru-button.outline {
+ color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+ background-color: transparent;
+}
+
+.cru-button.outline:hover {
+ color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button.outline:focus {
+ color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button.outline:active {
+ color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button.outline:disabled {
+ color: var(--cru-clickable-disabled-color);
+ border-color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx
index be605328..30ea8c11 100644
--- a/FrontEnd/src/views/common/button/Button.tsx
+++ b/FrontEnd/src/components/button/Button.tsx
@@ -1,14 +1,13 @@
import { ComponentPropsWithoutRef, Ref } from "react";
import classNames from "classnames";
-import { I18nText, useC } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { Text, useC, ClickableColor } from "../common";
import "./Button.css";
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
- color?: PaletteColorType;
- text?: I18nText;
+ color?: ClickableColor;
+ text?: Text;
outline?: boolean;
buttonRef?: Ref<HTMLButtonElement> | null;
}
@@ -34,8 +33,8 @@ export default function Button(props: ButtonProps) {
<button
ref={buttonRef}
className={classNames(
- "cru-" + (color ?? "primary"),
"cru-button",
+ `cru-clickable-${color ?? "primary"}`,
outline && "outline",
className,
)}
diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.css
diff --git a/FrontEnd/src/components/button/ButtonRow.tsx b/FrontEnd/src/components/button/ButtonRow.tsx
new file mode 100644
index 00000000..eea60cc4
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.tsx
@@ -0,0 +1,62 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonRowButton = (
+ | {
+ type: "normal";
+ props: ComponentPropsWithoutRef<typeof Button>;
+ }
+ | {
+ type: "flat";
+ props: ComponentPropsWithoutRef<typeof FlatButton>;
+ }
+ | {
+ type: "icon";
+ props: ComponentPropsWithoutRef<typeof IconButton>;
+ }
+ | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> }
+) & { key: string | number };
+
+interface ButtonRowProps {
+ className?: string;
+ containerRef?: Ref<HTMLDivElement>;
+ buttons: ButtonRowButton[];
+ buttonsClassName?: string;
+}
+
+export default function ButtonRow({
+ className,
+ containerRef,
+ buttons,
+ buttonsClassName,
+}: ButtonRowProps) {
+ return (
+ <div ref={containerRef} className={classNames("cru-button-row", className)}>
+ {buttons.map((button) => {
+ const { type, key, props } = button;
+ const newClassName = classNames(props.className, buttonsClassName);
+ switch (type) {
+ case "normal":
+ return <Button key={key} {...props} className={newClassName} />;
+ case "flat":
+ return <FlatButton key={key} {...props} className={newClassName} />;
+ case "icon":
+ return <IconButton key={key} {...props} className={newClassName} />;
+ case "loading":
+ return (
+ <LoadingButton key={key} {...props} className={newClassName} />
+ );
+ default:
+ throw new Error();
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx
new file mode 100644
index 00000000..a54425cc
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -0,0 +1,146 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, ClickableColor } from "../common";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonAction = "major" | "minor";
+
+interface ButtonRowV2ButtonBase {
+ key: string | number;
+ action?: ButtonAction;
+ color?: ClickableColor;
+ disabled?: boolean;
+ onClick?: () => void;
+}
+
+interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase {
+ type?: undefined | null;
+ text: Text;
+ outline?: boolean;
+ props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase {
+ type: "normal";
+ text: Text;
+ outline?: boolean;
+ props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase {
+ type: "flat";
+ text: Text;
+ props?: ComponentPropsWithoutRef<typeof FlatButton>;
+}
+
+interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase {
+ type: "icon";
+ icon: string;
+ props?: ComponentPropsWithoutRef<typeof IconButton>;
+}
+
+interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase {
+ type: "loading";
+ text: Text;
+ loading?: boolean;
+ props?: ComponentPropsWithoutRef<typeof LoadingButton>;
+}
+
+type ButtonRowV2Button =
+ | ButtonRowV2ButtonWithNoType
+ | ButtonRowV2NormalButton
+ | ButtonRowV2FlatButton
+ | ButtonRowV2IconButton
+ | ButtonRowV2LoadingButton;
+
+interface ButtonRowV2Props {
+ className?: string;
+ containerRef?: Ref<HTMLDivElement>;
+ buttons: ButtonRowV2Button[];
+ buttonsClassName?: string;
+}
+
+export default function ButtonRowV2({
+ className,
+ containerRef,
+ buttons,
+ buttonsClassName,
+}: ButtonRowV2Props) {
+ return (
+ <div ref={containerRef} className={classNames("cru-button-row", className)}>
+ {buttons.map((button) => {
+ const { key, action, color, disabled, onClick } = button;
+
+ const realAction: ButtonAction = action ?? "minor";
+ const realColor =
+ color ?? (realAction === "major" ? "primary" : "minor");
+
+ const commonProps = { key, color: realColor, disabled, onClick };
+ const newClassName = classNames(
+ button.props?.className,
+ buttonsClassName,
+ );
+
+ switch (button.type) {
+ case null:
+ case undefined:
+ case "normal": {
+ const { text, outline, props } = button;
+ return (
+ <Button
+ {...commonProps}
+ text={text}
+ outline={outline ?? realAction !== "major"}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "flat": {
+ const { text, props } = button;
+ return (
+ <FlatButton
+ {...commonProps}
+ text={text}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "icon": {
+ const { icon, props } = button;
+ return (
+ <IconButton
+ {...commonProps}
+ icon={icon}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "loading": {
+ const { text, loading, props } = button;
+ return (
+ <LoadingButton
+ {...commonProps}
+ text={text}
+ loading={loading}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ default:
+ throw new Error();
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css
new file mode 100644
index 00000000..2050946c
--- /dev/null
+++ b/FrontEnd/src/components/button/FlatButton.css
@@ -0,0 +1,27 @@
+.cru-flat-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.5s;
+ border-radius: 0.2em;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: 1px none;
+ color: var(--cru-clickable-normal-color);
+ cursor: pointer;
+}
+
+.cru-flat-button:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-flat-button:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-flat-button:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+} \ No newline at end of file
diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx
index 49912b68..aad02e76 100644
--- a/FrontEnd/src/views/common/button/FlatButton.tsx
+++ b/FrontEnd/src/components/button/FlatButton.tsx
@@ -1,14 +1,13 @@
import { ComponentPropsWithoutRef, Ref } from "react";
import classNames from "classnames";
-import { I18nText, useC } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { Text, useC, ClickableColor } from "../common";
import "./FlatButton.css";
interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
- color?: PaletteColorType;
- text?: I18nText;
+ color?: ClickableColor;
+ text?: Text;
buttonRef?: Ref<HTMLButtonElement> | null;
}
@@ -25,8 +24,8 @@ export default function FlatButton(props: FlatButtonProps) {
<button
ref={buttonRef}
className={classNames(
- "cru-" + (color ?? "primary"),
"cru-flat-button",
+ `cru-clickable-${color ?? "primary"}`,
className,
)}
{...otherProps}
diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css
new file mode 100644
index 00000000..a3747201
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.css
@@ -0,0 +1,30 @@
+.cru-icon-button {
+ color: var(--cru-clickable-normal-color);
+ font-size: 1.4rem;
+ background: none;
+ border: none;
+ transition: all 0.5s;
+ cursor: pointer;
+ user-select: none;
+}
+
+.cru-icon-button:hover {
+ color: var(--cru-clickable-hover-color);
+}
+
+.cru-icon-button:focus {
+ color: var(--cru-clickable-focus-color);
+}
+
+.cru-icon-button:active {
+ color: var(--cru-clickable-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
+
+.cru-icon-button.large {
+ font-size: 1.6rem;
+}
diff --git a/FrontEnd/src/views/common/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx
index 652a8b09..e0862167 100644
--- a/FrontEnd/src/views/common/button/IconButton.tsx
+++ b/FrontEnd/src/components/button/IconButton.tsx
@@ -1,14 +1,15 @@
import { ComponentPropsWithoutRef } from "react";
import classNames from "classnames";
-import { PaletteColorType } from "@/palette";
+import { ClickableColor } from "../common";
import "./IconButton.css";
interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
icon: string;
- color?: PaletteColorType;
+ color?: ClickableColor;
large?: boolean;
+ disabled?: boolean; // TODO: Not implemented
}
export default function IconButton(props: IconButtonProps) {
@@ -18,9 +19,9 @@ export default function IconButton(props: IconButtonProps) {
<button
className={classNames(
"cru-icon-button",
+ `cru-clickable-${color ?? "grayscale"}`,
large && "large",
"bi-" + icon,
- color ? "cru-" + color : "cru-primary",
className,
)}
{...otherProps}
diff --git a/FrontEnd/src/components/button/LoadingButton.css b/FrontEnd/src/components/button/LoadingButton.css
new file mode 100644
index 00000000..23fadd3d
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.css
@@ -0,0 +1,13 @@
+.cru-loading-button {
+ display: flex;
+ align-items: center;
+}
+
+.cru-loading-button-spinner {
+ margin-left: 0.5em;
+}
+
+.cru-loading-button-loading {
+ color: var(--cru-clickable-normal-color) !important;
+ border-color: var(--cru-clickable-normal-color) !important;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx
new file mode 100644
index 00000000..9d65a2b3
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -0,0 +1,39 @@
+import classNames from "classnames";
+
+import { I18nText, ClickableColor, useC } from "../common";
+import Spinner from "../Spinner";
+
+import "./LoadingButton.css";
+
+interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
+ color?: ClickableColor;
+ text?: I18nText;
+ loading?: boolean;
+}
+
+export default function LoadingButton(props: LoadingButtonProps) {
+ const c = useC();
+
+ const { color, text, loading, disabled, className, children, ...otherProps } =
+ props;
+
+ if (text != null && children != null) {
+ console.warn("You can't set both text and children props.");
+ }
+
+ return (
+ <button
+ disabled={disabled || loading}
+ className={classNames(
+ "cru-button outline cru-loading-button",
+ `cru-clickable-${color ?? "primary"}`,
+ loading && "cru-loading-button-loading",
+ className,
+ )}
+ {...otherProps}
+ >
+ {text != null ? c(text) : children}
+ {loading && <Spinner className="cru-loading-button-spinner" />}
+ </button>
+ );
+}
diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx
new file mode 100644
index 00000000..b5aa5470
--- /dev/null
+++ b/FrontEnd/src/components/button/index.tsx
@@ -0,0 +1,15 @@
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+import ButtonRow from "./ButtonRow";
+import ButtonRowV2 from "./ButtonRowV2";
+
+export {
+ Button,
+ FlatButton,
+ IconButton,
+ LoadingButton,
+ ButtonRow,
+ ButtonRowV2,
+};
diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
new file mode 100644
index 00000000..a6c3e705
--- /dev/null
+++ b/FrontEnd/src/components/common.ts
@@ -0,0 +1,22 @@
+import "./index.css";
+
+export type { Text, I18nText } from "~src/common";
+export { UiLogicError, c, convertI18nText, useC } from "~src/common";
+
+export const themeColors = [
+ "primary",
+ "secondary",
+ "danger",
+ "create",
+] as const;
+
+export type ThemeColor = (typeof themeColors)[number];
+
+export type ClickableColor = ThemeColor | "grayscale" | "light" | "minor";
+
+export { breakpoints } from "./breakpoints";
+
+export * as geometry from "~src/utilities/geometry";
+
+export * as array from "~src/utilities/array"
+
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 00000000..8b0a4219
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,54 @@
+import { useC, Text, ThemeColor } from "../common";
+
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { useCloseDialog } from "./DialogProvider";
+
+export default function ConfirmDialog({
+ onConfirm,
+ title,
+ body,
+ color,
+}: {
+ onConfirm: () => void;
+ title: Text;
+ body: Text;
+ color?: ThemeColor;
+ bodyColor?: ThemeColor;
+}) {
+ const c = useC();
+
+ const closeDialog = useCloseDialog();
+
+ return (
+ <Dialog color={color ?? "danger"}>
+ <DialogContainer
+ title={title}
+ titleColor={color ?? "danger"}
+ buttonsV2={[
+ {
+ key: "cancel",
+ type: "normal",
+ action: "minor",
+
+ text: "operationDialog.cancel",
+ onClick: closeDialog,
+ },
+ {
+ key: "confirm",
+ type: "normal",
+ action: "major",
+ text: "operationDialog.confirm",
+ color: "danger",
+ onClick: () => {
+ onConfirm();
+ closeDialog();
+ },
+ },
+ ]}
+ >
+ <div>{c(body)}</div>
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
new file mode 100644
index 00000000..23b663db
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -0,0 +1,39 @@
+.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ padding: 20vh 1em;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-dialog-overlay-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-theme-color) 2px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-dialog-container-background-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
new file mode 100644
index 00000000..043a8eec
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -0,0 +1,55 @@
+import { ReactNode, useRef } from "react";
+import ReactDOM from "react-dom";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+
+import { useCloseDialog } from "./DialogProvider";
+
+import "./Dialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface DialogProps {
+ color?: ThemeColor;
+ children?: ReactNode;
+ disableCloseOnClickOnOverlay?: boolean;
+}
+
+export default function Dialog({
+ color,
+ children,
+ disableCloseOnClickOnOverlay,
+}: DialogProps) {
+ const closeDialog = useCloseDialog();
+
+ const lastPointerDownIdRef = useRef<number | null>(null);
+
+ return ReactDOM.createPortal(
+ <div
+ className={classNames(
+ `cru-theme-${color ?? "primary"}`,
+ "cru-dialog-overlay",
+ )}
+ >
+ <div
+ className="cru-dialog-background"
+ onPointerDown={(e) => {
+ lastPointerDownIdRef.current = e.pointerId;
+ }}
+ onPointerUp={(e) => {
+ if (lastPointerDownIdRef.current === e.pointerId) {
+ if (!disableCloseOnClickOnOverlay) closeDialog();
+ }
+ lastPointerDownIdRef.current = null;
+ }}
+ />
+ <div className="cru-dialog-container">{children}</div>
+ </div>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css
new file mode 100644
index 00000000..f0d27a66
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.css
@@ -0,0 +1,20 @@
+.cru-dialog-container-title {
+ font-size: 1.2em;
+ font-weight: bold;
+ color: var(--cru-theme-color);
+ margin-bottom: 0.5em;
+}
+
+.cru-dialog-container-hr {
+ margin: 1em 0;
+ border-color: var(--cru-text-minor-color);
+}
+
+.cru-dialog-container-button-row {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.cru-dialog-container-button {
+ margin-left: 1em;
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx
new file mode 100644
index 00000000..6ee4e134
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.tsx
@@ -0,0 +1,95 @@
+import { ComponentProps, Ref, ReactNode } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, Text, useC } from "../common";
+import { ButtonRow, ButtonRowV2 } from "../button";
+
+import "./DialogContainer.css";
+
+interface DialogContainerBaseProps {
+ className?: string;
+ title: Text;
+ titleColor?: ThemeColor;
+ titleClassName?: string;
+ titleRef?: Ref<HTMLDivElement>;
+ bodyContainerClassName?: string;
+ bodyContainerRef?: Ref<HTMLDivElement>;
+ buttonsClassName?: string;
+ buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"];
+ children: ReactNode;
+}
+
+interface DialogContainerWithButtonsProps extends DialogContainerBaseProps {
+ buttons: ComponentProps<typeof ButtonRow>["buttons"];
+}
+
+interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps {
+ buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"];
+}
+
+type DialogContainerProps =
+ | DialogContainerWithButtonsProps
+ | DialogContainerWithButtonsV2Props;
+
+export default function DialogContainer(props: DialogContainerProps) {
+ const {
+ className,
+ title,
+ titleColor,
+ titleClassName,
+ titleRef,
+ bodyContainerClassName,
+ bodyContainerRef,
+ buttonsClassName,
+ buttonsContainerRef,
+ children,
+ } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className)}>
+ <div
+ ref={titleRef}
+ className={classNames(
+ `cru-dialog-container-title cru-theme-${titleColor ?? "primary"}`,
+ titleClassName,
+ )}
+ >
+ {c(title)}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ <div
+ ref={bodyContainerRef}
+ className={classNames(
+ "cru-dialog-container-body",
+ bodyContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ {"buttons" in props ? (
+ <ButtonRow
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttons}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ ) : (
+ <ButtonRowV2
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttonsV2}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx
new file mode 100644
index 00000000..bb85e4cf
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogProvider.tsx
@@ -0,0 +1,95 @@
+import { useState, useContext, createContext, ReactNode } from "react";
+
+import { UiLogicError } from "../common";
+
+type DialogMap<D extends string> = {
+ [K in D]: ReactNode;
+};
+
+interface DialogController<D extends string> {
+ currentDialog: D | null;
+ currentDialogReactNode: ReactNode;
+ canSwitchDialog: boolean;
+ switchDialog: (newDialog: D | null) => void;
+ setCanSwitchDialog: (enable: boolean) => void;
+ closeDialog: () => void;
+ forceSwitchDialog: (newDialog: D | null) => void;
+ forceCloseDialog: () => void;
+}
+
+export function useDialog<D extends string>(
+ dialogs: DialogMap<D>,
+ options?: {
+ initDialog?: D | null;
+ onClose?: {
+ [K in D]?: () => void;
+ };
+ },
+): {
+ controller: DialogController<D>;
+ switchDialog: (newDialog: D | null) => void;
+ forceSwitchDialog: (newDialog: D | null) => void;
+ createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+ const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true);
+ const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
+
+ const forceSwitchDialog = (newDialog: D | null) => {
+ if (dialog != null) {
+ options?.onClose?.[dialog]?.();
+ }
+ setDialog(newDialog);
+ setCanSwitchDialog(true);
+ };
+
+ const switchDialog = (newDialog: D | null) => {
+ if (canSwitchDialog) {
+ forceSwitchDialog(newDialog);
+ }
+ };
+
+ const controller: DialogController<D> = {
+ currentDialog: dialog,
+ currentDialogReactNode: dialog == null ? null : dialogs[dialog],
+ canSwitchDialog,
+ switchDialog,
+ setCanSwitchDialog,
+ closeDialog: () => switchDialog(null),
+ forceSwitchDialog,
+ forceCloseDialog: () => forceSwitchDialog(null),
+ };
+
+ return {
+ controller,
+ switchDialog,
+ forceSwitchDialog,
+ createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+ };
+}
+
+const DialogControllerContext = createContext<DialogController<string> | null>(
+ null,
+);
+
+export function useDialogController(): DialogController<string> {
+ const controller = useContext(DialogControllerContext);
+ if (controller == null) throw new UiLogicError("not in dialog provider");
+ return controller;
+}
+
+export function useCloseDialog(): () => void {
+ const controller = useDialogController();
+ return controller.closeDialog;
+}
+
+export function DialogProvider<D extends string>({
+ controller,
+}: {
+ controller: DialogController<D>;
+}) {
+ return (
+ <DialogControllerContext.Provider value={controller as never}>
+ {controller.currentDialogReactNode}
+ </DialogControllerContext.Provider>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css
new file mode 100644
index 00000000..ce07c6ac
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.css
@@ -0,0 +1,30 @@
+.cru-dialog-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--cru-background-color);
+ padding-top: 56px;
+}
+
+.cru-dialog-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-theme-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-dialog-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-dialog-full-page-back-button {
+ margin-left: 0.5em;
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
new file mode 100644
index 00000000..575abf7f
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -0,0 +1,52 @@
+import { ReactNode } from "react";
+import { createPortal } from "react-dom";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+import { IconButton } from "../button";
+
+import { useCloseDialog } from "./DialogProvider";
+
+import "./FullPageDialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface FullPageDialogProps {
+ color?: ThemeColor;
+ contentContainerClassName?: string;
+ children: ReactNode;
+}
+
+export default function FullPageDialog({
+ color,
+ children,
+ contentContainerClassName,
+}: FullPageDialogProps) {
+ const closeDialog = useCloseDialog();
+
+ return createPortal(
+ <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}>
+ <div className="cru-dialog-full-page-top-bar">
+ <IconButton
+ icon="arrow-left"
+ color="light"
+ className="cru-dialog-full-page-back-button"
+ onClick={closeDialog}
+ />
+ </div>
+ <div
+ className={classNames(
+ "cru-dialog-full-page-content-container",
+ contentContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ </div>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css
new file mode 100644
index 00000000..28f73c9d
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.css
@@ -0,0 +1,4 @@
+.cru-operation-dialog-input-group {
+ display: block;
+ margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
new file mode 100644
index 00000000..6ca4d0a0
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -0,0 +1,221 @@
+import { useState, ReactNode, ComponentProps } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+import {
+ useInputs,
+ InputGroup,
+ Initializer as InputInitializer,
+ InputConfirmValueDict,
+} from "../input";
+import { ButtonRowV2 } from "../button";
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { useDialogController } from "./DialogProvider";
+
+import "./OperationDialog.css";
+
+interface OperationDialogPromptProps {
+ message?: Text;
+ customMessage?: Text;
+ customMessageNode?: ReactNode;
+ className?: string;
+}
+
+function OperationDialogPrompt(props: OperationDialogPromptProps) {
+ const { message, customMessage, customMessageNode, className } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className, "cru-operation-dialog-prompt")}>
+ {message && <p>{c(message)}</p>}
+ {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
+ </div>
+ );
+}
+
+export interface OperationDialogProps<TData> {
+ color?: ThemeColor;
+ inputColor?: ThemeColor;
+ title: Text;
+ inputPrompt?: Text;
+ inputPromptNode?: ReactNode;
+ successPrompt?: (data: TData) => Text;
+ successPromptNode?: (data: TData) => ReactNode;
+ failurePrompt?: (error: unknown) => Text;
+ failurePromptNode?: (error: unknown) => ReactNode;
+
+ inputs: InputInitializer;
+
+ onProcess: (inputs: InputConfirmValueDict) => Promise<TData>;
+ onSuccessAndClose?: (data: TData) => void;
+}
+
+function OperationDialog<TData>(props: OperationDialogProps<TData>) {
+ const {
+ color,
+ inputColor,
+ title,
+ inputPrompt,
+ inputPromptNode,
+ successPrompt,
+ successPromptNode,
+ failurePrompt,
+ failurePromptNode,
+ inputs,
+ onProcess,
+ onSuccessAndClose,
+ } = props;
+
+ if (process.env.NODE_ENV === "development") {
+ if (inputPrompt && inputPromptNode) {
+ console.log("InputPrompt and inputPromptNode are both set.");
+ }
+ if (successPrompt && successPromptNode) {
+ console.log("SuccessPrompt and successPromptNode are both set.");
+ }
+ if (failurePrompt && failurePromptNode) {
+ console.log("FailurePrompt and failurePromptNode are both set.");
+ }
+ }
+
+ type Step =
+ | { type: "input" }
+ | { type: "process" }
+ | {
+ type: "success";
+ data: TData;
+ }
+ | {
+ type: "failure";
+ data: unknown;
+ };
+
+ const dialogController = useDialogController();
+
+ const [step, setStep] = useState<Step>({ type: "input" });
+
+ const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+ useInputs({
+ init: inputs,
+ });
+
+ function close() {
+ if (step.type !== "process") {
+ dialogController.closeDialog();
+ if (step.type === "success" && onSuccessAndClose) {
+ onSuccessAndClose?.(step.data);
+ }
+ } else {
+ console.log("Attempt to close modal dialog when processing.");
+ }
+ }
+
+ function onConfirm() {
+ const result = confirm();
+ if (result.type === "ok") {
+ setStep({ type: "process" });
+ dialogController.setCanSwitchDialog(false);
+ setAllDisabled(true);
+ onProcess(result.values)
+ .then(
+ (d) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ },
+ )
+ .finally(() => {
+ dialogController.setCanSwitchDialog(true);
+ });
+ }
+ }
+
+ let body: ReactNode;
+ let buttons: ComponentProps<typeof ButtonRowV2>["buttons"];
+
+ if (step.type === "input" || step.type === "process") {
+ const isProcessing = step.type === "process";
+
+ body = (
+ <div>
+ <OperationDialogPrompt
+ customMessage={inputPrompt}
+ customMessageNode={inputPromptNode}
+ />
+ <InputGroup
+ containerClassName="cru-operation-dialog-input-group"
+ color={inputColor ?? "primary"}
+ {...inputGroupProps}
+ />
+ </div>
+ );
+ buttons = [
+ {
+ key: "cancel",
+ text: "operationDialog.cancel",
+ onClick: close,
+ disabled: isProcessing,
+ },
+ {
+ key: "confirm",
+ type: "loading",
+ action: "major",
+ text: "operationDialog.confirm",
+ color,
+ loading: isProcessing,
+ disabled: hasErrorAndDirty,
+ onClick: onConfirm,
+ },
+ ];
+ } else {
+ const result = step;
+
+ const promptProps: OperationDialogPromptProps =
+ result.type === "success"
+ ? {
+ message: "operationDialog.success",
+ customMessage: successPrompt?.(result.data),
+ customMessageNode: successPromptNode?.(result.data),
+ }
+ : {
+ message: "operationDialog.error",
+ customMessage: failurePrompt?.(result.data),
+ customMessageNode: failurePromptNode?.(result.data),
+ };
+ body = (
+ <div>
+ <OperationDialogPrompt {...promptProps} />
+ </div>
+ );
+
+ buttons = [
+ {
+ key: "ok",
+ type: "normal",
+ action: "major",
+ color: "create",
+ text: "operationDialog.ok",
+ onClick: close,
+ },
+ ];
+ }
+
+ return (
+ <Dialog color={color}>
+ <DialogContainer title={title} titleColor={color} buttonsV2={buttons}>
+ {body}
+ </DialogContainer>
+ </Dialog>
+ );
+}
+
+export default OperationDialog;
diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx
new file mode 100644
index 00000000..9ca06de2
--- /dev/null
+++ b/FrontEnd/src/components/dialog/index.tsx
@@ -0,0 +1,12 @@
+export { default as Dialog } from "./Dialog";
+export { default as FullPageDialog } from "./FullPageDialog";
+export { default as OperationDialog } from "./OperationDialog";
+export { default as ConfirmDialog } from "./ConfirmDialog";
+export { default as DialogContainer } from "./DialogContainer";
+
+export {
+ useDialog,
+ useDialogController,
+ useCloseDialog,
+ DialogProvider,
+} from "./DialogProvider";
diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts
new file mode 100644
index 00000000..98ce729e
--- /dev/null
+++ b/FrontEnd/src/components/hooks/index.ts
@@ -0,0 +1,5 @@
+export { useMobile } from "./responsive";
+export { default as useClickOutside } from "./useClickOutside";
+export { default as useScrollToBottom } from "./useScrollToBottom";
+export { default as useWindowLeave } from "./useWindowLeave";
+export { default as useAutoUnsubscribePromise } from "./useAutoUnsubscribePromise";
diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts
new file mode 100644
index 00000000..42c134ef
--- /dev/null
+++ b/FrontEnd/src/components/hooks/responsive.ts
@@ -0,0 +1,7 @@
+import { useMediaQuery } from "react-responsive";
+
+import { breakpoints } from "../breakpoints";
+
+export function useMobile(onChange?: (mobile: boolean) => void): boolean {
+ return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange);
+}
diff --git a/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts
new file mode 100644
index 00000000..01c5a1db
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts
@@ -0,0 +1,24 @@
+import { useEffect, DependencyList } from "react";
+
+export default function useAutoUnsubscribePromise<T>(
+ promiseGenerator: () => Promise<T> | null | undefined,
+ resultHandler: (data: T) => void,
+ dependencies?: DependencyList | undefined,
+) {
+ useEffect(() => {
+ let subscribe = true;
+ const promise = promiseGenerator();
+ if (promise) {
+ void promise.then((data) => {
+ if (subscribe) {
+ resultHandler(data);
+ }
+ });
+
+ return () => {
+ subscribe = false;
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [promiseGenerator, resultHandler, ...(dependencies ?? [])]);
+}
diff --git a/FrontEnd/src/utilities/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts
index 6dcbf7b3..828ce7e3 100644
--- a/FrontEnd/src/utilities/hooks/useClickOutside.ts
+++ b/FrontEnd/src/components/hooks/useClickOutside.ts
@@ -3,7 +3,7 @@ import { useRef, useEffect } from "react";
export default function useClickOutside(
element: HTMLElement | null | undefined,
onClickOutside: () => void,
- nextTick?: boolean
+ nextTick?: boolean,
): void {
const onClickOutsideRef = useRef<() => void>(onClickOutside);
diff --git a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts
index 216746f4..79fcda16 100644
--- a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts
+++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts
@@ -1,6 +1,5 @@
import { useRef, useEffect } from "react";
-import { fromEvent } from "rxjs";
-import { filter, throttleTime } from "rxjs/operators";
+import { fromEvent, filter, throttleTime } from "rxjs";
function useScrollToBottom(
handler: () => void,
@@ -8,7 +7,7 @@ function useScrollToBottom(
option = {
maxOffset: 5,
throttle: 1000,
- }
+ },
): void {
const handlerRef = useRef<(() => void) | null>(null);
@@ -26,9 +25,9 @@ function useScrollToBottom(
filter(
() =>
window.scrollY >=
- document.body.scrollHeight - window.innerHeight - option.maxOffset
+ document.body.scrollHeight - window.innerHeight - option.maxOffset,
),
- throttleTime(option.throttle)
+ throttleTime(option.throttle),
)
.subscribe(() => {
if (enable) {
diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts
new file mode 100644
index 00000000..ecd999d4
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useWindowLeave.ts
@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+
+import { useC, Text } from "../common";
+
+export default function useWindowLeave(
+ allow: boolean,
+ message: Text = "timeline.confirmLeave",
+) {
+ const c = useC();
+
+ useEffect(() => {
+ if (!allow) {
+ window.onbeforeunload = () => {
+ return c(message);
+ };
+
+ return () => {
+ window.onbeforeunload = null;
+ };
+ }
+ }, [c, allow, message]);
+}
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
new file mode 100644
index 00000000..83b48318
--- /dev/null
+++ b/FrontEnd/src/components/index.css
@@ -0,0 +1,49 @@
+@import "./theme.css";
+
+* {
+ box-sizing: border-box;
+ margin-inline: 0;
+ margin-block: 0;
+}
+
+body {
+ font-family: var(--cru-default-font-family);
+ background: var(--cru-body-background-color);
+ color: var(--cru-text-major-color);
+ line-height: 1.2;
+}
+
+textarea {
+ transition: border-color 0.3s;
+ border-color: var(--cru-text-minor-color);
+ background: var(--cru-background-color);
+}
+
+textarea:hover {
+ border-color: var(--cru-clickable-primary-hover-color);
+}
+
+textarea:focus {
+ border-color: var(--cru-clickable-primary-normal-color);
+}
+
+.alert-container {
+ position: fixed;
+ z-index: 1070;
+}
+
+@media (min-width: 576px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ left: 0;
+ text-align: center;
+ }
+}
diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css
new file mode 100644
index 00000000..7e905b1e
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.css
@@ -0,0 +1,54 @@
+.cru-input-group {
+ display: block;
+}
+
+.cru-input-container {
+ margin: 0.4em 0;
+}
+
+.cru-input-label {
+ display: block;
+ color: var(--cru-clickable-normal-color);
+ font-size: 0.9em;
+ margin-bottom: 0.3em;
+}
+
+.cru-input-label-inline {
+ margin-inline-start: 0.5em;
+}
+
+.cru-input-type-text input {
+ appearance: none;
+ display: block;
+ border: 1px solid;
+ /* color: var(--cru-surface-on-color); */
+ /* background-color: var(--cru-surface-color); */
+ margin: 0;
+ font-size: 1em;
+ padding: 0.2em;
+}
+
+.cru-input-type-text input:hover {
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-input-type-text input:focus {
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-input-type-text input:disabled {
+ border-color: var(--cru-clickable-disabled-color);
+}
+
+.cru-input-error {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-danger-color);
+ margin-top: 0.4em;
+}
+
+.cru-input-helper {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx
new file mode 100644
index 00000000..47a43b38
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.tsx
@@ -0,0 +1,463 @@
+/**
+ * Some notes for InputGroup:
+ * This is one of the most complicated components in this project.
+ * Probably because the feature is complex and involved user inputs.
+ *
+ * I hope it contains following features:
+ * - Input features
+ * - Supports a wide range of input types.
+ * - Validator to validate user inputs.
+ * - Can set initial values.
+ * - Dirty, aka, has user touched this input.
+ * - Developer friendly
+ * - Easy to use APIs.
+ * - Type check as much as possible.
+ * - UI
+ * - Configurable appearance.
+ * - Can display helper and error messages.
+ * - Easy to extend, like new input types.
+ *
+ * So here is some design decisions:
+ * Inputs are identified by its _key_.
+ * `InputGroup` component takes care of only UI and no logic.
+ * `useInputs` hook takes care of logic and generate props for `InputGroup`.
+ */
+
+import { useState, Ref, useId } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./InputGroup.css";
+
+export interface InputBase {
+ key: string;
+ label: Text;
+ helper?: Text;
+ disabled?: boolean;
+ error?: Text;
+}
+
+export interface TextInput extends InputBase {
+ type: "text";
+ value: string;
+ password?: boolean;
+}
+
+export interface BoolInput extends InputBase {
+ type: "bool";
+ value: boolean;
+}
+
+export interface SelectInputOption {
+ value: string;
+ label: Text;
+ icon?: string;
+}
+
+export interface SelectInput extends InputBase {
+ type: "select";
+ value: string;
+ options: SelectInputOption[];
+}
+
+export type Input = TextInput | BoolInput | SelectInput;
+
+export type InputValue = Input["value"];
+
+export type InputValueDict = Record<string, InputValue>;
+export type InputErrorDict = Record<string, Text>;
+export type InputDisabledDict = Record<string, boolean>;
+export type InputDirtyDict = Record<string, boolean>;
+// use never so you don't have to cast everywhere
+export type InputConfirmValueDict = Record<string, never>;
+
+export type GeneralInputErrorDict = {
+ [key: string]: Text | null | undefined;
+};
+
+type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
+
+export type InputInfo = {
+ [I in Input as I["type"]]: MakeInputInfo<I>;
+}[Input["type"]];
+
+export type Validator = (
+ values: InputValueDict,
+ errors: GeneralInputErrorDict,
+ inputs: InputInfo[],
+) => void;
+
+export type InputScheme = {
+ inputs: InputInfo[];
+ validator?: Validator;
+};
+
+export type InputData = {
+ values: InputValueDict;
+ errors: InputErrorDict;
+ disabled: InputDisabledDict;
+ dirties: InputDirtyDict;
+};
+
+export type State = {
+ scheme: InputScheme;
+ data: InputData;
+};
+
+export type DataInitialization = {
+ values?: InputValueDict;
+ errors?: GeneralInputErrorDict;
+ disabled?: InputDisabledDict;
+ dirties?: InputDirtyDict;
+};
+
+export type Initialization = {
+ scheme: InputScheme;
+ dataInit?: DataInitialization;
+};
+
+export type GeneralInitialization = Initialization | InputScheme | InputInfo[];
+
+export type Initializer = GeneralInitialization | (() => GeneralInitialization);
+
+export interface InputGroupProps {
+ color?: ThemeColor;
+ containerClassName?: string;
+ containerRef?: Ref<HTMLDivElement>;
+
+ inputs: Input[];
+ onChange: (index: number, value: Input["value"]) => void;
+}
+
+function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
+ const result = { ...o };
+ for (const key of Object.keys(result)) {
+ if (result[key] == null) {
+ delete result[key];
+ }
+ }
+ return result as never;
+}
+
+export type ConfirmResult =
+ | {
+ type: "ok";
+ values: InputConfirmValueDict;
+ }
+ | {
+ type: "error";
+ errors: InputErrorDict;
+ };
+
+function validate(
+ validator: Validator | null | undefined,
+ values: InputValueDict,
+ inputs: InputInfo[],
+): InputErrorDict {
+ const errors: GeneralInputErrorDict = {};
+ validator?.(values, errors, inputs);
+ return cleanObject(errors);
+}
+
+export function useInputs(options: { init: Initializer }): {
+ inputGroupProps: InputGroupProps;
+ hasError: boolean;
+ hasErrorAndDirty: boolean;
+ confirm: () => ConfirmResult;
+ setAllDisabled: (disabled: boolean) => void;
+} {
+ function initializeValue(
+ input: InputInfo,
+ value?: InputValue | null,
+ ): InputValue {
+ if (input.type === "text") {
+ return value ?? "";
+ } else if (input.type === "bool") {
+ return value ?? false;
+ } else if (input.type === "select") {
+ return value ?? input.options[0].value;
+ }
+ throw new Error("Unknown input type");
+ }
+
+ function initialize(generalInitialization: GeneralInitialization): State {
+ const initialization: Initialization = Array.isArray(generalInitialization)
+ ? { scheme: { inputs: generalInitialization } }
+ : "scheme" in generalInitialization
+ ? generalInitialization
+ : { scheme: generalInitialization };
+
+ const { scheme, dataInit } = initialization;
+ const { inputs, validator } = scheme;
+ const keys = inputs.map((input) => input.key);
+
+ if (process.env.NODE_ENV === "development") {
+ const checkKeys = (dict: Record<string, unknown> | undefined) => {
+ if (dict != null) {
+ for (const key of Object.keys(dict)) {
+ if (!keys.includes(key)) {
+ console.warn("");
+ }
+ }
+ }
+ };
+
+ checkKeys(dataInit?.values);
+ checkKeys(dataInit?.errors ?? {});
+ checkKeys(dataInit?.disabled);
+ checkKeys(dataInit?.dirties);
+ }
+
+ function clean<V>(
+ dict: Record<string, V> | null | undefined,
+ ): Record<string, NonNullable<V>> {
+ return dict != null ? cleanObject(dict) : {};
+ }
+
+ const values: InputValueDict = {};
+ const disabled: InputDisabledDict = clean(dataInit?.disabled);
+ const dirties: InputDirtyDict = clean(dataInit?.dirties);
+ const isErrorSet = dataInit?.errors != null;
+ let errors: InputErrorDict = clean(dataInit?.errors);
+
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i];
+ const { key } = input;
+
+ values[key] = initializeValue(input, dataInit?.values?.[key]);
+ }
+
+ if (isErrorSet) {
+ if (process.env.NODE_ENV === "development") {
+ console.log(
+ "You explicitly set errors (not undefined) in initializer, so validator won't run.",
+ );
+ }
+ } else {
+ errors = validate(validator, values, inputs);
+ }
+
+ return {
+ scheme,
+ data: {
+ values,
+ errors,
+ disabled,
+ dirties,
+ },
+ };
+ }
+
+ const { init } = options;
+ const initializer = typeof init === "function" ? init : () => init;
+
+ const [state, setState] = useState<State>(() => initialize(initializer()));
+
+ const { scheme, data } = state;
+ const { validator } = scheme;
+
+ function createAllBooleanDict(value: boolean): Record<string, boolean> {
+ const result: InputDirtyDict = {};
+ for (const key of scheme.inputs.map((input) => input.key)) {
+ result[key] = value;
+ }
+ return result;
+ }
+
+ const createAllDirties = () => createAllBooleanDict(true);
+
+ const componentInputs: Input[] = [];
+
+ for (let i = 0; i < scheme.inputs.length; i++) {
+ const input = scheme.inputs[i];
+ const value = data.values[input.key];
+ const error = data.errors[input.key];
+ const disabled = data.disabled[input.key] ?? false;
+ const dirty = data.dirties[input.key] ?? false;
+ const componentInput: Input = {
+ ...input,
+ value: value as never,
+ disabled,
+ error: dirty ? error : undefined,
+ };
+ componentInputs.push(componentInput);
+ }
+
+ const hasError = Object.keys(data.errors).length > 0;
+ const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]);
+
+ return {
+ inputGroupProps: {
+ inputs: componentInputs,
+ onChange: (index, value) => {
+ const input = scheme.inputs[index];
+ const { key } = input;
+ const newValues = { ...data.values, [key]: value };
+ const newDirties = { ...data.dirties, [key]: true };
+ const newErrors = validate(validator, newValues, scheme.inputs);
+ setState({
+ scheme,
+ data: {
+ ...data,
+ values: newValues,
+ errors: newErrors,
+ dirties: newDirties,
+ },
+ });
+ },
+ },
+ hasError,
+ hasErrorAndDirty: hasError && hasDirty,
+ confirm() {
+ const newDirties = createAllDirties();
+ const newErrors = validate(validator, data.values, scheme.inputs);
+
+ setState({
+ scheme,
+ data: {
+ ...data,
+ dirties: newDirties,
+ errors: newErrors,
+ },
+ });
+
+ if (Object.keys(newErrors).length !== 0) {
+ return {
+ type: "error",
+ errors: newErrors,
+ };
+ } else {
+ return {
+ type: "ok",
+ values: data.values as InputConfirmValueDict,
+ };
+ }
+ },
+ setAllDisabled(disabled: boolean) {
+ setState({
+ scheme,
+ data: {
+ ...data,
+ disabled: createAllBooleanDict(disabled),
+ },
+ });
+ },
+ };
+}
+
+export function InputGroup({
+ color,
+ inputs,
+ onChange,
+ containerRef,
+ containerClassName,
+}: InputGroupProps) {
+ const c = useC();
+
+ const id = useId();
+
+ return (
+ <div
+ ref={containerRef}
+ className={classNames(
+ "cru-input-group",
+ `cru-clickable-${color ?? "primary"}`,
+ containerClassName,
+ )}
+ >
+ {inputs.map((item, index) => {
+ const { key, type, value, label, error, helper, disabled } = item;
+
+ const getContainerClassName = (
+ ...additionalClassNames: classNames.ArgumentArray
+ ) =>
+ classNames(
+ `cru-input-container cru-input-type-${type}`,
+ error && "error",
+ ...additionalClassNames,
+ );
+
+ const changeValue = (value: InputValue) => {
+ onChange(index, value);
+ };
+
+ const inputId = `${id}-${key}`;
+
+ if (type === "text") {
+ const { password } = item;
+ return (
+ <div
+ key={key}
+ className={getContainerClassName(password && "password")}
+ >
+ {label && (
+ <label className="cru-input-label" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ )}
+ <input
+ id={inputId}
+ type={password ? "password" : "text"}
+ value={value}
+ onChange={(event) => {
+ const v = event.target.value;
+ changeValue(v);
+ }}
+ disabled={disabled}
+ />
+ {error && <div className="cru-input-error">{c(error)}</div>}
+ {helper && <div className="cru-input-helper">{c(helper)}</div>}
+ </div>
+ );
+ } else if (type === "bool") {
+ return (
+ <div key={key} className={getContainerClassName()}>
+ <input
+ id={inputId}
+ type="checkbox"
+ checked={value}
+ onChange={(event) => {
+ const v = event.currentTarget.checked;
+ changeValue(v);
+ }}
+ disabled={disabled}
+ />
+ <label className="cru-input-label-inline" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ {error && <div className="cru-input-error">{c(error)}</div>}
+ {helper && <div className="cru-input-helper">{c(helper)}</div>}
+ </div>
+ );
+ } else if (type === "select") {
+ return (
+ <div key={key} className={getContainerClassName()}>
+ <label className="cru-input-label" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ <select
+ id={inputId}
+ value={value}
+ onChange={(event) => {
+ const e = event.target.value;
+ changeValue(e);
+ }}
+ disabled={disabled}
+ >
+ {item.options.map((option) => {
+ return (
+ <option value={option.value} key={option.value}>
+ {option.icon}
+ {c(option.label)}
+ </option>
+ );
+ })}
+ </select>
+ </div>
+ );
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts
new file mode 100644
index 00000000..ca183089
--- /dev/null
+++ b/FrontEnd/src/components/input/index.ts
@@ -0,0 +1,11 @@
+export { useInputs, InputGroup } from "./InputGroup";
+
+export type {
+ InputValueDict,
+ InputErrorDict,
+ InputDirtyDict,
+ InputDisabledDict,
+ InputConfirmValueDict,
+ Validator,
+ Initializer,
+} from "./InputGroup";
diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css
new file mode 100644
index 00000000..53781834
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.css
@@ -0,0 +1,4 @@
+.cru-list-container {
+ border: 1px solid var(--cru-clickable-primary-normal-color);
+ border-radius: 5px;
+}
diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx
new file mode 100644
index 00000000..c27e67d4
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListContainer.css";
+
+function _ListContainer(
+ { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+ ref: Ref<HTMLDivElement>,
+) {
+ return (
+ <div
+ ref={ref}
+ className={classNames("cru-list-container", className)}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
+
+const ListContainer = forwardRef(_ListContainer);
+
+export default ListContainer;
diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css
new file mode 100644
index 00000000..49468bc2
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.css
@@ -0,0 +1,7 @@
+.cru-list-item-container {
+ border-bottom: 1px solid var(--cru-clickable-primary-normal-color);
+}
+
+.cru-list-item-container:last-child {
+ border-bottom: none;
+}
diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx
new file mode 100644
index 00000000..315cbd6e
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListItemContainer.css";
+
+function _ListItemContainer(
+ { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+ ref: Ref<HTMLDivElement>,
+) {
+ return (
+ <div
+ ref={ref}
+ className={classNames("cru-list-item-container", className)}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
+
+const ListItemContainer = forwardRef(_ListItemContainer);
+
+export default ListItemContainer;
diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts
new file mode 100644
index 00000000..e183f7da
--- /dev/null
+++ b/FrontEnd/src/components/list/index.ts
@@ -0,0 +1,4 @@
+import ListContainer from "./ListContainer";
+import ListItemContainer from "./ListItemContainer";
+
+export { ListContainer, ListItemContainer };
diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css
new file mode 100644
index 00000000..75734533
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.css
@@ -0,0 +1,36 @@
+.cru-menu {
+ min-width: 200px;
+}
+
+.cru-menu-item {
+ display: block;
+ font-size: 1em;
+ width: 100%;
+ padding: 0.5em 1.5em;
+ transition: all 0.5s;
+ color: var(--cru-clickable-normal-color);
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: none;
+ cursor: pointer;
+}
+
+.cru-menu-item:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-menu-item:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-menu-item:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-menu-item-icon {
+ margin-right: 1em;
+}
+
+.cru-menu-divider {
+ border-width: 0;
+ border-top: 1px solid var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx
new file mode 100644
index 00000000..1a196a69
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -0,0 +1,62 @@
+import { MouseEvent, CSSProperties } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+import Icon from "../Icon";
+
+import "./Menu.css";
+
+export type MenuItem =
+ | {
+ type: "divider";
+ }
+ | {
+ type: "button";
+ text: Text;
+ icon?: string;
+ color?: ThemeColor;
+ onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
+ };
+
+export type MenuItems = MenuItem[];
+
+export type MenuProps = {
+ items: MenuItems;
+ onItemClick?: (e: MouseEvent<HTMLButtonElement>) => void;
+ className?: string;
+ style?: CSSProperties;
+};
+
+export default function Menu({
+ items,
+ onItemClick,
+ className,
+ style,
+}: MenuProps) {
+ const c = useC();
+
+ return (
+ <div className={classNames("cru-menu", className)} style={style}>
+ {items.map((item, index) => {
+ if (item.type === "divider") {
+ return <hr key={index} className="cru-menu-divider" />;
+ } else {
+ const { text, color, icon, onClick } = item;
+ return (
+ <button
+ key={index}
+ className={`cru-menu-item cru-clickable-${color ?? "primary"}`}
+ onClick={(e) => {
+ onClick?.(e);
+ onItemClick?.(e);
+ }}
+ >
+ {icon != null && <Icon color={color} icon={icon} />}
+ {c(text)}
+ </button>
+ );
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css
new file mode 100644
index 00000000..149e0699
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.css
@@ -0,0 +1,7 @@
+.cru-popup-menu-menu-container {
+ z-index: 1040;
+ border-radius: 3px;
+ border: var(--cru-clickable-normal-color) 1.5px solid;
+ background-color: var(--cru-background-color);
+ overflow: hidden;
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx
new file mode 100644
index 00000000..7ac2abfe
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -0,0 +1,72 @@
+import { useState, CSSProperties, ReactNode } from "react";
+import classNames from "classnames";
+import { createPortal } from "react-dom";
+import { usePopper } from "react-popper";
+
+import { ThemeColor } from "../common";
+import { useClickOutside } from "../hooks";
+import Menu, { MenuItems } from "./Menu";
+
+import "./PopupMenu.css";
+
+export interface PopupMenuProps {
+ color?: ThemeColor;
+ items: MenuItems;
+ children?: ReactNode;
+ containerClassName?: string;
+ containerStyle?: CSSProperties;
+}
+
+export default function PopupMenu({
+ color,
+ items,
+ children,
+ containerClassName,
+ containerStyle,
+}: PopupMenuProps) {
+ const [show, setShow] = useState<boolean>(false);
+
+ const [referenceElement, setReferenceElement] =
+ useState<HTMLDivElement | null>(null);
+ const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
+ null,
+ );
+ const { styles, attributes } = usePopper(referenceElement, popperElement);
+
+ useClickOutside(popperElement, () => setShow(false), true);
+
+ return (
+ <div
+ ref={setReferenceElement}
+ className={classNames(
+ "cru-popup-menu-trigger-container",
+ containerClassName,
+ )}
+ style={containerStyle}
+ onClick={() => setShow(true)}
+ >
+ {children}
+ {show &&
+ createPortal(
+ <div
+ ref={setPopperElement}
+ className={`cru-popup-menu-menu-container cru-clickable-${
+ color ?? "primary"
+ }`}
+ style={styles.popper}
+ {...attributes.popper}
+ >
+ <Menu
+ items={items}
+ onItemClick={(e) => {
+ setShow(false);
+ e.stopPropagation();
+ }}
+ />
+ </div>,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ document.getElementById("portal")!,
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabBar.css b/FrontEnd/src/components/tab/TabBar.css
new file mode 100644
index 00000000..dc6970c7
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabBar.css
@@ -0,0 +1,32 @@
+.cru-tab-bar {
+ display: flex;
+}
+
+.cru-tab-bar-tab-area {
+ display: flex;
+ align-items: center;
+ border: var(--cru-clickable-normal-color) 1.6px solid;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.cru-tab-bar-item {
+ color: var(--cru-text-minor-color);
+ transition: all 0.2s;
+ cursor: pointer;
+ padding: 0.3em 1em;
+}
+
+.cru-tab-bar-item:hover {
+ color: var(--cru-clickable-normal-color);
+}
+
+.cru-tab-bar-item.active {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-primary-color);
+}
+
+.cru-tab-bar-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/TabBar.tsx b/FrontEnd/src/components/tab/TabBar.tsx
new file mode 100644
index 00000000..601f664d
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabBar.tsx
@@ -0,0 +1,69 @@
+import { ReactNode } from "react";
+import { Link } from "react-router-dom";
+import classNames from "classnames";
+
+import { Text, ThemeColor, useC } from "../common";
+
+import "./TabBar.css";
+
+export interface Tab {
+ name: string;
+ text: Text;
+ link?: string;
+ onClick?: () => void;
+}
+
+export interface TabsProps {
+ activeTabName?: string;
+ tabs: Tab[];
+ color?: ThemeColor;
+ actions?: ReactNode;
+ dense?: boolean;
+ className?: string;
+}
+
+export default function TabBar(props: TabsProps) {
+ const { tabs, color, activeTabName, className, dense, actions } = props;
+
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ "cru-tab-bar",
+ dense && "dense",
+ `cru-clickable-${color ?? "primary"}`,
+ className,
+ )}
+ >
+ <div className="cru-tab-bar-tab-area">
+ {tabs.map((tab) => {
+ const { name, text, link, onClick } = tab;
+
+ const active = activeTabName === name;
+ const className = classNames("cru-tab-bar-item", active && "active");
+
+ if (link != null) {
+ return (
+ <Link
+ key={name}
+ to={link}
+ onClick={onClick}
+ className={className}
+ >
+ {c(text)}
+ </Link>
+ );
+ } else {
+ return (
+ <span key={name} onClick={onClick} className={className}>
+ {c(text)}
+ </span>
+ );
+ }
+ })}
+ </div>
+ <div className="cru-tab-bar-action-area">{actions}</div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabPages.css b/FrontEnd/src/components/tab/TabPages.css
new file mode 100644
index 00000000..c07d042e
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.css
@@ -0,0 +1,3 @@
+.cru-tab-page-container {
+ padding-top: 0.5em;
+}
diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx
new file mode 100644
index 00000000..ab45ffdf
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.tsx
@@ -0,0 +1,61 @@
+import { ReactNode, useState } from "react";
+import classNames from "classnames";
+
+import { Text, UiLogicError } from "../common";
+
+import Tabs from "./TabBar";
+
+import "./TabPages.css";
+
+interface TabPage {
+ name: string;
+ text: Text;
+ page: ReactNode;
+}
+
+interface TabPagesProps {
+ pages: TabPage[];
+ actions?: ReactNode;
+ dense?: boolean;
+ className?: string;
+ tabBarClassName?: string;
+ pageContainerClassName?: string;
+}
+
+export default function TabPages({
+ pages,
+ actions,
+ dense,
+ className,
+ tabBarClassName,
+ pageContainerClassName,
+}: TabPagesProps) {
+ const [tab, setTab] = useState<string>(pages[0].name);
+
+ const currentPage = pages.find((p) => p.name === tab);
+
+ if (currentPage == null) throw new UiLogicError();
+
+ return (
+ <div className={className}>
+ <Tabs
+ tabs={pages.map((page) => ({
+ name: page.name,
+ text: page.text,
+ onClick: () => {
+ setTab(page.name);
+ },
+ }))}
+ dense={dense}
+ activeTabName={tab}
+ className={tabBarClassName}
+ actions={actions}
+ />
+ <div
+ className={classNames("cru-tab-page-container", pageContainerClassName)}
+ >
+ {currentPage.page}
+ </div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/index.ts b/FrontEnd/src/components/tab/index.ts
new file mode 100644
index 00000000..43d545cc
--- /dev/null
+++ b/FrontEnd/src/components/tab/index.ts
@@ -0,0 +1,2 @@
+export { default as TabBar } from "./TabBar";
+export { default as TabPages } from "./TabPages";
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
new file mode 100644
index 00000000..68dd780f
--- /dev/null
+++ b/FrontEnd/src/components/theme.css
@@ -0,0 +1,201 @@
+:root {
+ --cru-default-font-family: 'Segoe UI', 'DengXian', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ --cru-page-padding: 1em 2em;
+
+ --cru-border-radius: 4px;
+ --cru-card-border-radius: 4px;
+}
+
+/* theme colors */
+:root {
+ --cru-primary-color: hsl(210 100% 50%);
+ --cru-secondary-color: hsl(30 100% 50%);
+ --cru-create-color: hsl(120 100% 25%);
+ --cru-danger-color: hsl(0 100% 50%);
+ --cru-warn-color: #e4a700;
+}
+
+.cru-theme-primary {
+ --cru-theme-color: var(--cru-primary-color);
+}
+
+.cru-theme-secondary {
+ --cru-theme-color: var(--cru-secondary-color);
+}
+
+.cru-theme-create {
+ --cru-theme-color: var(--cru-create-color);
+}
+
+.cru-theme-danger {
+ --cru-theme-color: var(--cru-danger-color);
+}
+
+/* common colors */
+:root {
+ --cru-background-color: hsl(0 0% 100%);
+ --cru-container-background-color: hsl(0 0% 97%);
+ --cru-text-major-color: hsl(0 0% 0%);
+ --cru-text-minor-color: hsl(0 0% 38%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-background-color: hsl(0 0% 0%);
+ --cru-container-background-color: hsl(0 0% 2%);
+ --cru-text-major-color: hsl(0 0% 100%);
+ --cru-text-minor-color: hsl(0 0% 85%);
+ }
+}
+
+:root {
+ --cru-body-background-color: var(--cru-background-color);
+}
+
+/* dialog color */
+
+:root {
+ --cru-dialog-overlay-color: hsl(0 0% 100%);
+ --cru-dialog-container-background-color: hsl(0 0% 100%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-dialog-overlay-color: hsl(0 0% 0%);
+ --cru-dialog-container-background-color: hsl(0 0% 0%);
+ }
+}
+
+/* clickable color */
+:root {
+ --cru-clickable-primary-normal-color: var(--cru-primary-color);
+ --cru-clickable-primary-hover-color: hsl(210 100% 60%);
+ --cru-clickable-primary-focus-color: hsl(210 100% 60%);
+ --cru-clickable-primary-active-color: hsl(210 100% 70%);
+ --cru-clickable-secondary-normal-color: var(--cru-secondary-color);
+ --cru-clickable-secondary-hover-color: hsl(30 100% 60%);
+ --cru-clickable-secondary-focus-color: hsl(30 100% 60%);
+ --cru-clickable-secondary-active-color: hsl(30 100% 70%);
+ --cru-clickable-create-normal-color: var(--cru-create-color);
+ --cru-clickable-create-hover-color: hsl(120 100% 35%);
+ --cru-clickable-create-focus-color: hsl(120 100% 35%);
+ --cru-clickable-create-active-color: hsl(120 100% 35%);
+ --cru-clickable-danger-normal-color: var(--cru-danger-color);
+ --cru-clickable-danger-hover-color: hsl(0 100% 60%);
+ --cru-clickable-danger-focus-color: hsl(0 100% 60%);
+ --cru-clickable-danger-active-color: hsl(0 100% 70%);
+ --cru-clickable-grayscale-normal-color: hsl(0 0% 100%);
+ --cru-clickable-grayscale-hover-color: hsl(0 0% 92%);
+ --cru-clickable-grayscale-focus-color: hsl(0 0% 92%);
+ --cru-clickable-grayscale-active-color: hsl(0 0% 88%);
+ --cru-clickable-light-normal-color: hsl(0 0% 100%);
+ --cru-clickable-light-hover-color: hsl(0 0% 92%);
+ --cru-clickable-light-focus-color: hsl(0 0% 92%);
+ --cru-clickable-light-active-color: hsl(0 0% 88%);
+ --cru-clickable-minor-normal-color: hsl(0 0% 30%);
+ --cru-clickable-minor-hover-color: hsl(0 0% 40%);
+ --cru-clickable-minor-focus-color: hsl(0 0% 40%);
+ --cru-clickable-minor-active-color: hsl(0 0% 45%);
+ --cru-clickable-disabled-color: hsl(0 0% 50%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-clickable-minor-normal-color: hsl(0 0% 74%);
+ --cru-clickable-minor-hover-color: hsl(0 0% 82%);
+ --cru-clickable-minor-focus-color: hsl(0 0% 82%);
+ --cru-clickable-minor-active-color: hsl(0 0% 90%);
+ --cru-clickable-grayscale-normal-color: hsl(0 0% 0%);
+ --cru-clickable-grayscale-hover-color: hsl(0 0% 10%);
+ --cru-clickable-grayscale-focus-color: hsl(0 0% 10%);
+ --cru-clickable-grayscale-active-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-clickable-primary {
+ --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-primary-active-color);
+}
+
+.cru-clickable-secondary {
+ --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-secondary-active-color);
+}
+
+.cru-clickable-create {
+ --cru-clickable-normal-color: var(--cru-clickable-create-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-create-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-create-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-create-active-color);
+}
+
+.cru-clickable-danger {
+ --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-danger-active-color);
+}
+
+.cru-clickable-grayscale {
+ --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-clickable-light {
+ --cru-clickable-normal-color: var(--cru-clickable-light-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-light-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-light-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-light-active-color);
+}
+
+.cru-clickable-minor {
+ --cru-clickable-normal-color: var(--cru-clickable-minor-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-minor-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-minor-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-minor-active-color);
+}
+
+/* button colors */
+:root {
+ /* push button colors */
+ --cru-push-button-text-color: #ffffff;
+ --cru-push-button-disabled-text-color: hsl(0 0% 80%);
+}
+
+/* Card colors */
+:root {
+ --cru-card-background-primary-color: hsl(210 100% 50%);
+ --cru-card-border-primary-color: hsl(210 100% 50%);
+ --cru-card-background-secondary-color: hsl(30 100% 50%);
+ --cru-card-border-secondary-color: hsl(30 100% 50%);
+ --cru-card-background-create-color: hsl(120 100% 25%);
+ --cru-card-border-create-color: hsl(120 100% 25%);
+ --cru-card-background-danger-color: hsl(0 100% 50%);
+ --cru-card-border-danger-color: hsl(0 100% 50%);
+}
+
+.cru-card-primary {
+ --cru-card-background-color: var(--cru-card-background-primary-color);
+ --cru-card-border-color: var(--cru-card-border-primary-color)
+}
+
+.cru-card-secondary {
+ --cru-card-background-color: var(--cru-card-background-secondary-color);
+ --cru-card-border-color: var(--cru-card-border-secondary-color)
+}
+
+.cru-card-create {
+ --cru-card-background-color: var(--cru-card-background-create-color);
+ --cru-card-border-color: var(--cru-card-border-create-color)
+}
+
+.cru-card-danger {
+ --cru-card-background-color: var(--cru-card-background-danger-color);
+ --cru-card-border-color: var(--cru-card-border-danger-color)
+}
diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx
new file mode 100644
index 00000000..8671f2d8
--- /dev/null
+++ b/FrontEnd/src/components/user/UserAvatar.tsx
@@ -0,0 +1,22 @@
+import { Ref, ComponentPropsWithoutRef } from "react";
+
+import { getHttpUserClient } from "~src/http/user";
+
+export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> {
+ username: string;
+ imgRef?: Ref<HTMLImageElement> | null;
+}
+
+export default function UserAvatar({
+ username,
+ imgRef,
+ ...otherProps
+}: UserAvatarProps) {
+ return (
+ <img
+ ref={imgRef}
+ src={getHttpUserClient().generateAvatarUrl(username)}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/http/bookmark.ts b/FrontEnd/src/http/bookmark.ts
index 40e121cc..311f9a0f 100644
--- a/FrontEnd/src/http/bookmark.ts
+++ b/FrontEnd/src/http/bookmark.ts
@@ -1,4 +1,4 @@
-import { withQuery } from "@/utilities/url";
+import { withQuery } from "~src/utilities/url";
import { axios, apiBaseUrl, extractResponseData, Page } from "./common";
diff --git a/FrontEnd/src/http/timeline.ts b/FrontEnd/src/http/timeline.ts
index 401ae116..255c786e 100644
--- a/FrontEnd/src/http/timeline.ts
+++ b/FrontEnd/src/http/timeline.ts
@@ -1,4 +1,4 @@
-import { withQuery } from "@/utilities/url";
+import { withQuery } from "~src/utilities/url";
import {
axios,
diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css
index 419ccb8c..f779297b 100644
--- a/FrontEnd/src/index.css
+++ b/FrontEnd/src/index.css
@@ -1,13 +1,5 @@
-@import "npm:bootstrap/dist/css/bootstrap-reboot.css";
-@import "npm:bootstrap/dist/css/bootstrap-grid.css";
@import "npm:bootstrap-icons/font/bootstrap-icons.css";
-@import "./views/common/index.css";
-
-body {
- background: var(--cru-background-color);
-}
-
small {
line-height: 1.2;
}
@@ -24,7 +16,7 @@ small {
textarea {
resize: none;
outline: none;
- border-color: var(--cru-background-2-color);
+ border-color: var(--cru-bg-2-color);
}
textarea:hover {
@@ -35,22 +27,6 @@ textarea:focus {
border-color: var(--cru-primary-color);
}
-input:not([type="checkbox"]):not([type="radio"]) {
- resize: none;
- outline: none;
- border: 1px solid;
- transition: all 0.5s;
- border-color: var(--cru-background-2-color);
-}
-
-input:hover:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-r2-color);
-}
-
-input:focus:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-color);
-}
-
.white-space-no-wrap {
white-space: nowrap;
}
@@ -75,6 +51,7 @@ i {
.markdown-container {
white-space: initial;
}
+
.markdown-container img {
max-height: 200px;
max-width: 100%;
@@ -82,4 +59,4 @@ i {
a {
text-decoration: none;
-}
+} \ No newline at end of file
diff --git a/FrontEnd/src/index.tsx b/FrontEnd/src/index.tsx
index ba61d357..64d39cd5 100644
--- a/FrontEnd/src/index.tsx
+++ b/FrontEnd/src/index.tsx
@@ -1,14 +1,9 @@
-import "regenerator-runtime";
-import "core-js/modules/es.promise";
-import "core-js/modules/es.array.iterator";
-
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "./i18n";
-import "./palette";
import App from "./App";
@@ -18,5 +13,5 @@ const root = createRoot(container!);
root.render(
<StrictMode>
<App />
- </StrictMode>
+ </StrictMode>,
);
diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json
index 21c826bd..a7e4efe5 100644
--- a/FrontEnd/src/locales/en/translation.json
+++ b/FrontEnd/src/locales/en/translation.json
@@ -86,7 +86,7 @@
"ok": "OK!",
"processing": "Processing...",
"success": "Success!",
- "error": "An error occured."
+ "error": "An error occurred."
},
"timeline": {
"messageCantSee": "Sorry, you are not allowed to see this timeline.😅",
@@ -176,7 +176,7 @@
"noAccount": "If you don't have an account and know a register code, then click <1>here</1> to register."
},
"settings": {
- "subheaders": {
+ "subheader": {
"account": "Account",
"customization": "Customization"
},
@@ -186,7 +186,6 @@
"logout": "Log out this account",
"changeAvatar": "Change avatar",
"changeNickname": "Change nickname",
- "changeBookmarkVisibility": "Change bookmark visibility",
"myRegisterCode": "My register code:",
"myRegisterCodeDesc": "Click to create a new register code.",
"renewRegisterCode": "Renew Register Code",
@@ -224,23 +223,11 @@
}
},
"about": {
- "author": {
- "title": "Site Developer",
- "name": "Name: ",
- "introduction": "Introduction: ",
- "introductionContent": "A programmer coding based on coincidence",
- "links": "Links: "
- },
- "site": {
- "title": "Site Information",
- "content": "The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.",
- "repo": "GitHub Repo"
- },
"credits": {
"title": "Credits",
- "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.",
- "frontend": "Frontend: ",
- "backend": "Backend: "
+ "content": "Timeline stands on shoulders of giants. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.",
+ "frontend": "Frontend",
+ "backend": "Backend"
}
},
"admin": {
diff --git a/FrontEnd/src/locales/zh/translation.json b/FrontEnd/src/locales/zh/translation.json
index b7212128..8a2f628f 100644
--- a/FrontEnd/src/locales/zh/translation.json
+++ b/FrontEnd/src/locales/zh/translation.json
@@ -176,7 +176,7 @@
"noAccount": "如果你没有账号但有一个注册码,请点击<1>这里</1>注册账号。"
},
"settings": {
- "subheaders": {
+ "subheader": {
"account": "账户",
"customization": "个性化"
},
diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/migrating/admin/Admin.tsx
index 986c36b4..986c36b4 100644
--- a/FrontEnd/src/views/admin/Admin.tsx
+++ b/FrontEnd/src/migrating/admin/Admin.tsx
diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/migrating/admin/AdminNav.tsx
index b7385e5c..b7385e5c 100644
--- a/FrontEnd/src/views/admin/AdminNav.tsx
+++ b/FrontEnd/src/migrating/admin/AdminNav.tsx
diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/migrating/admin/MoreAdmin.tsx
index d49d211f..d49d211f 100644
--- a/FrontEnd/src/views/admin/MoreAdmin.tsx
+++ b/FrontEnd/src/migrating/admin/MoreAdmin.tsx
diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/migrating/admin/UserAdmin.tsx
index d5179bf5..08560c87 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/migrating/admin/UserAdmin.tsx
@@ -1,3 +1,6 @@
+// eslint-disable
+// @ts-nocheck
+
import { useState, useEffect } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -5,9 +8,7 @@ import classnames from "classnames";
import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user";
-import OperationDialog, {
- OperationDialogBoolInput,
-} from "../common/dialog/OperationDialog";
+import OperationDialog from "../common/dialog/OperationDialog";
import Button from "../common/button/Button";
import Spinner from "../common/Spinner";
import FlatButton from "../common/button/FlatButton";
@@ -21,18 +22,15 @@ const CreateUserDialog: React.FC<{
return (
<OperationDialog
title="admin:user.dialog.create.title"
- themeColor="success"
inputPrompt="admin:user.dialog.create.prompt"
- inputScheme={
- [
- { type: "text", label: "admin:user.username" },
- { type: "text", label: "admin:user.password" },
- ] as const
- }
- onProcess={([username, password]) =>
+ inputs={[
+ { key: "username", type: "text", label: "admin:user.username" },
+ { key: "password", type: "text", label: "admin:user.password" },
+ ]}
+ onProcess={({ username, password }) =>
getHttpUserClient().post({
- username,
- password,
+ username: username as string,
+ password: password as string,
})
}
onClose={close}
@@ -80,13 +78,12 @@ const UserModifyDialog: React.FC<{
open={open}
onClose={close}
title="admin:user.dialog.modify.title"
- themeColor="danger"
- inputPrompt={() => (
+ inputPromptNode={
<Trans i18nKey="admin:user.dialog.modify.prompt">
0<UsernameLabel>{user.username}</UsernameLabel>2
</Trans>
- )}
- inputScheme={
+ }
+ inputs={
[
{
type: "text",
@@ -120,7 +117,7 @@ const UserPermissionModifyDialog: React.FC<{
onSuccess: () => void;
}> = ({ open, close, user, onSuccess }) => {
const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
- (permission) => user.permissions.includes(permission)
+ (permission) => user.permissions.includes(permission),
);
return (
@@ -139,7 +136,7 @@ const UserPermissionModifyDialog: React.FC<{
type: "bool",
label: { type: "custom", value: permission },
initValue: oldPermissionBoolList[index],
- })
+ }),
)}
onProcess={async (newPermissionBoolList): Promise<boolean[]> => {
for (let index = 0; index < kUserPermissionList.length; index++) {
@@ -150,12 +147,12 @@ const UserPermissionModifyDialog: React.FC<{
if (newValue) {
await getHttpUserClient().putUserPermission(
user.username,
- permission
+ permission,
);
} else {
await getHttpUserClient().deleteUserPermission(
user.username,
- permission
+ permission,
);
}
}
diff --git a/FrontEnd/src/views/admin/index.css b/FrontEnd/src/migrating/admin/index.css
index 17e24586..17e24586 100644
--- a/FrontEnd/src/views/admin/index.css
+++ b/FrontEnd/src/migrating/admin/index.css
diff --git a/FrontEnd/src/views/admin/index.tsx b/FrontEnd/src/migrating/admin/index.tsx
index 0467711d..0467711d 100644
--- a/FrontEnd/src/views/admin/index.tsx
+++ b/FrontEnd/src/migrating/admin/index.tsx
diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx
index a8be2c29..f1c3fc6a 100644
--- a/FrontEnd/src/views/center/CenterBoards.tsx
+++ b/FrontEnd/src/migrating/center/CenterBoards.tsx
@@ -1,13 +1,13 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
-import { highlightTimelineUsername } from "@/common";
+import { highlightTimelineUsername } from "~src/common";
-import { pushAlert } from "@/services/alert";
-import { useUserLoggedIn } from "@/services/user";
+import { pushAlert } from "~src/services/alert";
+import { useUserLoggedIn } from "~src/services/user";
-import { getHttpTimelineClient } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
+import { getHttpTimelineClient } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
import TimelineBoard from "./TimelineBoard";
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx
index b3ccdf8c..8f4401bc 100644
--- a/FrontEnd/src/views/center/TimelineBoard.tsx
+++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx
@@ -2,7 +2,7 @@ import * as React from "react";
import classnames from "classnames";
import { Link } from "react-router-dom";
-import { TimelineBookmark } from "@/http/bookmark";
+import { TimelineBookmark } from "~src/http/bookmark";
import TimelineLogo from "../common/TimelineLogo";
import LoadFailReload from "../common/LoadFailReload";
diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
index 63742936..340a08fe 100644
--- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
@@ -1,11 +1,11 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
-import { validateTimelineName } from "@/services/timeline";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { validateTimelineName } from "~src/services/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
import OperationDialog from "../common/dialog/OperationDialog";
-import { useUserLoggedIn } from "@/services/user";
+import { useUserLoggedIn } from "~src/services/user";
interface TimelineCreateDialogProps {
open: boolean;
diff --git a/FrontEnd/src/views/center/index.css b/FrontEnd/src/migrating/center/index.css
index a779ff90..a779ff90 100644
--- a/FrontEnd/src/views/center/index.css
+++ b/FrontEnd/src/migrating/center/index.css
diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx
index 77af2c20..11502517 100644
--- a/FrontEnd/src/views/center/index.tsx
+++ b/FrontEnd/src/migrating/center/index.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
-import { useUserLoggedIn } from "@/services/user";
+import { useUserLoggedIn } from "~src/services/user";
import SearchInput from "../common/SearchInput";
import Button from "../common/button/Button";
diff --git a/FrontEnd/src/pages/404/index.css b/FrontEnd/src/pages/404/index.css
new file mode 100644
index 00000000..cf5efbe7
--- /dev/null
+++ b/FrontEnd/src/pages/404/index.css
@@ -0,0 +1,7 @@
+.page-404 {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-danger-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/404/index.tsx b/FrontEnd/src/pages/404/index.tsx
new file mode 100644
index 00000000..751a450b
--- /dev/null
+++ b/FrontEnd/src/pages/404/index.tsx
@@ -0,0 +1,5 @@
+import "./index.css";
+
+export default function NotFoundPage() {
+ return <div className="page-404">Ah-oh, 404!</div>;
+}
diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css
new file mode 100644
index 00000000..1ce7a7c8
--- /dev/null
+++ b/FrontEnd/src/pages/about/index.css
@@ -0,0 +1,7 @@
+.about-page {
+ line-height: 1.5;
+}
+
+.about-page a {
+ color: var(--cru-surface-on-color);
+}
diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx
new file mode 100644
index 00000000..bce64322
--- /dev/null
+++ b/FrontEnd/src/pages/about/index.tsx
@@ -0,0 +1,87 @@
+import "./index.css";
+
+import { useC } from "~src/common";
+import Page from "~src/components/Page";
+
+interface Credit {
+ name: string;
+ url: string;
+}
+
+type Credits = Credit[];
+
+const frontendCredits: Credits = [
+ {
+ name: "react.js",
+ url: "https://reactjs.org",
+ },
+ {
+ name: "typescript",
+ url: "https://www.typescriptlang.org",
+ },
+ {
+ name: "bootstrap",
+ url: "https://getbootstrap.com",
+ },
+ {
+ name: "parcel.js",
+ url: "https://parceljs.org",
+ },
+ {
+ name: "eslint",
+ url: "https://eslint.org",
+ },
+ {
+ name: "prettier",
+ url: "https://prettier.io",
+ },
+];
+
+const backendCredits: Credits = [
+ {
+ 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",
+ },
+];
+
+export default function AboutPage() {
+ const c = useC();
+
+ return (
+ <Page className="about-page">
+ <h2>{c("about.credits.title")}</h2>
+ <p>{c("about.credits.content")}</p>
+ <h3>{c("about.credits.frontend")}</h3>
+ <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>
+ <h3>{c("about.credits.backend")}</h3>
+ <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>
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css
new file mode 100644
index 00000000..16601d8a
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.css
@@ -0,0 +1,13 @@
+.home-page {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-primary-color);
+}
+
+.home-page-2 {
+ width: 100%;
+ text-align: center;
+ margin-top: 2em;
+}
diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx
new file mode 100644
index 00000000..c29a1ca5
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.tsx
@@ -0,0 +1,12 @@
+import "./index.css";
+
+export default function HomePage() {
+ return (
+ <>
+ <div className="home-page">Be patient! I&apos;m working on this...</div>
+ <div className="home-page-2">
+ Have a look at <a href="/crupest">here</a>!
+ </div>
+ </>
+ );
+}
diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css
new file mode 100644
index 00000000..08e43c22
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.css
@@ -0,0 +1,7 @@
+.loading-page {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx
new file mode 100644
index 00000000..29d27adc
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.tsx
@@ -0,0 +1,11 @@
+import Spinner from "~src/components/Spinner";
+
+import "./index.css";
+
+export default function LoadingPage() {
+ return (
+ <div className="loading-page">
+ <Spinner />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css
new file mode 100644
index 00000000..ef97359c
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.css
@@ -0,0 +1,14 @@
+.login-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.login-page-welcome {
+ text-align: center;
+ font-size: 2em;
+}
+
+.login-page-error {
+ color: var(--cru-danger-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
new file mode 100644
index 00000000..39ea3831
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -0,0 +1,127 @@
+import { useState, useEffect } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { useUser, userService } from "~src/services/user";
+
+import { useC } from "~src/components/common";
+import LoadingButton from "~src/components/button/LoadingButton";
+import { InputGroup, useInputs } from "~src/components/input/InputGroup";
+import Page from "~src/components/Page";
+
+import "./index.css";
+
+export default function LoginPage() {
+ const c = useC();
+
+ const user = useUser();
+
+ const navigate = useNavigate();
+
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+ useInputs({
+ init: {
+ scheme: {
+ inputs: [
+ {
+ key: "username",
+ type: "text",
+ label: "user.username",
+ },
+ {
+ key: "password",
+ type: "text",
+ label: "user.password",
+ password: true,
+ },
+ {
+ key: "rememberMe",
+ type: "bool",
+ label: "user.rememberMe",
+ },
+ ],
+ validator: ({ username, password }, errors) => {
+ if (username === "") {
+ errors["username"] = "login.emptyUsername";
+ }
+ if (password === "") {
+ errors["password"] = "login.emptyPassword";
+ }
+ },
+ },
+ dataInit: {},
+ },
+ });
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => navigate("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [navigate, user]);
+
+ if (user != null) {
+ return <p>{c("login.alreadyLogin")}</p>;
+ }
+
+ const submit = (): void => {
+ const confirmResult = confirm();
+ if (confirmResult.type === "ok") {
+ const { username, password, rememberMe } = confirmResult.values;
+ setAllDisabled(true);
+ setProcess(true);
+ userService
+ .login(
+ {
+ username: username as string,
+ password: password as string,
+ },
+ rememberMe as boolean,
+ )
+ .then(
+ () => {
+ if (history.length === 0) {
+ navigate("/");
+ } else {
+ navigate(-1);
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setAllDisabled(false);
+ setError(e.message);
+ },
+ );
+ }
+ };
+
+ return (
+ <Page className="login-page">
+ <div className="login-page-container">
+ <div className="login-page-welcome">{c("welcome")}</div>
+ <InputGroup {...inputGroupProps} />
+ {error ? <p className="login-page-error">{c(error)}</p> : null}
+ <div className="login-page-button-row">
+ <LoadingButton
+ loading={process}
+ onClick={(e) => {
+ submit();
+ e.preventDefault();
+ }}
+ disabled={hasErrorAndDirty}
+ >
+ {c("user.login")}
+ </LoadingButton>
+ </div>
+ <Trans i18nKey="login.noAccount">
+ 0<Link to="/register">1</Link>2
+ </Trans>
+ </div>
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/views/register/index.css b/FrontEnd/src/pages/register/index.css
index c0078b28..c0078b28 100644
--- a/FrontEnd/src/views/register/index.css
+++ b/FrontEnd/src/pages/register/index.css
diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx
new file mode 100644
index 00000000..fa25c2c2
--- /dev/null
+++ b/FrontEnd/src/pages/register/index.tsx
@@ -0,0 +1,130 @@
+import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { userService, useUser } from "~src/services/user";
+
+import { LoadingButton } from "~src/components/button";
+import { useInputs, InputGroup } from "~src/components/input/InputGroup";
+
+import "./index.css";
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+
+ const { t } = useTranslation();
+
+ const user = useUser();
+
+ const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+ useInputs({
+ init: {
+ scheme: {
+ inputs: [
+ {
+ key: "username",
+ type: "text",
+ label: "register.username",
+ },
+ {
+ key: "password",
+ type: "text",
+ label: "register.password",
+ password: true,
+ },
+ {
+ key: "confirmPassword",
+ type: "text",
+ label: "register.confirmPassword",
+ password: true,
+ },
+ {
+ key: "registerCode",
+
+ type: "text",
+ label: "register.registerCode",
+ },
+ ],
+ validator: (
+ { username, password, confirmPassword, registerCode },
+ errors,
+ ) => {
+ if (username === "") {
+ errors["username"] = "register.error.usernameEmpty";
+ }
+ if (password === "") {
+ errors["password"] = "register.error.passwordEmpty";
+ }
+ if (confirmPassword !== password) {
+ errors["confirmPassword"] = "register.error.confirmPasswordWrong";
+ }
+ if (registerCode === "") {
+ errors["registerCode"] = "register.error.registerCodeEmpty";
+ }
+ },
+ },
+ dataInit: {},
+ },
+ });
+
+ const [process, setProcess] = useState<boolean>(false);
+ const [resultError, setResultError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (user != null) {
+ navigate("/");
+ }
+ }, [navigate, user]);
+
+ return (
+ <div className="container register-page">
+ <InputGroup {...inputGroupProps} />
+ {resultError && <div className="cru-color-danger">{t(resultError)}</div>}
+ <LoadingButton
+ text="register.register"
+ loading={process}
+ disabled={hasErrorAndDirty}
+ onClick={() => {
+ const confirmResult = confirm();
+ if (confirmResult.type === "ok") {
+ const { username, password, registerCode } = confirmResult.values;
+ setProcess(true);
+ setAllDisabled(true);
+ void getHttpTokenClient()
+ .register({
+ username: username as string,
+ password: password as string,
+ registerCode: registerCode as string,
+ })
+ .then(
+ () => {
+ void userService
+ .login(
+ {
+ username: username as string,
+ password: password as string,
+ },
+ true,
+ )
+ .then(() => {
+ navigate("/");
+ });
+ },
+ (error) => {
+ if (error instanceof HttpBadRequestError) {
+ setResultError("register.error.registerCodeInvalid");
+ } else {
+ setResultError("error.network");
+ }
+ setProcess(false);
+ setAllDisabled(false);
+ },
+ );
+ }
+ }}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
new file mode 100644
index 00000000..c9eb8011
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
@@ -0,0 +1,22 @@
+.change-avatar-dialog-prompt {
+ margin: 0.5em 0;
+}
+
+.change-avatar-dialog-prompt.success {
+ color: var(--cru-create-color);
+}
+
+.change-avatar-dialog-prompt.error {
+ color: var(--cru-danger-color);
+}
+
+.change-avatar-cropper {
+ max-width: 400px;
+ max-height: 400px;
+}
+
+.change-avatar-preview-image {
+ min-width: 50%;
+ max-width: 100%;
+ max-height: 300px;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..0df10411
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -0,0 +1,276 @@
+import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react";
+
+import { useC, Text, UiLogicError } from "~src/common";
+
+import { useUser } from "~src/services/user";
+
+import { getHttpUserClient } from "~src/http/user";
+
+import { ImageCropper, useImageCrop } from "~src/components/ImageCropper";
+import BlobImage from "~src/components/BlobImage";
+import { ButtonRowV2 } from "~src/components/button";
+import {
+ Dialog,
+ DialogContainer,
+ useDialogController,
+} from "~src/components/dialog";
+
+import "./ChangeAvatarDialog.css";
+
+export default function ChangeAvatarDialog() {
+ const c = useC();
+
+ const user = useUser();
+
+ const controller = useDialogController();
+
+ type State =
+ | "select"
+ | "crop"
+ | "process-crop"
+ | "preview"
+ | "uploading"
+ | "success"
+ | "error";
+ const [state, setState] = useState<State>("select");
+
+ const [file, setFile] = useState<File | null>(null);
+
+ const { canCrop, crop, imageCropperProps } = useImageCrop(file, {
+ constraint: {
+ ratio: 1,
+ },
+ });
+
+ const [resultBlob, setResultBlob] = useState<Blob | null>(null);
+ const [message, setMessage] = useState<Text>(
+ "settings.dialogChangeAvatar.prompt.select",
+ );
+
+ const close = controller.closeDialog;
+
+ const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ };
+
+ const onCropNext = () => {
+ if (!canCrop) {
+ throw new UiLogicError();
+ }
+
+ setState("process-crop");
+
+ void crop().then((b) => {
+ setState("preview");
+ setResultBlob(b);
+ });
+ };
+
+ const onCropPrevious = () => {
+ setFile(null);
+ setState("select");
+ };
+
+ const onPreviewPrevious = () => {
+ setState("crop");
+ };
+
+ const upload = () => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ if (user == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ controller.setCanSwitchDialog(false);
+ getHttpUserClient()
+ .putAvatar(user.username, resultBlob)
+ .then(
+ () => {
+ setState("success");
+ },
+ () => {
+ setState("error");
+ setMessage("operationDialog.error");
+ },
+ )
+ .finally(() => {
+ controller.setCanSwitchDialog(true);
+ });
+ };
+
+ const cancelButton = {
+ key: "cancel",
+ text: "operationDialog.cancel",
+ onClick: close,
+ } as const;
+
+ const createPreviousButton = (onClick: () => void) =>
+ ({
+ key: "previous",
+ text: "operationDialog.previousStep",
+ onClick,
+ }) as const;
+
+ const buttonsMap: Record<
+ State,
+ ComponentPropsWithoutRef<typeof ButtonRowV2>["buttons"]
+ > = {
+ select: [
+ cancelButton,
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: () => setState("crop"),
+ disabled: file == null,
+ },
+ ],
+ crop: [
+ cancelButton,
+ createPreviousButton(onCropPrevious),
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: onCropNext,
+ disabled: !canCrop,
+ },
+ ],
+ "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)],
+ preview: [
+ cancelButton,
+ createPreviousButton(onPreviewPrevious),
+ {
+ key: "upload",
+ action: "major",
+ text: "settings.dialogChangeAvatar.upload",
+ onClick: upload,
+ },
+ ],
+ uploading: [],
+ success: [
+ {
+ key: "ok",
+ text: "operationDialog.ok",
+ color: "create",
+ onClick: close,
+ },
+ ],
+ error: [
+ cancelButton,
+ {
+ key: "retry",
+ action: "major",
+ text: "operationDialog.retry",
+ onClick: upload,
+ },
+ ],
+ };
+
+ return (
+ <Dialog>
+ <DialogContainer
+ title="settings.dialogChangeAvatar.title"
+ titleColor="primary"
+ buttonsV2={buttonsMap[state]}
+ >
+ {(() => {
+ if (state === "select") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.select")}
+ </div>
+ <input
+ className="change-avatar-select-input"
+ type="file"
+ accept="image/*"
+ onChange={onSelectFile}
+ />
+ </div>
+ );
+ } else if (state === "crop") {
+ if (file == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <div className="change-avatar-dialog-container">
+ <ImageCropper
+ {...imageCropperProps}
+ containerClassName="change-avatar-cropper"
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.crop")}
+ </div>
+ </div>
+ );
+ } else if (state === "process-crop") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.processingCrop")}
+ </div>
+ </div>
+ );
+ } else if (state === "preview") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ alt={
+ c("settings.dialogChangeAvatar.previewImgAlt") ?? undefined
+ }
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.preview")}
+ </div>
+ </div>
+ );
+ } else if (state === "uploading") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.uploading")}
+ </div>
+ </div>
+ );
+ } else if (state === "success") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt success">
+ {c("operationDialog.success")}
+ </div>
+ </div>
+ );
+ } else {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt error">
+ {c(message)}
+ </div>
+ </div>
+ );
+ }
+ })()}
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..912f554f
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -0,0 +1,26 @@
+import { getHttpUserClient } from "~src/http/user";
+import { useUserLoggedIn } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export default function ChangeNicknameDialog() {
+ const user = useUserLoggedIn();
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangeNickname.title"
+ inputs={[
+ {
+ key: "newNickname",
+ type: "text",
+ label: "settings.dialogChangeNickname.inputLabel",
+ },
+ ]}
+ onProcess={({ newNickname }) => {
+ return getHttpUserClient().patch(user.username, {
+ nickname: newNickname,
+ });
+ }}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
new file mode 100644
index 00000000..c3111ac8
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { userService } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export function ChangePasswordDialog() {
+ const navigate = useNavigate();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangePassword.title"
+ color="danger"
+ inputPrompt="settings.dialogChangePassword.prompt"
+ inputs={{
+ inputs: [
+ {
+ key: "oldPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputOldPassword",
+ password: true,
+ },
+ {
+ key: "newPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputNewPassword",
+ password: true,
+ },
+ {
+ key: "retypedNewPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputRetypeNewPassword",
+ password: true,
+ },
+ ],
+ validator: (
+ { oldPassword, newPassword, retypedNewPassword },
+ errors,
+ ) => {
+ if (oldPassword === "") {
+ errors["oldPassword"] =
+ "settings.dialogChangePassword.errorEmptyOldPassword";
+ }
+ if (newPassword === "") {
+ errors["newPassword"] =
+ "settings.dialogChangePassword.errorEmptyNewPassword";
+ }
+ if (retypedNewPassword !== newPassword) {
+ errors["retypedNewPassword"] =
+ "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ },
+ }}
+ onProcess={async ({ oldPassword, newPassword }) => {
+ await userService.changePassword(oldPassword, newPassword);
+ setRedirect(true);
+ }}
+ onSuccessAndClose={() => {
+ if (redirect) {
+ navigate("/login");
+ }
+ }}
+ />
+ );
+}
+
+export default ChangePasswordDialog;
diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css
new file mode 100644
index 00000000..19e7cff4
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.css
@@ -0,0 +1,76 @@
+.setting-section {
+ padding: 1em 0;
+ margin: 1em 0;
+}
+
+.setting-section-title {
+ padding: 0 1em;
+}
+
+.setting-section-item-area {
+ margin-top: 1em;
+ border-top: 1px solid var(--cru-primary-color);
+}
+
+.setting-item-container {
+ padding: 0.5em 1em;
+ transition: background-color 0.3s;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border-bottom: 1px solid var(--cru-clickable-grayscale-active-color);
+ display: flex;
+ align-items: center;
+}
+
+.setting-item-container:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.setting-item-container:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.setting-item-container:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.setting-item-container.danger {
+ color: var(--cru-danger-color);
+}
+
+.setting-item-label-sub {
+ color: var(--cru-text-minor-color);
+}
+
+.setting-item-value-area {
+ margin-left: auto;
+}
+
+.setting-item-container.setting-type-button {
+ cursor: pointer;
+}
+
+.register-code {
+ background: var(--cru-text-major-color);
+ color: var(--cru-background-color);
+ border-radius: 3px;
+ padding: 0.2em;
+ cursor: pointer;
+}
+
+@media (max-width: 576) {
+ .setting-item-container.setting-type-select {
+ flex-direction: column;
+ }
+
+ .setting-item-container.setting-type-select .setting-item-value-area {
+ margin-top: 1em;
+ }
+
+ .register-code-setting-item {
+ flex-direction: column;
+ }
+
+ .register-code-setting-item .register-code {
+ margin-top: 1em;
+ }
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
new file mode 100644
index 00000000..88ab5cb2
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -0,0 +1,297 @@
+import {
+ useState,
+ useEffect,
+ ReactNode,
+ ComponentPropsWithoutRef,
+} from "react";
+import { useTranslation } from "react-i18next"; // For change language.
+import { useNavigate } from "react-router-dom";
+import classNames from "classnames";
+
+import { useUser, userService } from "~src/services/user";
+import { getHttpUserClient } from "~src/http/user";
+
+import { useC, Text } from "~src/common";
+
+import { pushAlert } from "~src/components/alert";
+import {
+ useDialog,
+ DialogProvider,
+ ConfirmDialog,
+} from "~src/components/dialog";
+import Card from "~src/components/Card";
+import Spinner from "~src/components/Spinner";
+import Page from "~src/components/Page";
+
+import ChangePasswordDialog from "./ChangePasswordDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+
+import "./index.css";
+
+interface SettingSectionProps
+ extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
+ title: Text;
+ children?: ReactNode;
+}
+
+function SettingSection({
+ title,
+ className,
+ children,
+ ...otherProps
+}: SettingSectionProps) {
+ const c = useC();
+
+ return (
+ <Card className={classNames(className, "setting-section")} {...otherProps}>
+ <h2 className="setting-section-title">{c(title)}</h2>
+ <div className="setting-section-item-area">{children}</div>
+ </Card>
+ );
+}
+
+interface SettingItemContainerProps
+ extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
+ title: Text;
+ description?: Text;
+ danger?: boolean;
+ extraClassName?: string;
+}
+
+function SettingItemContainer({
+ title,
+ description,
+ danger,
+ extraClassName,
+ className,
+ children,
+ ...otherProps
+}: SettingItemContainerProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ className,
+ "setting-item-container",
+ danger && "danger",
+ extraClassName,
+ )}
+ {...otherProps}
+ >
+ <div className="setting-item-label-area">
+ <div className="setting-item-label-title">{c(title)}</div>
+ <small className="setting-item-label-sub">{c(description)}</small>
+ </div>
+ <div className="setting-item-value-area">{children}</div>
+ </div>
+ );
+}
+
+type ButtonSettingItemProps = Omit<SettingItemContainerProps, "extraClassName">;
+
+function ButtonSettingItem(props: ButtonSettingItemProps) {
+ return (
+ <SettingItemContainer extraClassName="setting-type-button" {...props} />
+ );
+}
+
+interface SelectSettingItemProps
+ extends Omit<SettingItemContainerProps, "onSelect" | "extraClassName"> {
+ options: {
+ value: string;
+ label: Text;
+ }[];
+ value?: string | null;
+ onSelect: (value: string) => void;
+}
+
+function SelectSettingsItem({
+ options,
+ value,
+ onSelect,
+ ...extraProps
+}: SelectSettingItemProps) {
+ const c = useC();
+
+ return (
+ <SettingItemContainer extraClassName="setting-type-select" {...extraProps}>
+ {value == null ? (
+ <Spinner />
+ ) : (
+ <select
+ className="select-setting-item-select"
+ value={value}
+ onChange={(e) => {
+ onSelect(e.target.value);
+ }}
+ >
+ {options.map(({ value, label }) => (
+ <option key={value} value={value}>
+ {c(label)}
+ </option>
+ ))}
+ </select>
+ )}
+ </SettingItemContainer>
+ );
+}
+
+function RegisterCodeSettingItem() {
+ const user = useUser();
+
+ // undefined: loading
+ const [registerCode, setRegisterCode] = useState<undefined | null | string>();
+
+ const { controller, createDialogSwitch } = useDialog({
+ confirm: (
+ <ConfirmDialog
+ title="settings.renewRegisterCode"
+ body="settings.renewRegisterCodeDesc"
+ onConfirm={() => {
+ if (user == null) throw new Error();
+ void getHttpUserClient()
+ .renewRegisterCode(user.username)
+ .then(() => {
+ setRegisterCode(undefined);
+ });
+ }}
+ />
+ ),
+ });
+
+ useEffect(() => {
+ setRegisterCode(undefined);
+ }, [user]);
+
+ useEffect(() => {
+ if (user != null && registerCode === undefined) {
+ void getHttpUserClient()
+ .getRegisterCode(user.username)
+ .then((code) => {
+ setRegisterCode(code.registerCode ?? null);
+ });
+ }
+ }, [user, registerCode]);
+
+ return (
+ <>
+ <SettingItemContainer
+ title="settings.myRegisterCode"
+ description="settings.myRegisterCodeDesc"
+ className="register-code-setting-item"
+ onClick={createDialogSwitch("confirm")}
+ >
+ {registerCode === undefined ? (
+ <Spinner />
+ ) : registerCode === null ? (
+ <span>Noop</span>
+ ) : (
+ <code
+ className="register-code"
+ onClick={(event) => {
+ void navigator.clipboard.writeText(registerCode).then(() => {
+ pushAlert({
+ color: "create",
+ message: "settings.myRegisterCodeCopied",
+ });
+ });
+ event.stopPropagation();
+ }}
+ >
+ {registerCode}
+ </code>
+ )}
+ </SettingItemContainer>
+ <DialogProvider controller={controller} />
+ </>
+ );
+}
+
+function LanguageChangeSettingItem() {
+ const { i18n } = useTranslation();
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <SelectSettingsItem
+ title="settings.languagePrimary"
+ description="settings.languageSecondary"
+ options={[
+ {
+ value: "zh",
+ label: {
+ type: "custom",
+ value: "中文",
+ },
+ },
+ {
+ value: "en",
+ label: {
+ type: "custom",
+ value: "English",
+ },
+ },
+ ]}
+ value={language}
+ onSelect={(value) => {
+ void i18n.changeLanguage(value);
+ }}
+ />
+ );
+}
+
+export default function SettingPage() {
+ const user = useUser();
+ const navigate = useNavigate();
+
+ const { controller, createDialogSwitch } = useDialog({
+ "change-nickname": <ChangeNicknameDialog />,
+ "change-avatar": <ChangeAvatarDialog />,
+ "change-password": <ChangePasswordDialog />,
+ logout: (
+ <ConfirmDialog
+ title="settings.dialogConfirmLogout.title"
+ body="settings.dialogConfirmLogout.prompt"
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ navigate("/");
+ });
+ }}
+ />
+ ),
+ });
+
+ return (
+ <Page noTopPadding>
+ {user ? (
+ <SettingSection title="settings.subheader.account">
+ <RegisterCodeSettingItem />
+ <ButtonSettingItem
+ title="settings.changeAvatar"
+ onClick={createDialogSwitch("change-avatar")}
+ />
+ <ButtonSettingItem
+ title="settings.changeNickname"
+ onClick={createDialogSwitch("change-nickname")}
+ />
+ <ButtonSettingItem
+ title="settings.changePassword"
+ onClick={createDialogSwitch("change-password")}
+ danger
+ />
+ <ButtonSettingItem
+ title="settings.logout"
+ onClick={createDialogSwitch("logout")}
+ danger
+ />
+ </SettingSection>
+ ) : null}
+ <SettingSection title="settings.subheader.customization">
+ <LanguageChangeSettingItem />
+ </SettingSection>
+ <DialogProvider controller={controller} />
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
index 7fe83b9b..0a6979cb 100644
--- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
@@ -2,8 +2,8 @@
font-size: 0.8em;
border-radius: 5px;
padding: 0.1em 1em;
- background-color: #eaf2ff;
}
+
.connection-status-badge::before {
width: 10px;
height: 10px;
@@ -12,25 +12,27 @@
content: "";
margin-right: 0.6em;
}
+
.connection-status-badge.success {
- color: #006100;
+ color: var(--cru-create-color);
}
+
.connection-status-badge.success::before {
- background-color: #006100;
+ background-color: var(--cru-create-color);
}
.connection-status-badge.warning {
- color: #e4a700;
+ color: var(--cru-warn-color);
}
.connection-status-badge.warning::before {
- background-color: #e4a700;
+ background-color: var(--cru-warn-color);
}
.connection-status-badge.danger {
- color: #fd1616;
+ color: var(--cru-danger-color);
}
.connection-status-badge.danger::before {
- background-color: #fd1616;
+ background-color: var(--cru-danger-color);
}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
index 2b820454..63990878 100644
--- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -1,14 +1,13 @@
-import * as React from "react";
-import classnames from "classnames";
+import classNames from "classnames";
import { HubConnectionState } from "@microsoft/signalr";
-import { useTranslation } from "react-i18next";
+
+import { useC }from '~/src/components/common';
import "./ConnectionStatusBadge.css";
-export interface ConnectionStatusBadgeProps {
+interface ConnectionStatusBadgeProps {
status: HubConnectionState;
className?: string;
- style?: React.CSSProperties;
}
const classNameMap: Record<HubConnectionState, string> = {
@@ -19,23 +18,19 @@ const classNameMap: Record<HubConnectionState, string> = {
Reconnecting: "warning",
};
-const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
- const { status, className, style } = props;
-
- const { t } = useTranslation();
+export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) {
+ const c = useC();
return (
<div
- className={classnames(
+ className={classNames(
"connection-status-badge",
classNameMap[status],
className
)}
- style={style}
>
- {t(`connectionState.${status}`)}
+ {c(`connectionState.${status}`)}
</div>
);
};
-export default ConnectionStatusBadge;
diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css
new file mode 100644
index 00000000..db25eda0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.css
@@ -0,0 +1,42 @@
+.timeline-container {
+ --timeline-background-color: hsl(0, 0%, 95%);
+ --timeline-shadow-color: hsla(0, 0%, 0%, 0.5);
+ --timeline-card-shadow: 2px 1px 10px -2px var(--timeline-shadow-color);
+ --timeline-post-card-background-color: hsl(0, 0%, 100%);
+ --timeline-post-card-shadow: 0px 0px 11px -2px var(--timeline-shadow-color);
+ --timeline-post-card-border-radius: 10px;
+ --timeline-post-text-color: hsl(0, 0%, 0%);
+ --timeline-datetime-label-background-color: hsl(0, 0%, 30%);
+}
+
+@media (prefers-color-scheme: dark) {
+ .timeline-container {
+ --timeline-background-color: hsl(0, 0%, 0%);
+ --timeline-post-card-background-color: hsl(0, 0%, 15%);
+ --timeline-post-card-shadow: none;
+ }
+}
+
+.timeline {
+ z-index: 0;
+ position: relative;
+ width: 100%;
+ padding-top: 10px;
+ background: var(--timeline-background-color);
+}
+
+.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;
+}
diff --git a/FrontEnd/src/views/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index 3a7fbd00..32cbf8c8 100644
--- a/FrontEnd/src/views/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -1,69 +1,63 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useScrollToBottom } from "@/utilities/hooks";
+import { useState, useEffect } from "react";
+import classNames from "classnames";
import { HubConnectionState } from "@microsoft/signalr";
import {
HttpForbiddenError,
HttpNetworkError,
HttpNotFoundError,
-} from "@/http/common";
+} from "~src/http/common";
import {
getHttpTimelineClient,
HttpTimelineInfo,
HttpTimelinePostInfo,
-} from "@/http/timeline";
+} from "~src/http/timeline";
-import { useUser } from "@/services/user";
-import { getTimelinePostUpdate$ } from "@/services/timeline";
+import { getTimelinePostUpdate$ } from "~src/services/timeline";
-import TimelinePostListView from "./TimelinePostListView";
-import TimelineEmptyItem from "./TimelineEmptyItem";
-import TimelineLoading from "./TimelineLoading";
-import TimelinePostEdit from "./TimelinePostEdit";
-import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin";
-import TimelineCard from "./TimelineCard";
+import { useScrollToBottom } from "~src/components/hooks";
+
+import TimelinePostList from "./TimelinePostList";
+import TimelineInfoCard from "./TimelineInfoCard";
+import TimelinePostEdit from "./edit/TimelinePostCreateView";
import "./Timeline.css";
export interface TimelineProps {
className?: string;
- style?: React.CSSProperties;
timelineOwner: string;
timelineName: string;
}
-const Timeline: React.FC<TimelineProps> = (props) => {
- const { timelineOwner, timelineName, className, style } = props;
-
- const user = useUser();
+export function Timeline(props: TimelineProps) {
+ const { timelineOwner, timelineName, className } = props;
- const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
- const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null);
- const [signalrState, setSignalrState] = React.useState<HubConnectionState>(
- HubConnectionState.Connecting
+ const [timeline, setTimeline] = useState<HttpTimelineInfo | null>(null);
+ const [posts, setPosts] = useState<HttpTimelinePostInfo[] | null>(null);
+ const [signalrState, setSignalrState] = useState<HubConnectionState>(
+ HubConnectionState.Connecting,
);
- const [error, setError] = React.useState<
+ const [error, setError] = useState<
"offline" | "forbid" | "notfound" | "error" | null
>(null);
- const [currentPage, setCurrentPage] = React.useState(1);
- const [totalPage, setTotalPage] = React.useState(0);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPage, setTotalPage] = useState(0);
- const [timelineReloadKey, setTimelineReloadKey] = React.useState(0);
- const [postsReloadKey, setPostsReloadKey] = React.useState(0);
+ const [timelineReloadKey, setTimelineReloadKey] = useState(0);
+ const [postsReloadKey, setPostsReloadKey] = useState(0);
const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1);
const updatePosts = (): void => setPostsReloadKey((o) => o + 1);
- React.useEffect(() => {
+ useEffect(() => {
setTimeline(null);
setPosts(null);
setError(null);
setSignalrState(HubConnectionState.Connecting);
}, [timelineOwner, timelineName]);
- React.useEffect(() => {
+ useEffect(() => {
getHttpTimelineClient()
.getTimeline(timelineOwner, timelineName)
.then(
@@ -81,17 +75,17 @@ const Timeline: React.FC<TimelineProps> = (props) => {
console.error(error);
setError("error");
}
- }
+ },
);
}, [timelineOwner, timelineName, timelineReloadKey]);
- React.useEffect(() => {
+ useEffect(() => {
getHttpTimelineClient()
.listPost(timelineOwner, timelineName, 1)
.then(
(page) => {
setPosts(
- page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted)
+ page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted),
);
setTotalPage(page.totalPageCount);
},
@@ -106,14 +100,14 @@ const Timeline: React.FC<TimelineProps> = (props) => {
console.error(error);
setError("error");
}
- }
+ },
);
}, [timelineOwner, timelineName, postsReloadKey]);
- React.useEffect(() => {
+ useEffect(() => {
const timelinePostUpdate$ = getTimelinePostUpdate$(
timelineOwner,
- timelineName
+ timelineName,
);
const subscription = timelinePostUpdate$.subscribe(({ update, state }) => {
if (update) {
@@ -134,7 +128,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
.then(
(page) => {
const ps = page.items.filter(
- (p): p is HttpTimelinePostInfo => !p.deleted
+ (p): p is HttpTimelinePostInfo => !p.deleted,
);
setPosts((old) => [...(old ?? []), ...ps]);
},
@@ -149,59 +143,38 @@ const Timeline: React.FC<TimelineProps> = (props) => {
console.error(error);
setError("error");
}
- }
+ },
);
}, currentPage < totalPage);
if (error === "offline") {
- return (
- <div className={className} style={style}>
- Offline.
- </div>
- );
+ return <div className={className}>Offline.</div>;
} else if (error === "notfound") {
- return (
- <div className={className} style={style}>
- Not exist.
- </div>
- );
+ return <div className={className}>Not exist.</div>;
} else if (error === "forbid") {
- return (
- <div className={className} style={style}>
- Forbid.
- </div>
- );
+ return <div className={className}>Forbid.</div>;
} else if (error === "error") {
- return (
- <div className={className} style={style}>
- Error.
- </div>
- );
+ return <div className={className}>Error.</div>;
}
return (
- <>
- {timeline == null && posts == null && <TimelineLoading />}
+ <div className="timeline-container">
{timeline && (
- <TimelineCard
- className="timeline-card"
+ <TimelineInfoCard
timeline={timeline}
connectionStatus={signalrState}
onReload={updateTimeline}
/>
)}
{posts && (
- <div style={style} className={classnames("timeline", className)}>
- <TimelineEmptyItem className="timeline-top" height={50} />
- {timeline?.postable ? (
+ <div className={classNames("timeline", className)}>
+ {timeline?.postable && (
<TimelinePostEdit timeline={timeline} onPosted={updatePosts} />
- ) : user == null ? (
- <TimelinePostEditNoLogin />
- ) : null}
- <TimelinePostListView posts={posts} onReload={updatePosts} />
+ )}
+ <TimelinePostList posts={posts} onReload={updatePosts} />
</div>
)}
- </>
+ </div>
);
-};
+}
export default Timeline;
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.css b/FrontEnd/src/pages/timeline/TimelineDateLabel.css
new file mode 100644
index 00000000..47a4cb44
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.css
@@ -0,0 +1,9 @@
+.timeline-post-date-badge {
+ display: inline-block;
+ padding: 0.2em 0.5em;
+ border-radius: 0.4em;
+ background: var(--timeline-datetime-label-background-color);
+ color: white;
+ font-size: 0.8em;
+ margin-left: 5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
new file mode 100644
index 00000000..eaadcc1a
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
@@ -0,0 +1,13 @@
+import TimelinePostContainer from "./TimelinePostContainer";
+
+import "./TimelineDateLabel.css";
+
+export default function TimelineDateLabel({ date }: { date: Date }) {
+ return (
+ <TimelinePostContainer>
+ <div className="timeline-post-date-badge">
+ {date.toLocaleDateString()}
+ </div>
+ </TimelinePostContainer>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..d1af364b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,54 @@
+import { useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
+
+import { OperationDialog } from "~src/components/dialog";
+
+interface TimelineDeleteDialog {
+ timeline: HttpTimelineInfo;
+}
+
+export default function TimelineDeleteDialog({ timeline }: TimelineDeleteDialog) {
+ const navigate = useNavigate();
+
+ return (
+ <OperationDialog
+ title="timeline.deleteDialog.title"
+ color="danger"
+ inputPromptNode={
+ <Trans
+ i18nKey="timeline.deleteDialog.inputPrompt"
+ values={{ name: timeline.nameV2 }}
+ >
+ 0<code>1</code>2
+ </Trans>
+ }
+ inputs={{
+ inputs: [
+ {
+ key: "name",
+ type: "text",
+ label: "",
+ },
+ ],
+ validator: ({ name }, errors) => {
+ if (name !== timeline.nameV2) {
+ errors.name = "timeline.deleteDialog.notMatch";
+ }
+ },
+ }}
+ onProcess={() => {
+ return getHttpTimelineClient().deleteTimeline(
+ timeline.owner.username,
+ timeline.nameV2,
+ );
+ }}
+ onSuccessAndClose={() => {
+ navigate("/", { replace: true });
+ }}
+ />
+ );
+};
+
+TimelineDeleteDialog;
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
new file mode 100644
index 00000000..afcb6409
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
@@ -0,0 +1,63 @@
+.timeline-card {
+ position: fixed;
+ z-index: 1029;
+ top: 56px;
+ right: 0;
+ margin: 0.5em;
+ padding: 0.5em;
+ box-shadow: var(--timeline-card-shadow);
+}
+
+@media (min-width: 576px) {
+ .timeline-card-expand {
+ min-width: 400px;
+ }
+}
+
+.timeline-card-title {
+ display: inline-block;
+ vertical-align: middle;
+ color: var(--cru-text-major-color);
+ margin: 0.5em 1em;
+}
+
+.timeline-card-title-name {
+ margin-inline-start: 1em;
+ color: var(--cru-text-minor-color);
+}
+
+.timeline-card-user {
+ display: flex;
+ align-items: center;
+ margin: 0 1em 0.5em;
+}
+
+.timeline-card-user-avatar {
+ width: 2em;
+ height: 2em;
+ border-radius: 50%;
+}
+
+.timeline-card-user-nickname {
+ margin-inline: 0.6em;
+}
+
+.timeline-card-description {
+ margin: 0 1em 0.5em;
+}
+
+.timeline-card-top-right-area {
+ float: right;
+ display: flex;
+ align-items: center;
+ margin: 0 1em;
+}
+
+.timeline-card-buttons {
+ display: flex;
+ justify-content: end;
+}
+
+.timeline-card-button {
+ margin: 0 0.2em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..2bc40877
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,208 @@
+import { useState } from "react";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { useUser } from "~src/services/user";
+
+import { HttpTimelineInfo } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
+
+import { pushAlert } from "~src/components/alert";
+import { useMobile } from "~src/components/hooks";
+import { IconButton } from "~src/components/button";
+import {
+ Dialog,
+ FullPageDialog,
+ DialogProvider,
+ useDialog,
+} from "~src/components/dialog";
+import UserAvatar from "~src/components/user/UserAvatar";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import Card from "~src/components/Card";
+
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import TimelineMember from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+
+import "./TimelineInfoCard.css";
+
+function CollapseButton({
+ collapse,
+ onClick,
+ className,
+}: {
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+}) {
+ return (
+ <IconButton
+ color="primary"
+ icon={collapse ? "info-circle" : "x-circle"}
+ onClick={onClick}
+ className={className}
+ />
+ );
+}
+
+interface TimelineInfoCardProps {
+ timeline: HttpTimelineInfo;
+ connectionStatus: HubConnectionState;
+ onReload: () => void;
+}
+
+function TimelineInfoContent({
+ timeline,
+ onReload,
+}: Omit<TimelineInfoCardProps, "connectionStatus">) {
+ const user = useUser();
+
+ const { controller, createDialogSwitch } = useDialog({
+ member: (
+ <Dialog>
+ <TimelineMember timeline={timeline} onChange={onReload} />
+ </Dialog>
+ ),
+ property: (
+ <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} />
+ ),
+ delete: <TimelineDeleteDialog timeline={timeline} />,
+ });
+
+ return (
+ <div>
+ <h3 className="timeline-card-title">
+ {timeline.title}
+ <small className="timeline-card-title-name">{timeline.nameV2}</small>
+ </h3>
+ <div className="timeline-card-user">
+ <UserAvatar
+ username={timeline.owner.username}
+ className="timeline-card-user-avatar"
+ />
+ <span className="timeline-card-user-nickname">
+ {timeline.owner.nickname}
+ </span>
+ <small className="timeline-card-user-username">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="timeline-card-description">{timeline.description}</p>
+ <div className="timeline-card-buttons">
+ {user && (
+ <IconButton
+ icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
+ color="primary"
+ className="timeline-card-button"
+ onClick={() => {
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "post"](
+ user.username,
+ timeline.owner.username,
+ timeline.nameV2,
+ )
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ color: "danger",
+ });
+ });
+ }}
+ />
+ )}
+ <IconButton
+ icon="people"
+ color="primary"
+ className="timeline-card-button"
+ onClick={createDialogSwitch("member")}
+ />
+ {timeline.manageable && (
+ <PopupMenu
+ items={[
+ {
+ type: "button",
+ text: "timeline.manageItem.property",
+ onClick: createDialogSwitch("property"),
+ },
+ { type: "divider" },
+ {
+ type: "button",
+ onClick: createDialogSwitch("delete"),
+ color: "danger",
+ text: "timeline.manageItem.delete",
+ },
+ ]}
+ containerClassName="d-inline"
+ >
+ <IconButton
+ color="primary"
+ className="timeline-card-button"
+ icon="three-dots-vertical"
+ />
+ </PopupMenu>
+ )}
+ </div>
+ <DialogProvider controller={controller} />
+ </div>
+ );
+}
+
+export default function TimelineInfoCard(props: TimelineInfoCardProps) {
+ const { timeline, connectionStatus, onReload } = props;
+
+ const [collapse, setCollapse] = useState(true);
+
+ const isMobile = useMobile((mobile) => {
+ if (!mobile) {
+ switchDialog(null);
+ } else {
+ setCollapse(true);
+ }
+ });
+
+ const { controller, switchDialog } = useDialog(
+ {
+ "full-page": (
+ <FullPageDialog>
+ <TimelineInfoContent timeline={timeline} onReload={onReload} />
+ </FullPageDialog>
+ ),
+ },
+ {
+ onClose: {
+ "full-page": () => {
+ setCollapse(true);
+ },
+ },
+ },
+ );
+
+ return (
+ <Card
+ color="secondary"
+ className={`timeline-card timeline-card-${
+ collapse ? "collapse" : "expand"
+ }`}
+ >
+ <div className="timeline-card-top-right-area">
+ <ConnectionStatusBadge status={connectionStatus} />
+ <CollapseButton
+ collapse={collapse}
+ onClick={() => {
+ const open = collapse;
+ setCollapse(!open);
+ if (isMobile && open) {
+ switchDialog("full-page");
+ }
+ }}
+ />
+ </div>
+ {!collapse && !isMobile && (
+ <TimelineInfoContent timeline={timeline} onReload={onReload} />
+ )}
+ <DialogProvider controller={controller} />
+ </Card>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css
new file mode 100644
index 00000000..3ad74c57
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.css
@@ -0,0 +1,20 @@
+.timeline-member-item {
+ align-items: center;
+ display: flex;
+ padding: 0.6em;
+}
+
+.timeline-member-avatar {
+ height: 50px;
+ width: 50px;
+ border-radius: 50%;
+}
+
+.timeline-member-info {
+ margin-left: 1em;
+ margin-right: auto;
+}
+
+.timeline-member-user-search {
+ margin-top: 1em;
+}
diff --git a/FrontEnd/src/views/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx
index aaafd173..0812016f 100644
--- a/FrontEnd/src/views/timeline/TimelineMember.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx
@@ -1,55 +1,59 @@
import { useState } from "react";
-import * as React from "react";
import { useTranslation } from "react-i18next";
-import { convertI18nText, I18nText } from "@/common";
+import { convertI18nText, I18nText } from "~src/common";
-import { HttpUser } from "@/http/user";
-import { getHttpSearchClient } from "@/http/search";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { HttpUser } from "~src/http/user";
+import { getHttpSearchClient } from "~src/http/search";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
-import SearchInput from "../common/SearchInput";
-import UserAvatar from "../common/user/UserAvatar";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
+import SearchInput from "~src/components/SearchInput";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { IconButton } from "~src/components/button";
+import { ListContainer, ListItemContainer } from "~src/components/list";
import "./TimelineMember.css";
-const TimelineMemberItem: React.FC<{
+function TimelineMemberItem({
+ user,
+ add,
+ onAction,
+}: {
user: HttpUser;
add?: boolean;
onAction?: (username: string) => void;
-}> = ({ user, add, onAction }) => {
+}) {
return (
- <div className="container timeline-member-item">
- <div className="row">
- <div className="col col-auto">
- <UserAvatar username={user.username} className="cru-avatar small" />
- </div>
- <div className="col">
- <div className="row">{user.nickname}</div>
- <small className="row">{"@" + user.username}</small>
- </div>
- {onAction ? (
- <div className="col col-auto">
- <Button
- text={`timeline.member.${add ? "add" : "remove"}`}
- color={add ? "success" : "danger"}
- onClick={() => {
- onAction(user.username);
- }}
- />
- </div>
- ) : null}
+ <ListItemContainer className="timeline-member-item">
+ <UserAvatar username={user.username} className="timeline-member-avatar" />
+ <div className="timeline-member-info">
+ <div className="timeline-member-nickname">{user.nickname}</div>
+ <small className="timeline-member-username">
+ {"@" + user.username}
+ </small>
</div>
- </div>
+ {onAction ? (
+ <div className="timeline-member-action">
+ <IconButton
+ icon={add ? "plus-lg" : "trash"}
+ color={add ? "create" : "danger"}
+ onClick={() => {
+ onAction(user.username);
+ }}
+ />
+ </div>
+ ) : null}
+ </ListItemContainer>
);
-};
+}
-const TimelineMemberUserSearch: React.FC<{
+function TimelineMemberUserSearch({
+ timeline,
+ onChange,
+}: {
timeline: HttpTimelineInfo;
onChange: () => void;
-}> = ({ timeline, onChange }) => {
+}) {
const { t } = useTranslation();
const [userSearchText, setUserSearchText] = useState<string>("");
@@ -64,9 +68,9 @@ const TimelineMemberUserSearch: React.FC<{
>({ type: "init" });
return (
- <>
+ <div className="timeline-member-user-search">
<SearchInput
- className="mt-3"
+ className=""
value={userSearchText}
onChange={(v) => {
setUserSearchText(v);
@@ -88,8 +92,8 @@ const TimelineMemberUserSearch: React.FC<{
users = users.filter(
(user) =>
timeline.members.findIndex(
- (m) => m.username === user.username
- ) === -1 && timeline.owner.username !== user.username
+ (m) => m.username === user.username,
+ ) === -1 && timeline.owner.username !== user.username,
);
setUserSearchState({ type: "users", data: users });
},
@@ -98,7 +102,7 @@ const TimelineMemberUserSearch: React.FC<{
type: "error",
data: { type: "custom", value: String(e) },
});
- }
+ },
);
}}
/>
@@ -109,7 +113,7 @@ const TimelineMemberUserSearch: React.FC<{
return <div>{t("timeline.member.noUserAvailableToAdd")}</div>;
} else {
return (
- <div className="mt-2">
+ <div className="">
{users.map((user) => (
<TimelineMemberItem
key={user.username}
@@ -120,7 +124,7 @@ const TimelineMemberUserSearch: React.FC<{
.memberPut(
timeline.owner.username,
timeline.nameV2,
- user.username
+ user.username,
)
.then(() => {
setUserSearchText("");
@@ -141,22 +145,22 @@ const TimelineMemberUserSearch: React.FC<{
);
}
})()}
- </>
+ </div>
);
-};
+}
-export interface TimelineMemberProps {
+interface TimelineMemberProps {
timeline: HttpTimelineInfo;
onChange: () => void;
}
-const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
+export default function TimelineMember(props: TimelineMemberProps) {
const { timeline, onChange } = props;
const members = [timeline.owner, ...timeline.members];
return (
<div className="container px-4 py-3">
- <div>
+ <ListContainer>
{members.map((member, index) => (
<TimelineMemberItem
key={member.username}
@@ -168,7 +172,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
.memberDelete(
timeline.owner.username,
timeline.nameV2,
- member.username
+ member.username,
)
.then(onChange);
}
@@ -176,27 +180,10 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
}
/>
))}
- </div>
+ </ListContainer>
{timeline.manageable ? (
<TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
) : null}
</div>
);
-};
-
-export default TimelineMember;
-
-export interface TimelineMemberDialogProps extends TimelineMemberProps {
- open: boolean;
- onClose: () => void;
}
-
-export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
- props
-) => {
- return (
- <Dialog open={props.open} onClose={props.onClose}>
- <TimelineMember {...props} />
- </Dialog>
- );
-};
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.css b/FrontEnd/src/pages/timeline/TimelinePostCard.css
new file mode 100644
index 00000000..f60610c0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.css
@@ -0,0 +1,9 @@
+.timeline-post-card {
+ padding: 1em 1em 1em 3em;
+ background-color: var(--timeline-post-card-background-color);
+ box-shadow: var(--timeline-post-card-shadow);
+ border-radius: var(--timeline-post-card-border-radius);
+ border: none;
+ position: relative;
+ z-index: 1;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
new file mode 100644
index 00000000..d3fd3215
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from "react";
+import classNames from "classnames";
+
+import Card from "~src/components/Card";
+
+import "./TimelinePostCard.css";
+
+interface TimelinePostCardProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+export default function TimelinePostCard({
+ className,
+ children,
+}: TimelinePostCardProps) {
+ return (
+ <Card color="primary" className={classNames("timeline-post-card", className)}>
+ {children}
+ </Card>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.css b/FrontEnd/src/pages/timeline/TimelinePostContainer.css
new file mode 100644
index 00000000..a12f70b1
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.css
@@ -0,0 +1,3 @@
+.timeline-post-container {
+ padding: 0.5em 1em;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx
new file mode 100644
index 00000000..9dc211b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from "react";
+import classNames from "classnames";
+
+import "./TimelinePostContainer.css";
+
+interface TimelinePostContainerProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+export default function TimelinePostContainer({
+ className,
+ children,
+}: TimelinePostContainerProps) {
+ return (
+ <div className={classNames("timeline-post-container", className)}>
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.css b/FrontEnd/src/pages/timeline/TimelinePostList.css
new file mode 100644
index 00000000..bd575554
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.css
@@ -0,0 +1,10 @@
+.timeline-post-timeline {
+ position: absolute;
+ left: 2.5em;
+ width: 1em;
+ top: 0;
+ bottom: 0;
+ background-color: var(--timeline-post-line-color);
+ box-shadow: var(--timeline-post-line-shadow);
+ z-index: -1;
+} \ No newline at end of file
diff --git a/FrontEnd/src/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
index f878b004..66262ccd 100644
--- a/FrontEnd/src/views/timeline/TimelinePostListView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
@@ -1,11 +1,12 @@
-import { Fragment } from "react";
-import * as React from "react";
+import { useMemo, Fragment } from "react";
-import { HttpTimelinePostInfo } from "@/http/timeline";
+import { HttpTimelinePostInfo } from "~src/http/timeline";
import TimelinePostView from "./TimelinePostView";
import TimelineDateLabel from "./TimelineDateLabel";
+import "./TimelinePostList.css";
+
function dateEqual(left: Date, right: Date): boolean {
return (
left.getDate() == right.getDate() &&
@@ -14,15 +15,15 @@ function dateEqual(left: Date, right: Date): boolean {
);
}
-export interface TimelinePostListViewProps {
+interface TimelinePostListViewProps {
posts: HttpTimelinePostInfo[];
onReload: () => void;
}
-const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
+export default function TimelinePostList(props: TimelinePostListViewProps) {
const { posts, onReload } = props;
- const groupedPosts = React.useMemo<
+ const groupedPosts = useMemo<
{
date: Date;
posts: (HttpTimelinePostInfo & { index: number })[];
@@ -51,7 +52,7 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
}, [posts]);
return (
- <>
+ <div>
{groupedPosts.map((group) => {
return (
<Fragment key={group.date.toDateString()}>
@@ -69,8 +70,6 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
</Fragment>
);
})}
- </>
+ </div>
);
-};
-
-export default TimelinePostListView;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.css b/FrontEnd/src/pages/timeline/TimelinePostView.css
new file mode 100644
index 00000000..a8db46bf
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.css
@@ -0,0 +1,37 @@
+.timeline-post-header {
+ display: flex;
+ align-items: center;
+}
+
+.timeline-post-author-avatar {
+ border-radius: 50%;
+ width: 2em;
+ height: 2em;
+}
+
+.timeline-post-author-nickname {
+ margin: 0 1em;
+}
+
+.timeline-post-edit-button {
+ float: right;
+}
+
+.timeline-post-options-mask {
+ position: absolute;
+ inset: 0;
+ background-color: hsla(0, 0%, 100%, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+}
+
+@media (prefers-color-scheme: dark) {
+ .timeline-post-options-mask {
+ background-color: hsla(0, 0%, 0%, 0.8);
+ }
+}
+
+.timeline-post-content {
+ margin-top: 0.5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
new file mode 100644
index 00000000..4f0460ff
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelinePostInfo,
+} from "~src/http/timeline";
+
+import { pushAlert } from "~src/components/alert";
+import { useClickOutside } from "~src/components/hooks";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { DialogProvider, useDialog } from "~src/components/dialog";
+import FlatButton from "~src/components/button/FlatButton";
+import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
+import TimelinePostContentView from "./view/TimelinePostContentView";
+import IconButton from "~src/components/button/IconButton";
+
+import TimelinePostContainer from "./TimelinePostContainer";
+import TimelinePostCard from "./TimelinePostCard";
+
+import "./TimelinePostView.css";
+
+interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ onChanged: (post: HttpTimelinePostInfo) => void;
+ onDeleted: () => void;
+}
+
+export default function TimelinePostView(props: TimelinePostViewProps) {
+ const { post, onDeleted } = props;
+
+ const [operationMaskVisible, setOperationMaskVisible] =
+ useState<boolean>(false);
+
+ const { controller, switchDialog } = useDialog(
+ {
+ delete: (
+ <ConfirmDialog
+ title="timeline.post.deleteDialog.title"
+ body="timeline.post.deleteDialog.prompt"
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.deletePostFailed",
+ });
+ });
+ }}
+ />
+ ),
+ },
+ {
+ onClose: {
+ delete: () => {
+ setOperationMaskVisible(false);
+ },
+ },
+ },
+ );
+
+ const [maskElement, setMaskElement] = useState<HTMLElement | null>(null);
+ useClickOutside(maskElement, () => setOperationMaskVisible(false));
+
+ return (
+ <TimelinePostContainer>
+ <TimelinePostCard className="cru-primary">
+ {post.editable && (
+ <IconButton
+ color="primary"
+ icon="chevron-down"
+ className="timeline-post-edit-button"
+ onClick={(e) => {
+ setOperationMaskVisible(true);
+ e.stopPropagation();
+ }}
+ />
+ )}
+ <div className="timeline-post-header">
+ <UserAvatar
+ username={post.author.username}
+ className="timeline-post-author-avatar"
+ />
+ <small className="timeline-post-author-nickname">
+ {post.author.nickname}
+ </small>
+ <small className="timeline-post-time">
+ {new Date(post.time).toLocaleTimeString()}
+ </small>
+ </div>
+ <div className="timeline-post-content">
+ <TimelinePostContentView post={post} />
+ </div>
+ {operationMaskVisible ? (
+ <div
+ ref={setMaskElement}
+ className="timeline-post-options-mask"
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
+ >
+ <FlatButton
+ text="changeProperty"
+ onClick={(e) => {
+ e.stopPropagation();
+ }}
+ />
+ <FlatButton
+ text="delete"
+ color="danger"
+ onClick={(e) => {
+ switchDialog("delete");
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ ) : null}
+ </TimelinePostCard>
+ <DialogProvider controller={controller} />
+ </TimelinePostContainer>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..79838d58
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,79 @@
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ kTimelineVisibilities,
+ TimelineVisibility,
+} from "~src/http/timeline";
+
+import OperationDialog from "~src/components/dialog/OperationDialog";
+
+interface TimelinePropertyChangeDialogProps {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: "timeline.visibility.private",
+ Public: "timeline.visibility.public",
+ Register: "timeline.visibility.register",
+};
+
+export default function TimelinePropertyChangeDialog({
+ timeline,
+ onChange,
+}: TimelinePropertyChangeDialogProps) {
+ return (
+ <OperationDialog
+ title={"timeline.dialogChangeProperty.title"}
+ inputs={{
+ scheme: {
+ inputs: [
+ {
+ key: "title",
+ type: "text",
+ label: "timeline.dialogChangeProperty.titleField",
+ },
+ {
+ key: "visibility",
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
+ },
+ {
+ key: "description",
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ },
+ ],
+ },
+ dataInit: {
+ values: {
+ title: timeline.title,
+ visibility: timeline.visibility,
+ description: timeline.description,
+ },
+ },
+ }}
+ onProcess={({ title, visibility, description }) => {
+ const req: HttpTimelinePatchRequest = {};
+ if (title !== timeline.title) {
+ req.title = title;
+ }
+ if (visibility !== timeline.visibility) {
+ req.visibility = visibility;
+ }
+ if (description !== timeline.description) {
+ req.description = description;
+ }
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.owner.username, timeline.nameV2, req)
+ .then(onChange);
+ }}
+ />
+ );
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
new file mode 100644
index 00000000..232681c8
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -0,0 +1,5 @@
+.timeline-edit-image-image {
+ max-width: 100px;
+ max-height: 100px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
new file mode 100644
index 00000000..c62c8ee5
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -0,0 +1,36 @@
+import classNames from "classnames";
+
+import BlobImage from "~/src/components/BlobImage";
+
+import "./ImagePostEdit.css";
+
+interface TimelinePostEditImageProps {
+ file: File | null;
+ onChange: (file: File | null) => void;
+ disabled: boolean;
+ className?: string;
+}
+
+export default function ImagePostEdit(props: TimelinePostEditImageProps) {
+ const { file, onChange, disabled, className } = props;
+
+ return (
+ <div className={classNames("timeline-edit-image-container", className)}>
+ <input
+ type="file"
+ accept="image/*"
+ disabled={disabled}
+ onChange={(e) => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ onChange(null);
+ } else {
+ onChange(files[0]);
+ }
+ }}
+ className="timeline-edit-image-input"
+ />
+ {file && <BlobImage src={file} className="timeline-edit-image-image" />}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
new file mode 100644
index 00000000..c5b41b40
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
@@ -0,0 +1,24 @@
+.timeline-edit-markdown-tab-page {
+ min-height: 8em;
+ display: flex;
+}
+
+.timeline-edit-markdown-text {
+ width: 100%;
+}
+
+.timeline-edit-markdown-images {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.timeline-edit-markdown-images img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-edit-markdown-preview img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..36a5572b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from "react";
+import classnames from "classnames";
+import { marked } from "marked";
+
+import { HttpTimelinePostPostRequestData } from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { array } from "~src/components/common";
+import { TabPages } from "~src/components/tab";
+import { IconButton } from "~src/components/button";
+import BlobImage from "~src/components/BlobImage";
+
+import "./MarkdownPostEdit.css";
+
+class MarkedRenderer extends marked.Renderer {
+ constructor(public images: string[]) {
+ super();
+ }
+
+ // Custom image parser for indexed image link.
+ image(href: string, title: string | null, text: string): string {
+ const i = parseInt(href);
+ if (!isNaN(i) && i > 0 && i <= this.images.length) {
+ href = this.images[i - 1];
+ }
+
+ return super.image(href, title, text);
+ }
+}
+
+function generateMarkedOptions(imageUrls: string[]) {
+ return {
+ renderer: new MarkedRenderer(imageUrls),
+ async: false,
+ } as const;
+}
+
+function renderHtml(text: string, imageUrls: string[]): string {
+ return marked.parse(text, generateMarkedOptions(imageUrls));
+}
+
+async function build(
+ text: string,
+ images: File[],
+): Promise<HttpTimelinePostPostRequestData[]> {
+ return [
+ {
+ contentType: "text/markdown",
+ data: await base64(text),
+ },
+ ...(await Promise.all(
+ images.map(async (image) => {
+ const data = await base64(image);
+ return { contentType: image.type, data };
+ }),
+ )),
+ ];
+}
+
+export function useMarkdownEdit(disabled: boolean): {
+ hasContent: boolean;
+ clear: () => void;
+ build: () => Promise<HttpTimelinePostPostRequestData[]>;
+ markdownEditProps: Omit<MarkdownPostEditProps, "className">;
+} {
+ const [text, setText] = useState<string>("");
+ const [images, setImages] = useState<File[]>([]);
+
+ return {
+ hasContent: text !== "" || images.length !== 0,
+ clear: () => {
+ setText("");
+ setImages([]);
+ },
+ build: () => {
+ return build(text, images);
+ },
+ markdownEditProps: {
+ disabled,
+ text,
+ images,
+ onTextChange: setText,
+ onImageAppend: (image) => setImages(array.copy_push(images, image)),
+ onImageMove: (o, n) => setImages(array.copy_move(images, o, n)),
+ onImageDelete: (i) => setImages(array.copy_delete(images, i)),
+ },
+ };
+}
+
+function MarkdownPreview({ text, images }: { text: string; images: File[] }) {
+ const [html, setHtml] = useState("");
+
+ useEffect(() => {
+ const imageUrls = images.map((image) => URL.createObjectURL(image));
+
+ setHtml(renderHtml(text, imageUrls));
+
+ return () => {
+ imageUrls.forEach((url) => URL.revokeObjectURL(url));
+ };
+ }, [text, images]);
+
+ return (
+ <div
+ className="timeline-edit-markdown-preview"
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ );
+}
+
+interface MarkdownPostEditProps {
+ disabled: boolean;
+ text: string;
+ images: File[];
+ onTextChange: (text: string) => void;
+ onImageAppend: (image: File) => void;
+ onImageMove: (oldIndex: number, newIndex: number) => void;
+ onImageDelete: (index: number) => void;
+ className?: string;
+}
+
+export function MarkdownPostEdit({
+ disabled,
+ text,
+ images,
+ onTextChange,
+ onImageAppend,
+ // onImageMove,
+ onImageDelete,
+ className,
+}: MarkdownPostEditProps) {
+ return (
+ <TabPages
+ className={className}
+ pageContainerClassName="timeline-edit-markdown-tab-page"
+ dense
+ pages={[
+ {
+ name: "text",
+ text: "edit",
+ page: (
+ <textarea
+ value={text}
+ disabled={disabled}
+ className="timeline-edit-markdown-text"
+ onChange={(event) => {
+ onTextChange(event.currentTarget.value);
+ }}
+ />
+ ),
+ },
+ {
+ name: "images",
+ text: "image",
+ page: (
+ <div className="timeline-edit-markdown-images">
+ {images.map((image, index) => (
+ <div
+ key={image.name}
+ className="timeline-edit-markdown-image-container"
+ >
+ <BlobImage src={image} />
+ <IconButton
+ icon="trash"
+ color="danger"
+ className={classnames(
+ "timeline-edit-markdown-image-delete",
+ process && "d-none",
+ )}
+ onClick={() => {
+ onImageDelete(index);
+ }}
+ />
+ </div>
+ ))}
+ <input
+ type="file"
+ accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+ const { files } = event.currentTarget;
+ if (files != null && files.length !== 0) {
+ onImageAppend(files[0]);
+ }
+ }}
+ disabled={disabled}
+ />
+ </div>
+ ),
+ },
+ {
+ name: "preview",
+ text: "preview",
+ page: <MarkdownPreview text={text} images={images} />,
+ },
+ ]}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
new file mode 100644
index 00000000..d1a61793
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -0,0 +1,12 @@
+.timeline-edit-plain-text-container {
+ width: 100%;
+ height: 100%;
+}
+
+.timeline-edit-plain-text-input {
+ width: 100%;
+ height: 100%;
+ padding: 0.5em;
+ border-radius: 4px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
new file mode 100644
index 00000000..7f3663b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
@@ -0,0 +1,29 @@
+import classNames from "classnames";
+
+import "./PlainTextPostEdit.css";
+
+interface TimelinePostEditTextProps {
+ text: string;
+ disabled: boolean;
+ onChange: (text: string) => void;
+ className?: string;
+}
+
+export default function TimelinePostEditText(props: TimelinePostEditTextProps) {
+ const { text, disabled, onChange, className } = props;
+
+ return (
+ <div
+ className={classNames("timeline-edit-plain-text-container", className)}
+ >
+ <textarea
+ value={text}
+ disabled={disabled}
+ onChange={(event) => {
+ onChange(event.target.value);
+ }}
+ className={classNames("timeline-edit-plain-text-input")}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
new file mode 100644
index 00000000..6efe93e9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
@@ -0,0 +1,35 @@
+.timeline-post-create-card {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+ margin-right: 200px;
+}
+
+@media (max-width: 576px) {
+ .timeline-post-create-container {
+ padding-top: 60px;
+ }
+
+ .timeline-post-create-card {
+ margin-right: 0;
+ }
+}
+
+.timeline-post-create {
+ display: flex;
+}
+
+.timeline-post-create-edit-area {
+ flex-grow: 1;
+}
+
+.timeline-post-create-right-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-left: 1em;
+}
+
+.timeline-post-create-send {
+ margin-top: auto;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
new file mode 100644
index 00000000..c0a80ad0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -0,0 +1,193 @@
+import { useState } from "react";
+import classNames from "classnames";
+
+import { UiLogicError } from "~src/common";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+ HttpTimelinePostPostRequestData,
+} from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { useC } from "~/src/components/common";
+import { pushAlert } from "~src/components/alert";
+import { IconButton, LoadingButton } from "~src/components/button";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import { useWindowLeave } from "~src/components/hooks";
+
+import TimelinePostCard from "../TimelinePostCard";
+import TimelinePostContainer from "../TimelinePostContainer";
+import PlainTextPostEdit from "./PlainTextPostEdit";
+import ImagePostEdit from "./ImagePostEdit";
+import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit";
+
+import "./TimelinePostCreateView.css";
+
+type PostKind = "text" | "markdown" | "image";
+
+const postKindIconMap: Record<PostKind, string> = {
+ text: "fonts",
+ markdown: "markdown",
+ image: "image",
+};
+
+export interface TimelinePostEditProps {
+ className?: string;
+ timeline: HttpTimelineInfo;
+ onPosted: (newPost: HttpTimelinePostInfo) => void;
+}
+
+function TimelinePostEdit(props: TimelinePostEditProps) {
+ const { timeline, className, onPosted } = props;
+
+ const c = useC();
+
+ const [process, setProcess] = useState<boolean>(false);
+
+ const [kind, setKind] = useState<PostKind>("text");
+
+ const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
+ const [text, setText] = useState<string>(
+ () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "",
+ );
+ const [image, setImage] = useState<File | null>(null);
+ const {
+ hasContent: mdHasContent,
+ build: mdBuild,
+ clear: mdClear,
+ markdownEditProps,
+ } = useMarkdownEdit(process);
+
+ useWindowLeave(!mdHasContent && !image);
+
+ const canSend =
+ (kind === "text" && text.length !== 0) ||
+ (kind === "image" && image != null) ||
+ (kind === "markdown" && mdHasContent);
+
+ const onPostError = (): void => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.sendPostFailed",
+ });
+ };
+
+ const onSend = async (): Promise<void> => {
+ setProcess(true);
+
+ let requestDataList: HttpTimelinePostPostRequestData[];
+ switch (kind) {
+ case "text":
+ requestDataList = [
+ {
+ contentType: "text/plain",
+ data: await base64(text),
+ },
+ ];
+ break;
+ case "image":
+ if (image == null) {
+ throw new UiLogicError();
+ }
+ requestDataList = [
+ {
+ contentType: image.type,
+ data: await base64(image),
+ },
+ ];
+ break;
+ case "markdown":
+ if (!mdHasContent) {
+ throw new UiLogicError();
+ }
+ requestDataList = await mdBuild();
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
+
+ try {
+ const res = await getHttpTimelineClient().postPost(
+ timeline.owner.username,
+ timeline.nameV2,
+ {
+ dataList: requestDataList,
+ },
+ );
+
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftTextLocalStorageKey);
+ } else if (kind === "image") {
+ setImage(null);
+ } else if (kind === "markdown") {
+ mdClear();
+ }
+ onPosted(res);
+ } catch (e) {
+ onPostError();
+ } finally {
+ setProcess(false);
+ }
+ };
+
+ return (
+ <TimelinePostContainer
+ className={classNames(className, "timeline-post-create-container")}
+ >
+ <TimelinePostCard className="timeline-post-create-card">
+ <div className="timeline-post-create">
+ <div className="timeline-post-create-edit-area">
+ {kind === "text" && (
+ <PlainTextPostEdit
+ text={text}
+ disabled={process}
+ onChange={(text) => {
+ setText(text);
+ window.localStorage.setItem(draftTextLocalStorageKey, text);
+ }}
+ />
+ )}
+ {kind === "image" && (
+ <ImagePostEdit
+ file={image}
+ onChange={setImage}
+ disabled={process}
+ />
+ )}
+ {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />}
+ </div>
+ <div className="timeline-post-create-right-area">
+ <PopupMenu
+ containerClassName="timeline-post-create-kind-select"
+ items={(["text", "image", "markdown"] as const).map((kind) => ({
+ type: "button",
+ text: `timeline.post.type.${kind}`,
+ iconClassName: postKindIconMap[kind],
+ onClick: () => {
+ setKind(kind);
+ },
+ }))}
+ >
+ <IconButton color="primary" icon={postKindIconMap[kind]} />
+ </PopupMenu>
+ <LoadingButton
+ className="timeline-post-create-send"
+ onClick={() => void onSend()}
+ color="primary"
+ disabled={!canSend}
+ loading={process}
+ >
+ {c("timeline.send")}
+ </LoadingButton>
+ </div>
+ </div>
+ </TimelinePostCard>
+ </TimelinePostContainer>
+ );
+}
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx
index 1dffdcc1..6cd1ded0 100644
--- a/FrontEnd/src/views/timeline/index.tsx
+++ b/FrontEnd/src/pages/timeline/index.tsx
@@ -1,11 +1,10 @@
-import * as React from "react";
import { useParams } from "react-router-dom";
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
import Timeline from "./Timeline";
-const TimelinePage: React.FC = () => {
+export default function TimelinePage() {
const { owner, timeline: timelineNameParam } = useParams();
if (owner == null || owner == "")
@@ -13,11 +12,5 @@ const TimelinePage: React.FC = () => {
const timeline = timelineNameParam || "self";
- return (
- <div className="container">
- <Timeline timelineOwner={owner} timelineName={timeline} />
- </div>
- );
+ return <Timeline timelineOwner={owner} timelineName={timeline} />;
};
-
-export default TimelinePage;
diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.css b/FrontEnd/src/pages/timeline/view/ImagePostView.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/ImagePostView.css
diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.tsx b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx
new file mode 100644
index 00000000..85179475
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from "react";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import "./ImagePostView.css";
+
+interface ImagePostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function ImagePostView({ post, className }: ImagePostViewProps) {
+ const [url, setUrl] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (post) {
+ setUrl(
+ getHttpTimelineClient().generatePostDataUrl(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ ),
+ );
+ } else {
+ setUrl(null);
+ }
+ }, [post]);
+
+ return (
+ <div className={classNames("timeline-view-image-container", className)}>
+ <img src={url ?? undefined} className="timeline-view-image" />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.css b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css
new file mode 100644
index 00000000..48a893eb
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css
@@ -0,0 +1,4 @@
+.timeline-view-markdown img {
+ max-width: 100%;
+ max-height: 200px;
+}
diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx
new file mode 100644
index 00000000..9bb9f980
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx
@@ -0,0 +1,59 @@
+import { useMemo, useState } from "react";
+import { marked } from "marked";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import { useAutoUnsubscribePromise } from "~src/components/hooks";
+import Skeleton from "~src/components/Skeleton";
+
+import "./MarkdownPostView.css";
+
+interface MarkdownPostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function MarkdownPostView({
+ post,
+ className,
+}: MarkdownPostViewProps) {
+ const [markdown, setMarkdown] = useState<string | null>(null);
+
+ useAutoUnsubscribePromise(
+ () => {
+ if (post) {
+ return getHttpTimelineClient().getPostDataAsString(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ );
+ }
+ },
+ setMarkdown,
+ [post],
+ );
+
+ const markdownHtml = useMemo<string | null>(() => {
+ if (markdown == null) return null;
+ return marked.parse(markdown, {
+ async: false,
+ });
+ }, [markdown]);
+
+ return (
+ <div className={classNames("timeline-view-markdown-container", className)}>
+ {markdownHtml == null ? (
+ <Skeleton />
+ ) : (
+ <div
+ className="timeline-view-markdown"
+ dangerouslySetInnerHTML={{ __html: markdownHtml }}
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.css b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css
diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx
new file mode 100644
index 00000000..b964187d
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx
@@ -0,0 +1,50 @@
+import { useState } from "react";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import Skeleton from "~src/components/Skeleton";
+import { useAutoUnsubscribePromise } from "~src/components/hooks";
+
+import "./PlainTextPostView.css";
+
+interface PlainTextPostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function PlainTextPostView({
+ post,
+ className,
+}: PlainTextPostViewProps) {
+ const [text, setText] = useState<string | null>(null);
+
+ useAutoUnsubscribePromise(
+ () => {
+ if (post) {
+ return getHttpTimelineClient().getPostDataAsString(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ );
+ }
+ },
+ setText,
+ [post],
+ );
+
+ return (
+ <div
+ className={classNames("timeline-view-plain-text-container", className)}
+ >
+ {text == null ? (
+ <Skeleton />
+ ) : (
+ <div className="timeline-view-plain-text">{text}</div>
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx
new file mode 100644
index 00000000..851a9a33
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx
@@ -0,0 +1,37 @@
+import ImagePostView from "./ImagePostView";
+import MarkdownPostView from "./MarkdownPostView";
+import PlainTextPostView from "./PlainTextPostView";
+
+import type { HttpTimelinePostInfo } from "~src/http/timeline";
+
+interface TimelinePostContentViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
+ "text/plain": PlainTextPostView,
+ "text/markdown": MarkdownPostView,
+ "image/png": ImagePostView,
+ "image/jpeg": ImagePostView,
+ "image/gif": ImagePostView,
+ "image/webp": ImagePostView,
+};
+
+export default function TimelinePostContentView({
+ post,
+ className,
+}: TimelinePostContentViewProps) {
+ if (post == null) {
+ return <div />;
+ }
+
+ const type = post.dataList[0].kind;
+
+ if (type in viewMap) {
+ const View = viewMap[type];
+ return <View post={post} className={className} />;
+ }
+
+ return <div>Unknown post type.</div>;
+}
diff --git a/FrontEnd/src/palette.ts b/FrontEnd/src/palette.ts
deleted file mode 100644
index d06f9b19..00000000
--- a/FrontEnd/src/palette.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import Color from "color";
-import { BehaviorSubject, Observable } from "rxjs";
-
-import refreshAnimation from "./utilities/refreshAnimation";
-
-function lightenBy(color: Color, ratio: number): Color {
- const lightness = color.lightness();
- return color.lightness(lightness + (100 - lightness) * ratio);
-}
-
-function darkenBy(color: Color, ratio: number): Color {
- const lightness = color.lightness();
- return color.lightness(lightness - lightness * ratio);
-}
-
-export interface PaletteColor {
- color: string;
- l1: string;
- l2: string;
- l3: string;
- d1: string;
- d2: string;
- d3: string;
- f1: string;
- f2: string;
- f3: string;
- r1: string;
- r2: string;
- r3: string;
- t: string;
- t1: string;
- t2: string;
- t3: string;
- [key: string]: string;
-}
-
-const paletteColorList = [
- "primary",
- "primary-enhance",
- "secondary",
- "danger",
- "success",
-] as const;
-
-export type PaletteColorType = (typeof paletteColorList)[number];
-
-export type Palette = Record<PaletteColorType, PaletteColor>;
-
-export function generatePaletteColor(color: string): PaletteColor {
- const c = Color(color);
- const light = c.lightness() > 60;
- const l1 = lightenBy(c, 0.1).rgb().toString();
- const l2 = lightenBy(c, 0.2).rgb().toString();
- const l3 = lightenBy(c, 0.3).rgb().toString();
- const d1 = darkenBy(c, 0.1).rgb().toString();
- const d2 = darkenBy(c, 0.2).rgb().toString();
- const d3 = darkenBy(c, 0.3).rgb().toString();
- const f1 = light ? l1 : d1;
- const f2 = light ? l2 : d2;
- const f3 = light ? l3 : d3;
- const r1 = light ? d1 : l1;
- const r2 = light ? d2 : l2;
- const r3 = light ? d3 : l3;
- const _t = light ? Color("black") : Color("white");
- const t = _t.rgb().toString();
- const _b = light ? lightenBy : darkenBy;
- const t1 = _b(_t, 0.1).rgb().toString();
- const t2 = _b(_t, 0.2).rgb().toString();
- const t3 = _b(_t, 0.3).rgb().toString();
-
- return {
- color: c.rgb().toString(),
- l1,
- l2,
- l3,
- d1,
- d2,
- d3,
- f1,
- f2,
- f3,
- r1,
- r2,
- r3,
- t,
- t1,
- t2,
- t3,
- };
-}
-
-export function generatePalette(options: {
- primary: string;
- primaryEnhance?: string;
- secondary?: string;
-}): Palette {
- const { primary, primaryEnhance, secondary } = options;
- const p = Color(primary);
- const pe =
- primaryEnhance == null
- ? lightenBy(p, 0.3).saturate(0.3)
- : Color(primaryEnhance);
- const s = secondary == null ? Color("gray") : Color(secondary);
-
- return {
- primary: generatePaletteColor(p.toString()),
- "primary-enhance": generatePaletteColor(pe.toString()),
- secondary: generatePaletteColor(s.toString()),
- danger: generatePaletteColor("red"),
- success: generatePaletteColor("green"),
- };
-}
-
-export function generatePaletteCSS(palette: Palette): string {
- const colors: [string, string][] = [];
- for (const colorType of paletteColorList) {
- const paletteColor = palette[colorType];
- for (const variant in paletteColor) {
- let key = `--cru-${colorType}`;
- if (variant !== "color") key += `-${variant}`;
- key += "-color";
- colors.push([key, paletteColor[variant]]);
- }
- }
-
- return `:root {${colors
- .map(([key, color]) => `${key} : ${color};`)
- .join("")}}`;
-}
-
-const paletteSubject: BehaviorSubject<Palette | null> =
- new BehaviorSubject<Palette | null>(
- generatePalette({ primary: "rgb(0, 123, 255)" })
- );
-
-export const palette$: Observable<Palette | null> =
- paletteSubject.asObservable();
-
-palette$.subscribe((palette) => {
- const styleTagId = "timeline-palette-css";
- if (palette != null) {
- let styleTag = document.getElementById(styleTagId);
- if (styleTag == null) {
- styleTag = document.createElement("style");
- styleTag.id = styleTagId;
- document.head.append(styleTag);
- }
- styleTag.innerHTML = generatePaletteCSS(palette);
- } else {
- const styleTag = document.getElementById(styleTagId);
- if (styleTag != null) {
- styleTag.parentElement?.removeChild(styleTag);
- }
- }
-
- refreshAnimation();
-});
-
-export function setPalette(palette: Palette): () => void {
- const old = paletteSubject.value;
-
- paletteSubject.next(palette);
-
- return () => {
- paletteSubject.next(old);
- };
-}
diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts
deleted file mode 100644
index 83d63abe..00000000
--- a/FrontEnd/src/services/TimelinePostBuilder.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { marked } from "marked";
-
-import { UiLogicError } from "@/common";
-
-import base64 from "@/utilities/base64";
-
-import { HttpTimelinePostPostRequest } from "@/http/timeline";
-
-class TimelinePostMarkedRenderer extends marked.Renderer {
- constructor(private _images: { file: File; url: string }[]) {
- super();
- }
-
- image(href: string | null, title: string | null, text: string): string {
- if (href != null) {
- const i = parseInt(href);
- if (!isNaN(i) && i > 0 && i <= this._images.length) {
- href = this._images[i - 1].url;
- }
- }
- return this.image(href, title, text);
- }
-}
-
-export default class TimelinePostBuilder {
- private _onChange: () => void;
- private _text = "";
- private _images: { file: File; url: string }[] = [];
- private _markedOptions: marked.MarkedOptions;
-
- constructor(onChange: () => void) {
- this._onChange = onChange;
- this._markedOptions = {
- renderer: new TimelinePostMarkedRenderer(this._images),
- };
- }
-
- setMarkdownText(text: string): void {
- this._text = text;
- this._onChange();
- }
-
- appendImage(file: File): void {
- this._images = this._images.slice();
- this._images.push({
- file,
- url: URL.createObjectURL(file),
- });
- this._onChange();
- }
-
- moveImage(oldIndex: number, newIndex: number): void {
- if (oldIndex < 0 || oldIndex >= this._images.length) {
- throw new UiLogicError("Old index out of range.");
- }
-
- if (newIndex < 0) {
- newIndex = 0;
- }
-
- if (newIndex >= this._images.length) {
- newIndex = this._images.length - 1;
- }
-
- this._images = this._images.slice();
-
- const [old] = this._images.splice(oldIndex, 1);
- this._images.splice(newIndex, 0, old);
-
- this._onChange();
- }
-
- deleteImage(index: number): void {
- if (index < 0 || index >= this._images.length) {
- throw new UiLogicError("Old index out of range.");
- }
-
- this._images = this._images.slice();
-
- URL.revokeObjectURL(this._images[index].url);
- this._images.splice(index, 1);
-
- this._onChange();
- }
-
- get text(): string {
- return this._text;
- }
-
- get images(): { file: File; url: string }[] {
- return this._images;
- }
-
- get isEmpty(): boolean {
- return this._text.length === 0 && this._images.length === 0;
- }
-
- renderHtml(): string {
- return marked.parse(this._text);
- }
-
- dispose(): void {
- for (const image of this._images) {
- URL.revokeObjectURL(image.url);
- }
- this._images = [];
- }
-
- async build(): Promise<HttpTimelinePostPostRequest["dataList"]> {
- return [
- {
- contentType: "text/markdown",
- data: await base64(this._text),
- },
- ...(await Promise.all(
- this._images.map((image) =>
- base64(image.file).then((data) => ({
- contentType: image.file.type,
- data,
- })),
- ),
- )),
- ];
- }
-}
diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts
index 42b14451..0fa37848 100644
--- a/FrontEnd/src/services/alert.ts
+++ b/FrontEnd/src/services/alert.ts
@@ -1,10 +1,10 @@
import pull from "lodash/pull";
-import { I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { I18nText } from "~src/common";
+import { ThemeColor } from "~src/components/common";
export interface AlertInfo {
- type?: PaletteColorType;
+ type?: ThemeColor;
message?: I18nText;
customMessage?: React.ReactElement;
dismissTime?: number | "never";
diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts
index 707c956f..41a7bff0 100644
--- a/FrontEnd/src/services/timeline.ts
+++ b/FrontEnd/src/services/timeline.ts
@@ -1,9 +1,22 @@
-import { TimelineVisibility } from "@/http/timeline";
import XRegExp from "xregexp";
-import { Observable } from "rxjs";
-import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr";
-
-import { getHttpToken } from "@/http/common";
+import {
+ Observable,
+ BehaviorSubject,
+ switchMap,
+ filter,
+ first,
+ distinctUntilChanged,
+} from "rxjs";
+import {
+ HubConnection,
+ HubConnectionBuilder,
+ HubConnectionState,
+} from "@microsoft/signalr";
+
+import { TimelineVisibility } from "~src/http/timeline";
+import { token$ } from "~src/http/common";
+
+// cSpell:ignore onreconnected onreconnecting
const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
@@ -20,17 +33,23 @@ export const timelineVisibilityTooltipTranslationMap: Record<
Private: "timeline.visibilityTooltip.private",
};
-export function getTimelinePostUpdate$(
- owner: string,
- timeline: string,
-): Observable<{ update: boolean; state: HubConnectionState }> {
- return new Observable((subscriber) => {
- subscriber.next({
- update: false,
- state: HubConnectionState.Connecting,
- });
+type ConnectionState =
+ | "Connecting"
+ | "Reconnecting"
+ | "Disconnected"
+ | "Connected";
+
+type Connection = {
+ connection: HubConnection;
+ state$: Observable<ConnectionState>;
+};
+
+function createConnection$(token: string | null): Observable<Connection> {
+ return new Observable<Connection>((subscriber) => {
+ const connectionStateSubject = new BehaviorSubject<ConnectionState>(
+ "Connecting",
+ );
- const token = getHttpToken();
const connection = new HubConnectionBuilder()
.withUrl("/api/hub/timeline", {
accessTokenFactory: token == null ? undefined : () => token,
@@ -38,56 +57,138 @@ export function getTimelinePostUpdate$(
.withAutomaticReconnect()
.build();
- const o = owner;
- const t = timeline;
+ connection.onclose = () => {
+ connectionStateSubject.next("Disconnected");
+ };
+
+ connection.onreconnecting = () => {
+ connectionStateSubject.next("Reconnecting");
+ };
+
+ connection.onreconnected = () => {
+ connectionStateSubject.next("Connected");
+ };
+
+ let requestStopped = false;
+
+ void connection.start().then(
+ () => {
+ connectionStateSubject.next("Connected");
+ },
+ (e) => {
+ if (!requestStopped) {
+ throw e;
+ }
+ },
+ );
+
+ subscriber.next({
+ connection,
+ state$: connectionStateSubject.asObservable(),
+ });
+
+ return () => {
+ void connection.stop();
+ requestStopped = true;
+ };
+ });
+}
+
+const connectionSubject = new BehaviorSubject<Connection | null>(null);
+
+token$
+ .pipe(distinctUntilChanged(), switchMap(createConnection$))
+ .subscribe(connectionSubject);
+
+const connection$ = connectionSubject
+ .asObservable()
+ .pipe(filter((c): c is Connection => c != null));
+
+function createTimelinePostUpdateCount$(
+ connection: Connection,
+ owner: string,
+ timeline: string,
+): Observable<number> {
+ const [o, t] = [owner, timeline];
+ return new Observable<number>((subscriber) => {
+ const hubConnection = connection.connection;
+ let count = 0;
const handler = (owner: string, timeline: string): void => {
if (owner === o && timeline === t) {
- subscriber.next({ update: true, state: connection.state });
+ subscriber.next(count++);
}
};
- connection.onclose(() => {
- subscriber.next({
- update: false,
- state: HubConnectionState.Disconnected,
+ let hubOn = false;
+
+ const subscription = connection.state$
+ .pipe(first((state) => state === "Connected"))
+ .subscribe(() => {
+ hubConnection.on("OnTimelinePostChangedV2", handler);
+ void hubConnection.invoke(
+ "SubscribeTimelinePostChangeV2",
+ owner,
+ timeline,
+ );
+ hubOn = true;
});
- });
- connection.onreconnecting(() => {
- subscriber.next({
- update: false,
- state: HubConnectionState.Reconnecting,
- });
- });
+ return () => {
+ if (hubOn) {
+ void hubConnection.invoke(
+ "UnsubscribeTimelinePostChangeV2",
+ owner,
+ timeline,
+ );
+ hubConnection.off("OnTimelinePostChangedV2", handler);
+ }
+
+ subscription.unsubscribe();
+ };
+ });
+}
+
+type OldUpdateInfo = { update: boolean; state: HubConnectionState };
- connection.onreconnected(() => {
+function createTimelinePostOldUpdateInfo$(
+ connection: Connection,
+ owner: string,
+ timeline: string,
+): Observable<OldUpdateInfo> {
+ return new Observable<OldUpdateInfo>((subscriber) => {
+ let savedState: ConnectionState = "Connecting";
+
+ const postUpdateSubscription = createTimelinePostUpdateCount$(
+ connection,
+ owner,
+ timeline,
+ ).subscribe(() => {
subscriber.next({
- update: false,
- state: HubConnectionState.Connected,
+ update: true,
+ state: savedState as HubConnectionState,
});
});
- connection.on("OnTimelinePostChangedV2", handler);
-
- void connection.start().then(() => {
- subscriber.next({ update: false, state: HubConnectionState.Connected });
-
- return connection.invoke(
- "SubscribeTimelinePostChangeV2",
- owner,
- timeline,
- );
+ const stateSubscription = connection.state$.subscribe((state) => {
+ savedState = state;
+ subscriber.next({ update: false, state: state as HubConnectionState });
});
return () => {
- connection.off("OnTimelinePostChangedV2", handler);
-
- if (connection.state === HubConnectionState.Connected) {
- void connection
- .invoke("UnsubscribeTimelinePostChangeV2", owner, timeline)
- .then(() => connection.stop());
- }
+ stateSubscription.unsubscribe();
+ postUpdateSubscription.unsubscribe();
};
});
}
+
+export function getTimelinePostUpdate$(
+ owner: string,
+ timeline: string,
+): Observable<OldUpdateInfo> {
+ return connection$.pipe(
+ switchMap((connection) =>
+ createTimelinePostOldUpdateInfo$(connection, owner, timeline),
+ ),
+ );
+}
diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts
index c89ca893..5f682a36 100644
--- a/FrontEnd/src/services/user.ts
+++ b/FrontEnd/src/services/user.ts
@@ -2,20 +2,23 @@ import { useState, useEffect } from "react";
import { BehaviorSubject, Observable } from "rxjs";
import { AxiosError } from "axios";
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
-import { setHttpToken, axios, HttpBadRequestError } from "@/http/common";
-import { getHttpTokenClient } from "@/http/token";
-import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user";
+import { setHttpToken, axios, HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { getHttpUserClient, HttpUser, UserPermission } from "~src/http/user";
-import { pushAlert } from "./alert";
+import { pushAlert } from "~src/components/alert";
interface IAuthUser extends HttpUser {
token: string;
}
export class AuthUser implements IAuthUser {
- constructor(user: HttpUser, public token: string) {
+ constructor(
+ user: HttpUser,
+ public token: string,
+ ) {
this.uniqueId = user.uniqueId;
this.username = user.username;
this.permissions = user.permissions;
@@ -61,7 +64,7 @@ export class UserService {
if (e.isAxiosError && e.response && e.response.status === 401) {
this.userSubject.next(null);
pushAlert({
- type: "danger",
+ color: "danger",
message: "user.tokenInvalid",
});
} else {
@@ -97,11 +100,11 @@ export class UserService {
localStorage.removeItem(USER_STORAGE_KEY);
this.userSubject.next(null);
pushAlert({
- type: "danger",
+ color: "danger",
message: "user.tokenInvalid",
});
}
- }
+ },
);
}
}
@@ -118,7 +121,7 @@ export class UserService {
async login(
credentials: LoginCredentials,
- rememberMe: boolean
+ rememberMe: boolean,
): Promise<void> {
if (this.currentUser) {
throw new UiLogicError("Already login.");
diff --git a/FrontEnd/src/utilities/array.ts b/FrontEnd/src/utilities/array.ts
new file mode 100644
index 00000000..838e8744
--- /dev/null
+++ b/FrontEnd/src/utilities/array.ts
@@ -0,0 +1,41 @@
+export function copy_move<T>(
+ array: T[],
+ oldIndex: number,
+ newIndex: number,
+): T[] {
+ if (oldIndex < 0 || oldIndex >= array.length) {
+ throw new Error("Old index out of range.");
+ }
+
+ if (newIndex < 0) {
+ newIndex = 0;
+ }
+
+ if (newIndex >= array.length) {
+ newIndex = array.length - 1;
+ }
+
+ const result = array.slice();
+ const [element] = result.splice(oldIndex, 1);
+ result.splice(newIndex, 0, element);
+
+ return result;
+}
+
+export function copy_insert<T>(array: T[], index: number, element: T): T[] {
+ const result = array.slice();
+ result.splice(index, 0, element);
+ return result;
+}
+
+export function copy_push<T>(array: T[], element: T): T[] {
+ const result = array.slice();
+ result.push(element);
+ return result;
+}
+
+export function copy_delete<T>(array: T[], index: number): T[] {
+ const result = array.slice();
+ result.splice(index, 1);
+ return array;
+}
diff --git a/FrontEnd/src/utilities/base64.ts b/FrontEnd/src/utilities/base64.ts
index 59de7512..6eece979 100644
--- a/FrontEnd/src/utilities/base64.ts
+++ b/FrontEnd/src/utilities/base64.ts
@@ -1,8 +1,19 @@
-import { Base64 } from "js-base64";
+function bytesToBase64(bytes: Uint8Array): string {
+ const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join("");
+ return btoa(binString);
+}
+
+export default function base64(
+ data: Blob | Uint8Array | string,
+): Promise<string> {
+ if (typeof data === "string") {
+ // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
+ const binString = new TextEncoder().encode(data);
+ return Promise.resolve(bytesToBase64(binString));
+ }
-export default function base64(blob: Blob | string): Promise<string> {
- if (typeof blob === "string") {
- return Promise.resolve(Base64.encode(blob));
+ if (data instanceof Uint8Array) {
+ return Promise.resolve(bytesToBase64(data));
}
return new Promise<string>((resolve) => {
@@ -10,6 +21,6 @@ export default function base64(blob: Blob | string): Promise<string> {
reader.onload = function () {
resolve((reader.result as string).replace(/^data:.*;base64,/, ""));
};
- reader.readAsDataURL(blob);
+ reader.readAsDataURL(data);
});
}
diff --git a/FrontEnd/src/utilities/geometry.ts b/FrontEnd/src/utilities/geometry.ts
new file mode 100644
index 00000000..60a8d3d4
--- /dev/null
+++ b/FrontEnd/src/utilities/geometry.ts
@@ -0,0 +1,292 @@
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export type Movement = Point;
+
+export interface Size {
+ width: number;
+ height: number;
+}
+
+export class Rect {
+ static empty = new Rect(0, 0, 0, 0);
+ static max = new Rect(
+ Number.MIN_VALUE,
+ Number.MIN_VALUE,
+ Number.MAX_VALUE,
+ Number.MAX_VALUE,
+ );
+
+ static from({
+ left,
+ top,
+ width,
+ height,
+ }: {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ }): Rect {
+ return new Rect(left, top, width, height);
+ }
+
+ constructor(
+ public left: number,
+ public top: number,
+ public width: number,
+ public height: number,
+ ) {}
+
+ get right(): number {
+ return this.left + this.width;
+ }
+
+ set right(value: number) {
+ this.width = value - this.left;
+ }
+
+ get bottom(): number {
+ return this.top + this.height;
+ }
+
+ set bottom(value: number) {
+ this.height = value - this.top;
+ }
+
+ get ratio(): number {
+ return this.height / this.width;
+ }
+
+ get position(): Point {
+ return {
+ x: this.left,
+ y: this.top,
+ };
+ }
+
+ set position(value: Point) {
+ this.left = value.x;
+ this.top = value.y;
+ }
+
+ get size(): Size {
+ return {
+ width: this.width,
+ height: this.height,
+ };
+ }
+
+ set size(value: Size) {
+ this.width = value.width;
+ this.height = value.height;
+ }
+
+ get normalizedLeft(): number {
+ return this.width >= 0 ? this.left : this.right;
+ }
+
+ get normalizedTop(): number {
+ return this.height >= 0 ? this.top : this.bottom;
+ }
+
+ get normalizedRight(): number {
+ return this.width >= 0 ? this.right : this.left;
+ }
+
+ get normalizedBottom(): number {
+ return this.height >= 0 ? this.bottom : this.top;
+ }
+
+ get normalizedWidth(): number {
+ return Math.abs(this.width);
+ }
+
+ get normalizedHeight(): number {
+ return Math.abs(this.height);
+ }
+
+ get normalizedSize(): Size {
+ return {
+ width: this.normalizedWidth,
+ height: this.normalizedHeight,
+ };
+ }
+
+ get normalizedRatio(): number {
+ return Math.abs(this.ratio);
+ }
+
+ normalize(): Rect {
+ if (this.width < 0) {
+ this.width = -this.width;
+ this.left -= this.width;
+ }
+ if (this.height < 0) {
+ this.height = -this.height;
+ this.top -= this.height;
+ }
+ return this;
+ }
+
+ move(movement: Movement): Rect {
+ this.left += movement.x;
+ this.top += movement.y;
+ return this;
+ }
+
+ expand(size: Size | Point): Rect {
+ if ("x" in size) {
+ this.width += size.x;
+ this.height += size.y;
+ } else {
+ this.width += size.width;
+ this.height += size.height;
+ }
+ return this;
+ }
+
+ copy(): Rect {
+ return new Rect(this.left, this.top, this.width, this.height);
+ }
+}
+
+export function adjustRectToContainer(
+ rect: Rect,
+ container: Rect,
+ mode: "move" | "resize" | "both",
+ options?: {
+ targetRatio?: number;
+ resizeNoFlip?: boolean;
+ ratioCorrectBasedOn?: "bigger" | "smaller" | "width" | "height";
+ },
+): Rect {
+ rect = rect.copy();
+ container = container.copy().normalize();
+
+ if (process.env.NODE_ENV === "development") {
+ if (mode === "move") {
+ if (rect.normalizedWidth > container.width) {
+ console.warn(
+ "adjust rect (move): rect.normalizedWidth > container.normalizedWidth",
+ );
+ }
+ if (rect.normalizedHeight > container.height) {
+ console.warn(
+ "adjust rect (move): rect.normalizedHeight > container.normalizedHeight",
+ );
+ }
+ }
+ if (mode === "resize") {
+ if (rect.left < container.left) {
+ console.warn(
+ "adjust rect (resize): rect.left < container.normalizedLeft",
+ );
+ }
+ if (rect.left > container.right) {
+ console.warn(
+ "adjust rect (resize): rect.left > container.normalizedRight",
+ );
+ }
+ if (rect.top < container.top) {
+ console.warn(
+ "adjust rect (resize): rect.top < container.normalizedTop",
+ );
+ }
+ if (rect.top > container.bottom) {
+ console.warn(
+ "adjust rect (resize): rect.top > container.normalizedBottom",
+ );
+ }
+ }
+ }
+
+ if (mode === "move") {
+ rect.left =
+ rect.width >= 0
+ ? clamp(rect.left, container.left, container.right - rect.width)
+ : clamp(rect.left, container.left - rect.width, container.right);
+ rect.top =
+ rect.height >= 0
+ ? clamp(rect.top, container.top, container.bottom - rect.height)
+ : clamp(rect.top, container.top - rect.height, container.bottom);
+ } else if (mode === "resize") {
+ const noFlip = options?.resizeNoFlip;
+ const newRight = clamp(
+ rect.right,
+ rect.width > 0 && noFlip ? rect.left : container.left,
+ rect.width < 0 && noFlip ? rect.left : container.right,
+ );
+ rect.right = newRight;
+ rect.bottom = clamp(
+ rect.bottom,
+ rect.height > 0 && noFlip ? rect.top : container.top,
+ rect.height < 0 && noFlip ? rect.top : container.bottom,
+ );
+ } else {
+ rect.left = clamp(rect.left, container.left, container.right);
+ rect.top = clamp(rect.top, container.top, container.bottom);
+ rect.right = clamp(rect.right, container.left, container.right);
+ rect.bottom = clamp(rect.bottom, container.top, container.bottom);
+ }
+
+ // Now correct ratio
+ const currentRatio = rect.normalizedRatio;
+ let targetRatio = options?.targetRatio;
+ if (targetRatio != null) targetRatio = Math.abs(targetRatio);
+ if (targetRatio != null && currentRatio !== targetRatio) {
+ const { ratioCorrectBasedOn } = options ?? {};
+
+ const newWidth =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ const newHeight =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+
+ const newBottom = rect.top + newHeight;
+ const newRight = rect.left + newWidth;
+
+ if (ratioCorrectBasedOn === "width") {
+ if (newBottom >= container.top && newBottom <= container.bottom) {
+ rect.height = newHeight;
+ } else {
+ rect.bottom = clamp(newBottom, container.top, container.bottom);
+ rect.width =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ }
+ } else if (ratioCorrectBasedOn === "height") {
+ if (newRight >= container.left && newRight <= container.right) {
+ rect.width = newWidth;
+ } else {
+ rect.right = clamp(newRight, container.left, container.right);
+ rect.height =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+ }
+ } else if (ratioCorrectBasedOn === "smaller") {
+ if (currentRatio > targetRatio) {
+ // too tall
+ rect.width =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ } else {
+ rect.height =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+ }
+ } else {
+ if (currentRatio < targetRatio) {
+ // too wide
+ rect.width =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ } else {
+ rect.height =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+ }
+ }
+ }
+
+ return rect;
+}
diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts
deleted file mode 100644
index a59f7167..00000000
--- a/FrontEnd/src/utilities/hooks.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import useClickOutside from "./hooks/useClickOutside";
-import useScrollToBottom from "./hooks/useScrollToBottom";
-import { useIsSmallScreen } from "./hooks/mediaQuery";
-
-export { useClickOutside, useScrollToBottom, useIsSmallScreen };
diff --git a/FrontEnd/src/utilities/hooks/mediaQuery.ts b/FrontEnd/src/utilities/hooks/mediaQuery.ts
deleted file mode 100644
index ad55c3c0..00000000
--- a/FrontEnd/src/utilities/hooks/mediaQuery.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { useMediaQuery } from "react-responsive";
-
-export function useIsSmallScreen(): boolean {
- return useMediaQuery({ maxWidth: 576 });
-}
diff --git a/FrontEnd/src/utilities/index.ts b/FrontEnd/src/utilities/index.ts
new file mode 100644
index 00000000..7659a8aa
--- /dev/null
+++ b/FrontEnd/src/utilities/index.ts
@@ -0,0 +1,27 @@
+export { default as base64 } from "./base64";
+export { withQuery } from "./url";
+
+export function delay(milliseconds: number): Promise<void> {
+ return new Promise<void>((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, milliseconds);
+ });
+}
+
+export function range(stop: number): number[];
+export function range(start: number, stop: number, step?: number): number[];
+export function range(start: number, stop?: number, step?: number): number[] {
+ if (stop == undefined) {
+ stop = start;
+ start = 0;
+ }
+ if (step == undefined) {
+ step = 1;
+ }
+ const result: number[] = [];
+ for (let i = start; i < stop; i += step) {
+ result.push(i);
+ }
+ return result;
+}
diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png
deleted file mode 100644
index d890d8d0..00000000
--- a/FrontEnd/src/views/about/author-avatar.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png
deleted file mode 100644
index ea6ff545..00000000
--- a/FrontEnd/src/views/about/github.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/views/about/index.css b/FrontEnd/src/views/about/index.css
deleted file mode 100644
index 2574f4b7..00000000
--- a/FrontEnd/src/views/about/index.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.about-link-icon {
- width: 1.2em;
- height: 1.2em;
-}
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx
deleted file mode 100644
index 093da894..00000000
--- a/FrontEnd/src/views/about/index.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useTranslation, Trans } from "react-i18next";
-
-import authorAvatarUrl from "./author-avatar.png";
-import githubLogoUrl from "./github.png";
-
-import Card from "../common/Card";
-
-import "./index.css";
-
-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: "vite",
- url: "https://vitejs.dev",
- },
- {
- name: "eslint",
- url: "https://eslint.org",
- },
- {
- name: "prettier",
- url: "https://prettier.io",
- },
- {
- name: "pepjs",
- url: "https://github.com/jquery/PEP",
- },
-];
-
-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",
- },
-];
-
-export default function AboutPage() {
- const { t } = useTranslation();
-
- return (
- <div className="px-2 mb-4">
- <Card className="container mt-4 py-3">
- <h4 id="author-info">{t("about.author.title")}</h4>
- <div>
- <div className="d-block">
- <img
- src={authorAvatarUrl}
- className="cru-avatar large cru-round cru-float-left"
- />
- <p>
- <small>{t("about.author.name")}</small>
- <span className="cru-color-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" />
- </a>
- </p>
- </div>
- </Card>
- <Card className="container mt-4 py-3">
- <h4>{t("about.site.title")}</h4>
- <p>
- <Trans i18nKey="about.site.content">
- 0<span className="cru-color-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>
- </Card>
- <Card className="container mt-4 py-3">
- <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>
- </Card>
- </div>
- );
-}
diff --git a/FrontEnd/src/views/common/AppBar.css b/FrontEnd/src/views/common/AppBar.css
deleted file mode 100644
index 3ec4fa36..00000000
--- a/FrontEnd/src/views/common/AppBar.css
+++ /dev/null
@@ -1,95 +0,0 @@
-.app-bar {
- display: flex;
- align-items: center;
- height: 56px;
- position: fixed;
- z-index: 1030;
- top: 0;
- left: 0;
- right: 0;
- background-color: var(--cru-primary-color);
- transition: background-color 1s;
-}
-
-.app-bar .cru-avatar {
- background-color: white;
-}
-
-.app-bar a {
- color: var(--cru-primary-t1-color);
- text-decoration: none;
- margin: 0 1em;
- transition: color 1s;
-}
-.app-bar a:hover {
- color: var(--cru-primary-t-color);
-}
-.app-bar a.active {
- color: var(--cru-primary-t-color);
-}
-
-.app-bar-brand {
- display: flex;
- align-items: center;
-}
-
-.app-bar-brand-icon {
- height: 2em;
-}
-
-.app-bar-main-area {
- display: flex;
- flex-grow: 1;
-}
-
-.app-bar-link-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
-}
-
-.app-bar-user-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- margin-left: auto;
-}
-
-.small-screen .app-bar-main-area {
- position: absolute;
- top: 56px;
- left: 0;
- right: 0;
- transform-origin: top;
- transition: transform 0.6s, background-color 1s;
- background-color: var(--cru-primary-color);
- flex-direction: column;
-}
-.small-screen .app-bar-main-area.app-bar-collapse {
- transform: scale(1, 0);
-}
-.small-screen .app-bar-main-area a {
- text-align: left;
- padding: 0.5em 0.5em;
-}
-.small-screen .app-bar-link-area {
- flex-direction: column;
- align-items: stretch;
-}
-.small-screen .app-bar-user-area {
- flex-direction: column;
- align-items: stretch;
- margin-left: unset;
-}
-.small-screen .app-bar-avatar {
- align-self: flex-end;
-}
-
-.app-bar-toggler {
- margin-left: auto;
- font-size: 2em;
- margin-right: 1em;
- color: var(--cru-primary-t-color);
- cursor: pointer;
- user-select: none;
-}
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx
deleted file mode 100644
index 278c70fd..00000000
--- a/FrontEnd/src/views/common/AppBar.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-import { Link, NavLink } from "react-router-dom";
-import { useMediaQuery } from "react-responsive";
-
-import { useUser } from "@/services/user";
-
-import TimelineLogo from "./TimelineLogo";
-import UserAvatar from "./user/UserAvatar";
-
-import "./AppBar.css";
-
-const AppBar: React.FC = () => {
- const { t } = useTranslation();
-
- const user = useUser();
- const hasAdministrationPermission = user && user.hasAdministrationPermission;
-
- const isSmallScreen = useMediaQuery({ maxWidth: 576 });
-
- const [expand, setExpand] = React.useState<boolean>(false);
- const collapse = (): void => setExpand(false);
- const toggleExpand = (): void => setExpand(!expand);
-
- const createLink = (
- link: string,
- label: React.ReactNode,
- className?: string
- ): React.ReactNode => (
- <NavLink
- to={link}
- onClick={collapse}
- className={({ isActive }) => classnames(className, isActive && "active")}
- >
- {label}
- </NavLink>
- );
-
- return (
- <nav className={classnames("app-bar", isSmallScreen && "small-screen")}>
- <Link to="/" className="app-bar-brand active">
- <TimelineLogo className="app-bar-brand-icon" />
- Timeline
- </Link>
-
- {isSmallScreen && (
- <i className="bi-list app-bar-toggler" onClick={toggleExpand} />
- )}
-
- <div
- className={classnames(
- "app-bar-main-area",
- !expand && "app-bar-collapse"
- )}
- >
- <div className="app-bar-link-area">
- {createLink("/settings", t("nav.settings"))}
- {createLink("/about", t("nav.about"))}
- {hasAdministrationPermission &&
- createLink("/admin", t("nav.administration"))}
- </div>
-
- <div className="app-bar-user-area">
- {user != null
- ? createLink(
- "/",
- <UserAvatar
- username={user.username}
- className="cru-avatar small cru-round cursor-pointer ml-auto"
- />,
- "app-bar-avatar"
- )
- : createLink("/login", t("nav.login"))}
- </div>
- </div>
- </nav>
- );
-};
-
-export default AppBar;
diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx
deleted file mode 100644
index 5e050ebe..00000000
--- a/FrontEnd/src/views/common/BlobImage.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import * as React from "react";
-
-const BlobImage: React.FC<
- Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
- blob?: Blob | unknown;
- }
-> = (props) => {
- const { blob, ...otherProps } = props;
-
- const [url, setUrl] = React.useState<string | undefined>(undefined);
-
- React.useEffect(() => {
- if (blob instanceof Blob) {
- const url = URL.createObjectURL(blob);
- setUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setUrl(undefined);
- }
- }, [blob]);
-
- return <img {...otherProps} src={url} />;
-};
-
-export default BlobImage;
diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css
deleted file mode 100644
index 6de0dd8e..00000000
--- a/FrontEnd/src/views/common/Card.css
+++ /dev/null
@@ -1,15 +0,0 @@
-:root {
- --cru-card-border-radius: 8px;
-}
-
-.cru-card {
- border: 1px solid;
- border-color: #e9ecef;
- border-radius: var(--cru-card-border-radius);
- background: #fefeff;
- transition: all 0.3s;
-}
-
-.cru-card:hover {
- border-color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx
deleted file mode 100644
index ebbce77e..00000000
--- a/FrontEnd/src/views/common/Card.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import classNames from "classnames";
-import * as React from "react";
-
-import "./Card.css";
-
-function _Card(
- {
- className,
- children,
- ...otherProps
- }: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>,
- ref: React.ForwardedRef<HTMLDivElement>
-): React.ReactElement | null {
- return (
- <div
- ref={ref}
- className={classNames("cru-card", className)}
- {...otherProps}
- >
- {children}
- </div>
- );
-}
-
-const Card = React.forwardRef(_Card);
-
-export default Card;
diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx
deleted file mode 100644
index 04e17415..00000000
--- a/FrontEnd/src/views/common/ImageCropper.tsx
+++ /dev/null
@@ -1,306 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { UiLogicError } from "@/common";
-
-import "./ImageCropper.css";
-
-export interface Clip {
- left: number;
- top: number;
- width: number;
-}
-
-interface NormailizedClip extends Clip {
- height: number;
-}
-
-interface ImageInfo {
- width: number;
- height: number;
- landscape: boolean;
- ratio: number;
- maxClipWidth: number;
- maxClipHeight: number;
-}
-
-interface ImageCropperSavedState {
- clip: NormailizedClip;
- x: number;
- y: number;
- pointerId: number;
-}
-
-export interface ImageCropperProps {
- clip: Clip | null;
- imageUrl: string;
- onChange: (clip: Clip) => void;
- imageElementCallback?: (element: HTMLImageElement | null) => void;
- className?: string;
-}
-
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
- const { clip, imageUrl, onChange, imageElementCallback, className } = props;
-
- const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
- null
- );
- const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
-
- const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
- if (c == null) {
- return { left: 0, top: 0, width: 0, height: 0 };
- }
-
- return {
- left: c.left || 0,
- top: c.top || 0,
- width: c.width || 0,
- height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
- };
- };
-
- const c = normalizeClip(clip);
-
- const imgElementRef = React.useRef<HTMLImageElement | null>(null);
-
- const onImageRef = React.useCallback(
- (e: HTMLImageElement | null) => {
- imgElementRef.current = e;
- if (imageElementCallback != null && e == null) {
- imageElementCallback(null);
- }
- },
- [imageElementCallback]
- );
-
- const onImageLoad = React.useCallback(
- (e: React.SyntheticEvent<HTMLImageElement>) => {
- const img = e.currentTarget;
- const landscape = img.naturalWidth >= img.naturalHeight;
-
- const info = {
- width: img.naturalWidth,
- height: img.naturalHeight,
- landscape,
- ratio: img.naturalHeight / img.naturalWidth,
- maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
- maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
- };
- setImageInfo(info);
- onChange({ left: 0, top: 0, width: info.maxClipWidth });
- if (imageElementCallback != null) {
- imageElementCallback(img);
- }
- },
- [onChange, imageElementCallback]
- );
-
- const onPointerDown = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState != null) return;
- e.currentTarget.setPointerCapture(e.pointerId);
- setOldState({
- x: e.clientX,
- y: e.clientY,
- clip: c,
- pointerId: e.pointerId,
- });
- },
- [oldState, c]
- );
-
- const onPointerUp = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null || oldState.pointerId !== e.pointerId) return;
- e.currentTarget.releasePointerCapture(e.pointerId);
- setOldState(null);
- },
- [oldState]
- );
-
- const onPointerMove = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null) return;
-
- const oldClip = oldState.clip;
-
- const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
- const { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError("Image element is null.");
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.y / imgElement.height,
- };
-
- const newRatio = {
- x: oldClip.left + moveRatio.x,
- y: oldClip.top + moveRatio.y,
- };
- if (newRatio.x < 0) {
- newRatio.x = 0;
- } else if (newRatio.x > 1 - oldClip.width) {
- newRatio.x = 1 - oldClip.width;
- }
- if (newRatio.y < 0) {
- newRatio.y = 0;
- } else if (newRatio.y > 1 - oldClip.height) {
- newRatio.y = 1 - oldClip.height;
- }
-
- onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
- },
- [oldState, onChange]
- );
-
- const onHandlerPointerMove = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null) return;
-
- const oldClip = oldState.clip;
-
- const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
- const ratio = imageInfo == null ? 1 : imageInfo.ratio;
-
- const { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError("Image element is null.");
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.x / imgElement.width / ratio,
- };
-
- const newRatio = {
- x: oldClip.width + moveRatio.x,
- y: oldClip.height + moveRatio.y,
- };
-
- const maxRatio = {
- x: Math.min(1 - oldClip.left, newRatio.x),
- y: Math.min(1 - oldClip.top, newRatio.y),
- };
-
- const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
-
- let newWidth;
- if (newRatio.x < 0) {
- newWidth = 0;
- } else if (newRatio.x > maxWidthRatio) {
- newWidth = maxWidthRatio;
- } else {
- newWidth = newRatio.x;
- }
-
- onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
- },
- [imageInfo, oldState, onChange]
- );
-
- const toPercentage = (n: number): string => `${n}%`;
-
- // fuck!!! I just can't find a better way to implement this in pure css
- const containerStyle: React.CSSProperties = (() => {
- if (imageInfo == null) {
- return { width: "100%", paddingTop: "100%", height: 0 };
- } else {
- if (imageInfo.ratio > 1) {
- return {
- width: toPercentage(100 / imageInfo.ratio),
- paddingTop: "100%",
- height: 0,
- };
- } else {
- return {
- width: "100%",
- paddingTop: toPercentage(100 * imageInfo.ratio),
- height: 0,
- };
- }
- }
- })();
-
- return (
- <div
- className={classnames("image-cropper-container", className)}
- style={containerStyle}
- >
- <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
- <div className="image-cropper-mask-container">
- <div
- className="image-cropper-mask"
- style={{
- left: toPercentage(c.left * 100),
- top: toPercentage(c.top * 100),
- width: toPercentage(c.width * 100),
- height: toPercentage(c.height * 100),
- }}
- onPointerMove={onPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- <div
- className="image-cropper-handler"
- style={{
- left: `calc(${(c.left + c.width) * 100}% - 15px)`,
- top: `calc(${(c.top + c.height) * 100}% - 15px)`,
- }}
- onPointerMove={onHandlerPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
- image: HTMLImageElement,
- clip: Clip,
- mimeType: string
-): Promise<Blob> {
- return new Promise((resolve, reject) => {
- const naturalSize = {
- width: image.naturalWidth,
- height: image.naturalHeight,
- };
- const clipArea = {
- x: naturalSize.width * clip.left,
- y: naturalSize.height * clip.top,
- length: naturalSize.width * clip.width,
- };
-
- const canvas = document.createElement("canvas");
- canvas.width = clipArea.length;
- canvas.height = clipArea.length;
- const context = canvas.getContext("2d");
-
- if (context == null) throw new Error("Failed to create context.");
-
- context.drawImage(
- image,
- clipArea.x,
- clipArea.y,
- clipArea.length,
- clipArea.length,
- 0,
- 0,
- clipArea.length,
- clipArea.length
- );
-
- canvas.toBlob((blob) => {
- if (blob == null) {
- reject(new Error("canvas.toBlob returns null"));
- } else {
- resolve(blob);
- }
- }, mimeType);
- });
-}
diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx
deleted file mode 100644
index 35ee1aa8..00000000
--- a/FrontEnd/src/views/common/LoadingPage.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as React from "react";
-
-import Spinner from "./Spinner";
-
-const LoadingPage: React.FC = () => {
- return (
- <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
- <Spinner />
- </div>
- );
-};
-
-export default LoadingPage;
diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx
deleted file mode 100644
index 9d644ab7..00000000
--- a/FrontEnd/src/views/common/SearchInput.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useCallback } from "react";
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import LoadingButton from "./button/LoadingButton";
-
-import "./SearchInput.css";
-
-export interface SearchInputProps {
- value: string;
- onChange: (value: string) => void;
- onButtonClick: () => void;
- className?: string;
- loading?: boolean;
- buttonText?: string;
- placeholder?: string;
- additionalButton?: React.ReactNode;
- alwaysOneline?: boolean;
-}
-
-const SearchInput: React.FC<SearchInputProps> = (props) => {
- const { onChange, onButtonClick, alwaysOneline } = props;
-
- const { t } = useTranslation();
-
- const onInputChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>): void => {
- onChange(event.currentTarget.value);
- },
- [onChange]
- );
-
- const onInputKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>): void => {
- if (event.key === "Enter") {
- onButtonClick();
- event.preventDefault();
- }
- },
- [onButtonClick]
- );
-
- return (
- <div
- className={classnames(
- "cru-search-input",
- alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap",
- props.className
- )}
- >
- <input
- type="text"
- className="cru-search-input-input me-sm-2 flex-grow-1"
- value={props.value}
- onChange={onInputChange}
- onKeyPress={onInputKeyPress}
- placeholder={props.placeholder}
- />
- {props.additionalButton ? (
- <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ms-sm-2">
- {props.additionalButton}
- </div>
- ) : null}
- <div
- className={classnames(
- alwaysOneline ? "mt-0 ms-2" : "mt-2 mt-sm-0 ms-auto ms-sm-0",
- "flex-shrink-0"
- )}
- >
- <LoadingButton loading={props.loading} onClick={props.onButtonClick}>
- {props.buttonText ?? t("search")}
- </LoadingButton>
- </div>
- </div>
- );
-};
-
-export default SearchInput;
diff --git a/FrontEnd/src/views/common/Skeleton.css b/FrontEnd/src/views/common/Skeleton.css
deleted file mode 100644
index db1a1c34..00000000
--- a/FrontEnd/src/views/common/Skeleton.css
+++ /dev/null
@@ -1,14 +0,0 @@
-.cru-skeleton {
- padding: 0 1em;
-}
-
-.cru-skeleton-line {
- height: 1em;
- background-color: #e6e6e6;
- margin: 0.7em 0;
- border-radius: 0.2em;
-}
-
-.cru-skeleton-line.last {
- width: 50%;
-}
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx
deleted file mode 100644
index 3b149db9..00000000
--- a/FrontEnd/src/views/common/Skeleton.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import range from "lodash/range";
-
-import "./Skeleton.css";
-
-export interface SkeletonProps {
- lineNumber?: number;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const Skeleton: React.FC<SkeletonProps> = (props) => {
- const { lineNumber: lineNumberProps, className, style } = props;
- const lineNumber = lineNumberProps ?? 3;
-
- return (
- <div className={classnames(className, "cru-skeleton")} style={style}>
- {range(lineNumber).map((i) => (
- <div
- key={i}
- className={classnames(
- "cru-skeleton-line",
- i === lineNumber - 1 && "last"
- )}
- />
- ))}
- </div>
- );
-};
-
-export default Skeleton;
diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx
deleted file mode 100644
index e99a9d1b..00000000
--- a/FrontEnd/src/views/common/Spinner.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { PaletteColorType } from "@/palette";
-
-import "./Spinner.css";
-
-export interface SpinnerProps {
- size?: "sm" | "md" | "lg" | number | string;
- color?: PaletteColorType;
- className?: string;
- style?: React.CSSProperties;
-}
-
-export default function Spinner(
- props: SpinnerProps
-): React.ReactElement | null {
- const { size, color, className, style } = props;
- const calculatedSize =
- size === "sm"
- ? "18px"
- : size === "md"
- ? "30px"
- : size === "lg"
- ? "42px"
- : typeof size === "number"
- ? size
- : size == null
- ? "20px"
- : size;
- const calculatedColor = color ?? "primary";
-
- return (
- <span
- className={classnames(
- "cru-spinner",
- `cru-color-${calculatedColor}`,
- className
- )}
- style={{ width: calculatedSize, height: calculatedSize, ...style }}
- />
- );
-}
diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx
deleted file mode 100644
index 42074781..00000000
--- a/FrontEnd/src/views/common/alert/AlertHost.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import * as React from "react";
-import without from "lodash/without";
-import { useTranslation } from "react-i18next";
-import classNames from "classnames";
-
-import { alertService, AlertInfoEx, AlertInfo } from "@/services/alert";
-import { convertI18nText } from "@/common";
-
-import IconButton from "../button/IconButton";
-
-import "./alert.css";
-
-interface AutoCloseAlertProps {
- alert: AlertInfo;
- close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
- const { alert, close } = props;
- const { dismissTime } = alert;
-
- const { t } = useTranslation();
-
- const timerTag = React.useRef<number | null>(null);
- const closeHandler = React.useRef<(() => void) | null>(null);
-
- React.useEffect(() => {
- closeHandler.current = close;
- }, [close]);
-
- React.useEffect(() => {
- const tag =
- dismissTime === "never"
- ? null
- : typeof dismissTime === "number"
- ? window.setTimeout(() => closeHandler.current?.(), dismissTime)
- : window.setTimeout(() => closeHandler.current?.(), 5000);
- timerTag.current = tag;
- return () => {
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
- }, [dismissTime]);
-
- const cancelTimer = (): void => {
- const { current: tag } = timerTag;
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
-
- return (
- <div
- className={classNames(
- "m-3 cru-alert",
- "cru-" + (alert.type ?? "primary")
- )}
- onClick={cancelTimer}
- >
- <div className="cru-alert-content">
- {(() => {
- const { message, customMessage } = alert;
- if (customMessage != null) {
- return customMessage;
- } else {
- return convertI18nText(message, t);
- }
- })()}
- </div>
- <div className="cru-alert-close-button-container">
- <IconButton
- icon="x"
- className="cru-alert-close-button"
- onClick={close}
- />
- </div>
- </div>
- );
-};
-
-const AlertHost: React.FC = () => {
- const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
-
- React.useEffect(() => {
- const consume = (alert: AlertInfoEx): void => {
- setAlerts((old) => [...old, alert]);
- };
-
- alertService.registerConsumer(consume);
- return () => {
- alertService.unregisterConsumer(consume);
- };
- }, []);
-
- return (
- <div className="alert-container">
- {alerts.map((alert) => {
- return (
- <AutoCloseAlert
- key={alert.id}
- alert={alert}
- close={() => {
- setAlerts((old) => without(old, alert));
- }}
- />
- );
- })}
- </div>
- );
-};
-
-export default AlertHost;
diff --git a/FrontEnd/src/views/common/alert/alert.css b/FrontEnd/src/views/common/alert/alert.css
deleted file mode 100644
index fc15e3cb..00000000
--- a/FrontEnd/src/views/common/alert/alert.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.alert-container {
- position: fixed;
- z-index: 1040;
-}
-
-.cru-alert {
- border-radius: 5px;
- border: var(--cru-theme-color) 1px solid;
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-r1-color);
-
- display: flex;
- overflow: hidden;
-}
-
-.cru-alert-content {
- padding: 0.5em 2em;
-}
-
-.cru-alert-close-button-container {
- flex-shrink: 0;
- margin-left: auto;
- width: 2em;
- text-align: center;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: var(--cru-theme-t-color);
-}
-
-.cru-alert-close-button {
- color: var(--cru-theme-color);
-}
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css
deleted file mode 100644
index c34176f6..00000000
--- a/FrontEnd/src/views/common/button/Button.css
+++ /dev/null
@@ -1,51 +0,0 @@
-.cru-button:not(.outline) {
- color: var(--cru-theme-t-color);
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- transition: all 0.5s;
- background-color: var(--cru-theme-color);
-}
-
-.cru-button:not(.outline):hover {
- background-color: var(--cru-theme-f1-color);
-}
-
-.cru-button:not(.outline):active {
- background-color: var(--cru-theme-f2-color);
-}
-
-.cru-button:not(.outline):disabled {
- background-color: var(--cru-disable-color);
- cursor: auto;
-}
-
-.cru-button.outline {
- color: var(--cru-theme-color);
- border: var(--cru-theme-color) 1px solid;
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- transition: all 0.6s;
- background-color: white;
-}
-
-.cru-button.outline:hover {
- color: var(--cru-theme-f1-color);
- border-color: var(--cru-theme-f1-color);
- background-color: var(--cru-background-color);
-}
-
-.cru-button.outline:active {
- color: var(--cru-theme-f2-color);
- border-color: var(--cru-theme-f2-color);
- background-color: var(--cru-background-1-color);
-}
-
-.cru-button.outline:disabled {
- color: var(--cru-disable-color);
- border-color: var(--cru-disable-color);
- background-color: white;
- cursor: auto;
-}
diff --git a/FrontEnd/src/views/common/button/FlatButton.css b/FrontEnd/src/views/common/button/FlatButton.css
deleted file mode 100644
index f0d33153..00000000
--- a/FrontEnd/src/views/common/button/FlatButton.css
+++ /dev/null
@@ -1,18 +0,0 @@
-.cru-flat-button {
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- background-color: transparent;
- transition: all 0.6s;
- color: var(--cru-theme-color);
-}
-
-.cru-flat-button.disabled {
- color: var(--cru-theme-l1-color);
- cursor: default;
-}
-
-.cru-flat-button:hover:not(.disabled) {
- background-color: #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/button/IconButton.css b/FrontEnd/src/views/common/button/IconButton.css
deleted file mode 100644
index 45fb103c..00000000
--- a/FrontEnd/src/views/common/button/IconButton.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.cru-icon-button {
- color: var(--cru-theme-color);
- font-size: 1.4rem;
- background: none;
- border: none;
-}
-
-.cru-icon-button.large {
- font-size: 1.6rem;
-}
diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx
deleted file mode 100644
index fceaec27..00000000
--- a/FrontEnd/src/views/common/button/LoadingButton.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import * as React from "react";
-import classNames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { convertI18nText, I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
-
-import Spinner from "../Spinner";
-
-interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
- color?: PaletteColorType;
- text?: I18nText;
- loading?: boolean;
-}
-
-function LoadingButton(props: LoadingButtonProps): JSX.Element {
- const { t } = useTranslation();
-
- const { color, text, loading, className, children, ...otherProps } = props;
-
- if (text != null && children != null) {
- console.warn("You can't set both text and children props.");
- }
-
- return (
- <button
- className={classNames(
- "cru-" + (color ?? "primary"),
- "cru-button outline",
- className,
- )}
- {...otherProps}
- >
- {text != null ? convertI18nText(text, t) : children}
- {loading && <Spinner />}
- </button>
- );
-}
-
-export default LoadingButton;
diff --git a/FrontEnd/src/views/common/button/index.tsx b/FrontEnd/src/views/common/button/index.tsx
deleted file mode 100644
index cff5ba3f..00000000
--- a/FrontEnd/src/views/common/button/index.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import Button from "./Button";
-import FlatButton from "./FlatButton";
-import IconButton from "./IconButton";
-import LoadingButton from "./LoadingButton";
-
-export { Button, FlatButton, IconButton, LoadingButton };
diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
deleted file mode 100644
index 8c2cea5a..00000000
--- a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { convertI18nText, I18nText } from "@/common";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import Button from "../button/Button";
-import Dialog from "./Dialog";
-
-const ConfirmDialog: React.FC<{
- open: boolean;
- onClose: () => void;
- onConfirm: () => void;
- title: I18nText;
- body: I18nText;
-}> = ({ open, onClose, onConfirm, title, body }) => {
- const { t } = useTranslation();
-
- return (
- <Dialog onClose={onClose} open={open}>
- <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3>
- <hr />
- <p>{convertI18nText(body, t)}</p>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={onClose}
- />
- <Button
- text="operationDialog.confirm"
- color="danger"
- onClick={() => {
- onConfirm();
- onClose();
- }}
- />
- </div>
- </Dialog>
- );
-};
-
-export default ConfirmDialog;
diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css
deleted file mode 100644
index 21ea52fc..00000000
--- a/FrontEnd/src/views/common/dialog/Dialog.css
+++ /dev/null
@@ -1,55 +0,0 @@
-.cru-dialog-overlay {
- position: fixed;
- z-index: 1040;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(255, 255, 255, 0.92);
-
- display: flex;
- padding: 2em;
-
- overflow: auto;
-}
-
-.cru-dialog-container {
- max-width: 100%;
- min-width: 30vw;
-
- margin: auto;
-
- border: var(--cru-primary-color) 1px solid;
- border-radius: 5px;
- padding: 1.5em;
- background-color: white;
-}
-
-.cru-dialog-bottom-area {
- display: flex;
- justify-content: flex-end;
-}
-
-.cru-dialog-bottom-area > * {
- margin: 0 0.5em;
-}
-
-.cru-dialog-enter .cru-dialog-container {
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
-
-.cru-dialog-enter-active .cru-dialog-container {
- transform: scale(1, 1);
- opacity: 1;
- transition: transform 0.3s, opacity 0.3s;
- transform-origin: center;
-}
-
-.cru-dialog-exit-active .cru-dialog-container {
- transition: transform 0.3s, opacity 0.3s;
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx
deleted file mode 100644
index 923c636b..00000000
--- a/FrontEnd/src/views/common/dialog/Dialog.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { ReactNode } from "react";
-import ReactDOM from "react-dom";
-import { CSSTransition } from "react-transition-group";
-
-import "./Dialog.css";
-
-const optionalPortalElement = document.getElementById("portal");
-if (optionalPortalElement == null) {
- throw new Error("Portal element not found");
-}
-const portalElement = optionalPortalElement;
-
-interface DialogProps {
- onClose: () => void;
- open: boolean;
- children?: ReactNode;
- disableCloseOnClickOnOverlay?: boolean;
-}
-
-export default function Dialog(props: DialogProps) {
- const { open, onClose, children, disableCloseOnClickOnOverlay } = props;
-
- return ReactDOM.createPortal(
- <CSSTransition
- mountOnEnter
- unmountOnExit
- in={open}
- timeout={300}
- classNames="cru-dialog"
- >
- <div
- className="cru-dialog-overlay"
- onPointerDown={
- disableCloseOnClickOnOverlay
- ? undefined
- : () => {
- onClose();
- }
- }
- >
- <div
- className="cru-dialog-container"
- onPointerDown={(e) => e.stopPropagation()}
- >
- {children}
- </div>
- </div>
- </CSSTransition>,
- portalElement,
- );
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css
deleted file mode 100644
index 2f1fc636..00000000
--- a/FrontEnd/src/views/common/dialog/FullPageDialog.css
+++ /dev/null
@@ -1,44 +0,0 @@
-.cru-full-page {
- position: fixed;
- z-index: 1030;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: white;
- padding-top: 56px;
-}
-
-.cru-full-page-top-bar {
- height: 56px;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1;
- background-color: var(--cru-primary-color);
- display: flex;
- align-items: center;
-}
-
-.cru-full-page-content-container {
- overflow: scroll;
-}
-
-.cru-full-page-back-button {
- color: var(--cru-primary-t-color);
-}
-
-.cru-full-page-enter {
- transform: translate(100%, 0);
-}
-
-.cru-full-page-enter-active {
- transform: none;
- transition: transform 0.3s;
-}
-
-.cru-full-page-exit-active {
- transition: transform 0.3s;
- transform: translate(100%, 0);
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx
deleted file mode 100644
index 6368fc0a..00000000
--- a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as React from "react";
-import { createPortal } from "react-dom";
-import classnames from "classnames";
-import { CSSTransition } from "react-transition-group";
-
-import "./FullPageDialog.css";
-import IconButton from "../button/IconButton";
-
-export interface FullPageDialogProps {
- show: boolean;
- onBack: () => void;
- contentContainerClassName?: string;
- children: React.ReactNode;
-}
-
-const FullPageDialog: React.FC<FullPageDialogProps> = ({
- show,
- onBack,
- children,
- contentContainerClassName,
-}) => {
- return createPortal(
- <CSSTransition
- mountOnEnter
- unmountOnExit
- in={show}
- timeout={300}
- classNames="cru-full-page"
- >
- <div className="cru-full-page">
- <div className="cru-full-page-top-bar">
- <IconButton
- icon="arrow-left"
- className="ms-3 cru-full-page-back-button"
- onClick={onBack}
- />
- </div>
- <div
- className={classnames(
- "cru-full-page-content-container",
- contentContainerClassName
- )}
- >
- {children}
- </div>
- </div>
- </CSSTransition>,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- document.getElementById("portal")!
- );
-};
-
-export default FullPageDialog;
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css
deleted file mode 100644
index 2f7617d0..00000000
--- a/FrontEnd/src/views/common/dialog/OperationDialog.css
+++ /dev/null
@@ -1,25 +0,0 @@
-.cru-operation-dialog-group {
- display: block;
- margin: 0.4em 0;
-}
-
-.cru-operation-dialog-label {
- display: block;
- color: var(--cru-primary-color);
-}
-
-.cru-operation-dialog-inline-label {
- margin-inline-start: 0.5em;
-}
-
-.cru-operation-dialog-error-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-danger-color);
-}
-
-.cru-operation-dialog-helper-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
deleted file mode 100644
index 71be030a..00000000
--- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx
+++ /dev/null
@@ -1,531 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { TwitterPicker } from "react-color";
-import classNames from "classnames";
-import moment from "moment";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-
-import { PaletteColorType } from "@/palette";
-
-import Button from "../button/Button";
-import LoadingButton from "../button/LoadingButton";
-import Dialog from "./Dialog";
-
-import "./OperationDialog.css";
-
-interface DefaultErrorPromptProps {
- error?: string;
-}
-
-const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
- const { t } = useTranslation();
-
- let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>;
-
- if (props.error != null) {
- result = (
- <>
- {result}
- <p className="cru-color-danger">{props.error}</p>
- </>
- );
- }
-
- return result;
-};
-
-export interface OperationDialogTextInput {
- type: "text";
- label?: I18nText;
- password?: boolean;
- initValue?: string;
- textFieldProps?: Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
- "type" | "value" | "onChange"
- >;
- helperText?: string;
-}
-
-export interface OperationDialogBoolInput {
- type: "bool";
- label: I18nText;
- initValue?: boolean;
- helperText?: string;
-}
-
-export interface OperationDialogSelectInputOption {
- value: string;
- label: I18nText;
- icon?: React.ReactElement;
-}
-
-export interface OperationDialogSelectInput {
- type: "select";
- label: I18nText;
- options: OperationDialogSelectInputOption[];
- initValue?: string;
-}
-
-export interface OperationDialogColorInput {
- type: "color";
- label?: I18nText;
- initValue?: string | null;
- canBeNull?: boolean;
-}
-
-export interface OperationDialogDateTimeInput {
- type: "datetime";
- label?: I18nText;
- initValue?: string;
- helperText?: string;
-}
-
-export type OperationDialogInput =
- | OperationDialogTextInput
- | OperationDialogBoolInput
- | OperationDialogSelectInput
- | OperationDialogColorInput
- | OperationDialogDateTimeInput;
-
-interface OperationInputTypeStringToValueTypeMap {
- text: string;
- bool: boolean;
- select: string;
- color: string | null;
- datetime: string;
-}
-
-type MapOperationInputTypeStringToValueType<Type> =
- Type extends keyof OperationInputTypeStringToValueTypeMap
- ? OperationInputTypeStringToValueTypeMap[Type]
- : never;
-
-type MapOperationInputInfoValueType<T> = T extends OperationDialogInput
- ? MapOperationInputTypeStringToValueType<T["type"]>
- : T;
-
-const initValueMapperMap: {
- [T in OperationDialogInput as T["type"]]: (
- item: T
- ) => MapOperationInputInfoValueType<T>;
-} = {
- bool: (item) => item.initValue ?? false,
- color: (item) => item.initValue ?? null,
- datetime: (item) => {
- if (item.initValue != null) {
- return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss");
- } else {
- return "";
- }
- },
- select: (item) => item.initValue ?? item.options[0].value,
- text: (item) => item.initValue ?? "",
-};
-
-type MapOperationInputInfoValueTypeList<
- Tuple extends readonly OperationDialogInput[]
-> = {
- [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>;
-} & { length: Tuple["length"] };
-
-export type OperationInputError =
- | {
- [index: number]: I18nText | null | undefined;
- }
- | null
- | undefined;
-
-const isNoError = (error: OperationInputError): boolean => {
- if (error == null) return true;
- for (const key in error) {
- if (error[key] != null) return false;
- }
- return true;
-};
-
-export interface OperationDialogProps<
- TData,
- OperationInputInfoList extends readonly OperationDialogInput[]
-> {
- open: boolean;
- onClose: () => void;
- title: I18nText | (() => React.ReactNode);
- themeColor?: PaletteColorType;
- onProcess: (
- inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
- ) => Promise<TData>;
- inputScheme?: OperationInputInfoList;
- inputValidator?: (
- inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
- ) => OperationInputError;
- inputPrompt?: I18nText | (() => React.ReactNode);
- processPrompt?: () => React.ReactNode;
- successPrompt?: (data: TData) => React.ReactNode;
- failurePrompt?: (error: unknown) => React.ReactNode;
- onSuccessAndClose?: (data: TData) => void;
-}
-
-const OperationDialog = <
- TData,
- OperationInputInfoList extends readonly OperationDialogInput[]
->(
- props: OperationDialogProps<TData, OperationInputInfoList>
-): React.ReactElement => {
- const inputScheme = (props.inputScheme ??
- []) as readonly OperationDialogInput[];
-
- const { t } = useTranslation();
-
- type Step =
- | { type: "input" }
- | { type: "process" }
- | {
- type: "success";
- data: TData;
- }
- | {
- type: "failure";
- data: unknown;
- };
- const [step, setStep] = useState<Step>({ type: "input" });
-
- type ValueType = boolean | string | null | undefined;
-
- const [values, setValues] = useState<ValueType[]>(
- inputScheme.map((item) => {
- if (item.type in initValueMapperMap) {
- return (
- initValueMapperMap[item.type] as (
- i: OperationDialogInput
- ) => ValueType
- )(item);
- } else {
- throw new UiLogicError("Unknown input scheme.");
- }
- })
- );
- const [dirtyList, setDirtyList] = useState<boolean[]>(() =>
- inputScheme.map(() => false)
- );
- const [inputError, setInputError] = useState<OperationInputError>();
-
- const close = (): void => {
- if (step.type !== "process") {
- props.onClose();
- if (step.type === "success" && props.onSuccessAndClose) {
- props.onSuccessAndClose(step.data);
- }
- } else {
- console.log("Attempt to close modal when processing.");
- }
- };
-
- const onConfirm = (): void => {
- setStep({ type: "process" });
- props
- .onProcess(
- values.map((v, index) => {
- if (inputScheme[index].type === "datetime" && v !== "")
- return new Date(v as string).toISOString();
- else return v;
- }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
- )
- .then(
- (d) => {
- setStep({
- type: "success",
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: "failure",
- data: e,
- });
- }
- );
- };
-
- let body: React.ReactNode;
- if (step.type === "input" || step.type === "process") {
- const process = step.type === "process";
-
- let inputPrompt =
- typeof props.inputPrompt === "function"
- ? props.inputPrompt()
- : convertI18nText(props.inputPrompt, t);
- inputPrompt = <h6>{inputPrompt}</h6>;
-
- const validate = (values: ValueType[]): boolean => {
- const { inputValidator } = props;
- if (inputValidator != null) {
- const result = inputValidator(
- values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
- );
- setInputError(result);
- return isNoError(result);
- }
- return true;
- };
-
- const updateValue = (index: number, newValue: ValueType): void => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- setValues(newValues);
- if (dirtyList[index] === false) {
- const newDirtyList = dirtyList.slice();
- newDirtyList[index] = true;
- setDirtyList(newDirtyList);
- }
- validate(newValues);
- };
-
- const canProcess = isNoError(inputError);
-
- body = (
- <>
- <div>
- {inputPrompt}
- {inputScheme.map((item, index) => {
- const value = values[index];
- const error: string | null =
- dirtyList[index] && inputError != null
- ? convertI18nText(inputError[index], t)
- : null;
-
- if (item.type === "text") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- {item.label && (
- <label className="cru-operation-dialog-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type={item.password === true ? "password" : "text"}
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={process}
- />
- {error != null && (
- <div className="cru-operation-dialog-error-text">
- {error}
- </div>
- )}
- {item.helperText && (
- <div className="cru-operation-dialog-helper-text">
- {t(item.helperText)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "bool") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- <input
- type="checkbox"
- checked={value as boolean}
- onChange={(event) => {
- updateValue(index, event.currentTarget.checked);
- }}
- disabled={process}
- />
- <label className="cru-operation-dialog-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- {error != null && (
- <div className="cru-operation-dialog-error-text">
- {error}
- </div>
- )}
- {item.helperText && (
- <div className="cru-operation-dialog-helper-text">
- {t(item.helperText)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "select") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- <label className="cru-operation-dialog-label">
- {convertI18nText(item.label, t)}
- </label>
- <select
- value={value as string}
- onChange={(event) => {
- updateValue(index, event.target.value);
- }}
- disabled={process}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {convertI18nText(option.label, t)}
- </option>
- );
- })}
- </select>
- </div>
- );
- } else if (item.type === "color") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- {item.canBeNull ? (
- <input
- type="checkbox"
- checked={value !== null}
- onChange={(event) => {
- if (event.currentTarget.checked) {
- updateValue(index, "#007bff");
- } else {
- updateValue(index, null);
- }
- }}
- disabled={process}
- />
- ) : null}
- <label className="cru-operation-dialog-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- {value !== null && (
- <TwitterPicker
- color={value as string}
- triangle="hide"
- onChange={(result) => updateValue(index, result.hex)}
- />
- )}
- </div>
- );
- } else if (item.type === "datetime") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- {item.label && (
- <label className="cru-operation-dialog-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type="datetime-local"
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={process}
- />
- {error != null && <div>{error}</div>}
- </div>
- );
- }
- })}
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={close}
- disabled={process}
- />
- <LoadingButton
- color={props.themeColor}
- loading={process}
- disabled={!canProcess}
- onClick={() => {
- setDirtyList(inputScheme.map(() => true));
- if (validate(values)) {
- onConfirm();
- }
- }}
- >
- {t("operationDialog.confirm")}
- </LoadingButton>
- </div>
- </>
- );
- } else {
- let content: React.ReactNode;
- const result = step;
- if (result.type === "success") {
- content =
- props.successPrompt?.(result.data) ?? t("operationDialog.success");
- if (typeof content === "string")
- content = <p className="cru-color-success">{content}</p>;
- } else {
- content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
- if (typeof content === "string")
- content = <DefaultErrorPrompt error={content} />;
- }
- body = (
- <>
- <div>{content}</div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button text="operationDialog.ok" color="primary" onClick={close} />
- </div>
- </>
- );
- }
-
- const title =
- typeof props.title === "function"
- ? props.title()
- : convertI18nText(props.title, t);
-
- return (
- <Dialog open={props.open} onClose={close}>
- <h3
- className={
- props.themeColor != null
- ? "cru-color-" + props.themeColor
- : "cru-color-primary"
- }
- >
- {title}
- </h3>
- <hr />
- {body}
- </Dialog>
- );
-};
-
-export default OperationDialog;
diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css
deleted file mode 100644
index 111a3ec0..00000000
--- a/FrontEnd/src/views/common/index.css
+++ /dev/null
@@ -1,293 +0,0 @@
-:root {
- --cru-background-color: #f8f9fa;
- --cru-background-1-color: #e9ecef;
- --cru-background-2-color: #dee2e6;
-
- --cru-disable-color: #ced4da;
-
- /*
- --cru-primary-color: rgb(0, 123, 255);
- --cru-primary-l1-color: rgb(26, 136, 255);
- --cru-primary-l2-color: rgb(51, 149, 255);
- --cru-primary-l3-color: rgb(77, 163, 255);
- --cru-primary-d1-color: rgb(0, 111, 230);
- --cru-primary-d2-color: rgb(0, 98, 204);
- --cru-primary-d3-color: rgb(0, 86, 179);
- --cru-primary-f1-color: rgb(0, 111, 230);
- --cru-primary-f2-color: rgb(0, 98, 204);
- --cru-primary-f3-color: rgb(0, 86, 179);
- --cru-primary-r1-color: rgb(26, 136, 255);
- --cru-primary-r2-color: rgb(51, 149, 255);
- --cru-primary-r3-color: rgb(77, 163, 255);
- --cru-primary-t-color: rgb(255, 255, 255);
- --cru-primary-t1-color: rgb(230, 230, 230);
- --cru-primary-t2-color: rgb(204, 204, 204);
- --cru-primary-t3-color: rgb(179, 179, 179);
- --cru-primary-enhance-color: rgb(77, 163, 255);
- --cru-primary-enhance-l1-color: rgb(94, 172, 255);
- --cru-primary-enhance-l2-color: rgb(112, 181, 255);
- --cru-primary-enhance-l3-color: rgb(130, 190, 255);
- --cru-primary-enhance-d1-color: rgb(43, 145, 255);
- --cru-primary-enhance-d2-color: rgb(10, 128, 255);
- --cru-primary-enhance-d3-color: rgb(0, 112, 232);
- --cru-primary-enhance-f1-color: rgb(94, 172, 255);
- --cru-primary-enhance-f2-color: rgb(112, 181, 255);
- --cru-primary-enhance-f3-color: rgb(130, 190, 255);
- --cru-primary-enhance-r1-color: rgb(43, 145, 255);
- --cru-primary-enhance-r2-color: rgb(10, 128, 255);
- --cru-primary-enhance-r3-color: rgb(0, 112, 232);
- --cru-primary-enhance-t-color: rgb(0, 0, 0);
- --cru-primary-enhance-t1-color: rgb(26, 26, 26);
- --cru-primary-enhance-t2-color: rgb(51, 51, 51);
- --cru-primary-enhance-t3-color: rgb(77, 77, 77);
- --cru-secondary-color: rgb(128, 128, 128);
- --cru-secondary-l1-color: rgb(141, 141, 141);
- --cru-secondary-l2-color: rgb(153, 153, 153);
- --cru-secondary-l3-color: rgb(166, 166, 166);
- --cru-secondary-d1-color: rgb(115, 115, 115);
- --cru-secondary-d2-color: rgb(102, 102, 102);
- --cru-secondary-d3-color: rgb(90, 90, 90);
- --cru-secondary-f1-color: rgb(115, 115, 115);
- --cru-secondary-f2-color: rgb(102, 102, 102);
- --cru-secondary-f3-color: rgb(90, 90, 90);
- --cru-secondary-r1-color: rgb(141, 141, 141);
- --cru-secondary-r2-color: rgb(153, 153, 153);
- --cru-secondary-r3-color: rgb(166, 166, 166);
- --cru-secondary-t-color: rgb(255, 255, 255);
- --cru-secondary-t1-color: rgb(230, 230, 230);
- --cru-secondary-t2-color: rgb(204, 204, 204);
- --cru-secondary-t3-color: rgb(179, 179, 179);
- --cru-danger-color: rgb(255, 0, 0);
- --cru-danger-l1-color: rgb(255, 26, 26);
- --cru-danger-l2-color: rgb(255, 51, 51);
- --cru-danger-l3-color: rgb(255, 77, 77);
- --cru-danger-d1-color: rgb(230, 0, 0);
- --cru-danger-d2-color: rgb(204, 0, 0);
- --cru-danger-d3-color: rgb(179, 0, 0);
- --cru-danger-f1-color: rgb(230, 0, 0);
- --cru-danger-f2-color: rgb(204, 0, 0);
- --cru-danger-f3-color: rgb(179, 0, 0);
- --cru-danger-r1-color: rgb(255, 26, 26);
- --cru-danger-r2-color: rgb(255, 51, 51);
- --cru-danger-r3-color: rgb(255, 77, 77);
- --cru-danger-t-color: rgb(255, 255, 255);
- --cru-danger-t1-color: rgb(230, 230, 230);
- --cru-danger-t2-color: rgb(204, 204, 204);
- --cru-danger-t3-color: rgb(179, 179, 179);
- --cru-success-color: rgb(0, 128, 0);
- --cru-success-l1-color: rgb(0, 166, 0);
- --cru-success-l2-color: rgb(0, 204, 0);
- --cru-success-l3-color: rgb(0, 243, 0);
- --cru-success-d1-color: rgb(0, 115, 0);
- --cru-success-d2-color: rgb(0, 102, 0);
- --cru-success-d3-color: rgb(0, 90, 0);
- --cru-success-f1-color: rgb(0, 115, 0);
- --cru-success-f2-color: rgb(0, 102, 0);
- --cru-success-f3-color: rgb(0, 90, 0);
- --cru-success-r1-color: rgb(0, 166, 0);
- --cru-success-r2-color: rgb(0, 204, 0);
- --cru-success-r3-color: rgb(0, 243, 0);
- --cru-success-t-color: rgb(255, 255, 255);
- --cru-success-t1-color: rgb(230, 230, 230);
- --cru-success-t2-color: rgb(204, 204, 204);
- --cru-success-t3-color: rgb(179, 179, 179);
- */
-}
-
-.cru-primary {
- --cru-theme-color: var(--cru-primary-color);
- --cru-theme-l1-color: var(--cru-primary-l1-color);
- --cru-theme-l2-color: var(--cru-primary-l2-color);
- --cru-theme-l3-color: var(--cru-primary-l3-color);
- --cru-theme-d1-color: var(--cru-primary-d1-color);
- --cru-theme-d2-color: var(--cru-primary-d2-color);
- --cru-theme-d3-color: var(--cru-primary-d3-color);
- --cru-theme-f1-color: var(--cru-primary-f1-color);
- --cru-theme-f2-color: var(--cru-primary-f2-color);
- --cru-theme-f3-color: var(--cru-primary-f3-color);
- --cru-theme-r1-color: var(--cru-primary-r1-color);
- --cru-theme-r2-color: var(--cru-primary-r2-color);
- --cru-theme-r3-color: var(--cru-primary-r3-color);
- --cru-theme-t-color: var(--cru-primary-t-color);
- --cru-theme-t1-color: var(--cru-primary-t1-color);
- --cru-theme-t2-color: var(--cru-primary-t2-color);
- --cru-theme-t3-color: var(--cru-primary-t3-color);
-}
-
-.cru-primary-enhance {
- --cru-theme-color: var(--cru-primary-enhance-color);
- --cru-theme-l1-color: var(--cru-primary-enhance-l1-color);
- --cru-theme-l2-color: var(--cru-primary-enhance-l2-color);
- --cru-theme-l3-color: var(--cru-primary-enhance-l3-color);
- --cru-theme-d1-color: var(--cru-primary-enhance-d1-color);
- --cru-theme-d2-color: var(--cru-primary-enhance-d2-color);
- --cru-theme-d3-color: var(--cru-primary-enhance-d3-color);
- --cru-theme-f1-color: var(--cru-primary-enhance-f1-color);
- --cru-theme-f2-color: var(--cru-primary-enhance-f2-color);
- --cru-theme-f3-color: var(--cru-primary-enhance-f3-color);
- --cru-theme-r1-color: var(--cru-primary-enhance-r1-color);
- --cru-theme-r2-color: var(--cru-primary-enhance-r2-color);
- --cru-theme-r3-color: var(--cru-primary-enhance-r3-color);
- --cru-theme-t-color: var(--cru-primary-enhance-t-color);
- --cru-theme-t1-color: var(--cru-primary-enhance-t1-color);
- --cru-theme-t2-color: var(--cru-primary-enhance-t2-color);
- --cru-theme-t3-color: var(--cru-primary-enhance-t3-color);
-}
-
-.cru-secondary {
- --cru-theme-color: var(--cru-secondary-color);
- --cru-theme-l1-color: var(--cru-secondary-l1-color);
- --cru-theme-l2-color: var(--cru-secondary-l2-color);
- --cru-theme-l3-color: var(--cru-secondary-l3-color);
- --cru-theme-d1-color: var(--cru-secondary-d1-color);
- --cru-theme-d2-color: var(--cru-secondary-d2-color);
- --cru-theme-d3-color: var(--cru-secondary-d3-color);
- --cru-theme-f1-color: var(--cru-secondary-f1-color);
- --cru-theme-f2-color: var(--cru-secondary-f2-color);
- --cru-theme-f3-color: var(--cru-secondary-f3-color);
- --cru-theme-r1-color: var(--cru-secondary-r1-color);
- --cru-theme-r2-color: var(--cru-secondary-r2-color);
- --cru-theme-r3-color: var(--cru-secondary-r3-color);
- --cru-theme-t-color: var(--cru-secondary-t-color);
- --cru-theme-t1-color: var(--cru-secondary-t1-color);
- --cru-theme-t2-color: var(--cru-secondary-t2-color);
- --cru-theme-t3-color: var(--cru-secondary-t3-color);
-}
-
-.cru-success {
- --cru-theme-color: var(--cru-success-color);
- --cru-theme-l1-color: var(--cru-success-l1-color);
- --cru-theme-l2-color: var(--cru-success-l2-color);
- --cru-theme-l3-color: var(--cru-success-l3-color);
- --cru-theme-d1-color: var(--cru-success-d1-color);
- --cru-theme-d2-color: var(--cru-success-d2-color);
- --cru-theme-d3-color: var(--cru-success-d3-color);
- --cru-theme-f1-color: var(--cru-success-f1-color);
- --cru-theme-f2-color: var(--cru-success-f2-color);
- --cru-theme-f3-color: var(--cru-success-f3-color);
- --cru-theme-r1-color: var(--cru-success-r1-color);
- --cru-theme-r2-color: var(--cru-success-r2-color);
- --cru-theme-r3-color: var(--cru-success-r3-color);
- --cru-theme-t-color: var(--cru-success-t-color);
- --cru-theme-t1-color: var(--cru-success-t1-color);
- --cru-theme-t2-color: var(--cru-success-t2-color);
- --cru-theme-t3-color: var(--cru-success-t3-color);
-}
-
-.cru-danger {
- --cru-theme-color: var(--cru-danger-color);
- --cru-theme-l1-color: var(--cru-danger-l1-color);
- --cru-theme-l2-color: var(--cru-danger-l2-color);
- --cru-theme-l3-color: var(--cru-danger-l3-color);
- --cru-theme-d1-color: var(--cru-danger-d1-color);
- --cru-theme-d2-color: var(--cru-danger-d2-color);
- --cru-theme-d3-color: var(--cru-danger-d3-color);
- --cru-theme-f1-color: var(--cru-danger-f1-color);
- --cru-theme-f2-color: var(--cru-danger-f2-color);
- --cru-theme-f3-color: var(--cru-danger-f3-color);
- --cru-theme-r1-color: var(--cru-danger-r1-color);
- --cru-theme-r2-color: var(--cru-danger-r2-color);
- --cru-theme-r3-color: var(--cru-danger-r3-color);
- --cru-theme-t-color: var(--cru-danger-t-color);
- --cru-theme-t1-color: var(--cru-danger-t1-color);
- --cru-theme-t2-color: var(--cru-danger-t2-color);
- --cru-theme-t3-color: var(--cru-danger-t3-color);
-}
-
-.cru-color-primary {
- color: var(--cru-primary-color);
-}
-
-.cru-color-primary-enhance {
- color: var(--cru-primary-enhance-color);
-}
-
-.cru-color-secondary {
- color: var(--cru-secondary-color);
-}
-
-.cru-color-success {
- color: var(--cru-success-color);
-}
-
-.cru-color-danger {
- color: var(--cru-danger-color);
-}
-
-.cru-text-center {
- text-align: center;
-}
-
-.cru-text-end {
- text-align: end;
-}
-
-.cru-float-left {
- float: left;
-}
-
-.cru-float-right {
- float: right;
-}
-
-.cru-align-text-bottom {
- vertical-align: text-bottom;
-}
-
-.cru-align-middle {
- vertical-align: middle;
-}
-
-.cru-clearfix::after {
- clear: both;
-}
-
-.cru-fill-parent {
- width: 100%;
- height: 100%;
-}
-
-.cru-avatar {
- width: 60px;
- height: 60px;
-}
-
-.cru-avatar.large {
- width: 100px;
- height: 100px;
-}
-
-.cru-avatar.small {
- width: 40px;
- height: 40px;
-}
-
-.cru-round {
- border-radius: 50%;
-}
-
-.cru-tab-pages-action-area {
- display: flex;
- align-items: center;
-}
-
-.alert-container {
- position: fixed;
- z-index: 1070;
-}
-
-@media (min-width: 576px) {
- .alert-container {
- bottom: 0;
- right: 0;
- }
-}
-
-@media (max-width: 575.98px) {
- .alert-container {
- bottom: 0;
- right: 0;
- left: 0;
- text-align: center;
- }
-} \ No newline at end of file
diff --git a/FrontEnd/src/views/common/input/InputPanel.css b/FrontEnd/src/views/common/input/InputPanel.css
deleted file mode 100644
index f9d6ac8b..00000000
--- a/FrontEnd/src/views/common/input/InputPanel.css
+++ /dev/null
@@ -1,25 +0,0 @@
-.cru-input-panel-group {
- display: block;
- margin: 0.4em 0;
-}
-
-.cru-input-panel-label {
- display: block;
- color: var(--cru-primary-color);
-}
-
-.cru-input-panel-inline-label {
- margin-inline-start: 0.5em;
-}
-
-.cru-input-panel-error-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-danger-color);
-}
-
-.cru-input-panel-helper-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/input/InputPanel.tsx b/FrontEnd/src/views/common/input/InputPanel.tsx
deleted file mode 100644
index 234ed267..00000000
--- a/FrontEnd/src/views/common/input/InputPanel.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import * as React from "react";
-import classNames from "classnames";
-import { useTranslation } from "react-i18next";
-import { TwitterPicker } from "react-color";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import "./InputPanel.css";
-
-export interface TextInput {
- type: "text";
- label?: I18nText;
- helper?: I18nText;
- password?: boolean;
-}
-
-export interface BoolInput {
- type: "bool";
- label: I18nText;
- helper?: I18nText;
-}
-
-export interface SelectInputOption {
- value: string;
- label: I18nText;
- icon?: React.ReactElement;
-}
-
-export interface SelectInput {
- type: "select";
- label: I18nText;
- options: SelectInputOption[];
-}
-
-export interface ColorInput {
- type: "color";
- label?: I18nText;
-}
-
-export interface DateTimeInput {
- type: "datetime";
- label?: I18nText;
- helper?: I18nText;
-}
-
-export type Input =
- | TextInput
- | BoolInput
- | SelectInput
- | ColorInput
- | DateTimeInput;
-
-interface InputTypeToValueTypeMap {
- text: string;
- bool: boolean;
- select: string;
- color: string;
- datetime: string;
-}
-
-type ValueTypes = InputTypeToValueTypeMap[keyof InputTypeToValueTypeMap];
-
-type MapInputTypeToValueType<Type> = Type extends keyof InputTypeToValueTypeMap
- ? InputTypeToValueTypeMap[Type]
- : never;
-
-type MapInputToValueType<T> = T extends Input
- ? MapInputTypeToValueType<T["type"]>
- : T;
-
-type MapInputListToValueTypeList<Tuple extends readonly Input[]> = {
- [Index in keyof Tuple]: MapInputToValueType<Tuple[Index]>;
-} & { length: Tuple["length"] };
-
-export type InputPanelError = {
- [index: number]: I18nText | null | undefined;
-};
-
-export function hasError(e: InputPanelError | null | undefined): boolean {
- if (e == null) return false;
- for (const key of Object.keys(e)) {
- if (e[key as unknown as number] != null) return true;
- }
- return false;
-}
-
-export interface InputPanelProps<InputList extends readonly Input[]> {
- scheme: InputList;
- values: MapInputListToValueTypeList<InputList>;
- onChange: (
- values: MapInputListToValueTypeList<InputList>,
- index: number
- ) => void;
- error?: InputPanelError;
- disable?: boolean;
-}
-
-const InputPanel = <InputList extends readonly Input[]>(
- props: InputPanelProps<InputList>
-): React.ReactElement => {
- const { values, onChange, scheme, error, disable } = props;
-
- const { t } = useTranslation();
-
- const updateValue = (index: number, newValue: ValueTypes): void => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- onChange(
- newValues as unknown as MapInputListToValueTypeList<InputList>,
- index
- );
- };
-
- return (
- <div>
- {scheme.map((item, index) => {
- const v = values[index];
- const e: string | null = convertI18nText(error?.[index], t);
-
- if (item.type === "text") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- {item.label && (
- <label className="cru-input-panel-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type={item.password === true ? "password" : "text"}
- value={v as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={disable}
- />
- {e && <div className="cru-input-panel-error-text">{e}</div>}
- {item.helper && (
- <div className="cru-input-panel-helper-text">
- {convertI18nText(item.helper, t)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "bool") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- <input
- type="checkbox"
- checked={v as boolean}
- onChange={(event) => {
- const value = event.currentTarget.checked;
- updateValue(index, value);
- }}
- disabled={disable}
- />
- <label className="cru-input-panel-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- {e != null && (
- <div className="cru-input-panel-error-text">{e}</div>
- )}
- {item.helper && (
- <div className="cru-input-panel-helper-text">
- {convertI18nText(item.helper, t)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "select") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- <label className="cru-input-panel-label">
- {convertI18nText(item.label, t)}
- </label>
- <select
- value={v as string}
- onChange={(event) => {
- const value = event.target.value;
- updateValue(index, value);
- }}
- disabled={disable}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {convertI18nText(option.label, t)}
- </option>
- );
- })}
- </select>
- </div>
- );
- } else if (item.type === "color") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- <label className="cru-input-panel-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- <TwitterPicker
- color={v as string}
- triangle="hide"
- onChange={(result) => updateValue(index, result.hex)}
- />
- </div>
- );
- } else if (item.type === "datetime") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- {item.label && (
- <label className="cru-input-panel-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type="datetime-local"
- value={v as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={disable}
- />
- {e != null && (
- <div className="cru-input-panel-error-text">{e}</div>
- )}
- {item.helper && (
- <div className="cru-input-panel-helper-text">
- {convertI18nText(item.helper, t)}
- </div>
- )}
- </div>
- );
- }
- })}
- </div>
- );
-};
-
-export default InputPanel;
diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css
deleted file mode 100644
index c3fa82c4..00000000
--- a/FrontEnd/src/views/common/menu/Menu.css
+++ /dev/null
@@ -1,24 +0,0 @@
-.cru-menu {
- min-width: 200px;
-}
-
-.cru-menu-item {
- font-size: 1em;
- padding: 0.5em 1.5em;
- cursor: pointer;
- transition: all 0.5s;
- color: var(--cru-theme-color);
-}
-
-.cru-menu-item:hover {
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-color);
-}
-
-.cru-menu-item-icon {
- margin-right: 1em;
-}
-
-.cru-menu-divider {
- border-top: 1px solid #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/menu/Menu.tsx b/FrontEnd/src/views/common/menu/Menu.tsx
deleted file mode 100644
index de3b1664..00000000
--- a/FrontEnd/src/views/common/menu/Menu.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { convertI18nText, I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
-
-import "./Menu.css";
-
-export type MenuItem =
- | {
- type: "divider";
- }
- | {
- type: "button";
- text: I18nText;
- iconClassName?: string;
- color?: PaletteColorType;
- onClick: () => void;
- };
-
-export type MenuItems = MenuItem[];
-
-export type MenuProps = {
- items: MenuItems;
- onItemClicked?: () => void;
- className?: string;
- style?: React.CSSProperties;
-};
-
-export default function _Menu({
- items,
- onItemClicked,
- className,
- style,
-}: MenuProps): React.ReactElement | null {
- const { t } = useTranslation();
-
- return (
- <div className={classnames("cru-menu", className)} style={style}>
- {items.map((item, index) => {
- if (item.type === "divider") {
- return <div key={index} className="cru-menu-divider" />;
- } else {
- return (
- <div
- key={index}
- className={classnames(
- "cru-menu-item",
- `cru-${item.color ?? "primary"}`
- )}
- onClick={() => {
- item.onClick();
- onItemClicked?.();
- }}
- >
- {item.iconClassName != null ? (
- <i
- className={classnames(
- item.iconClassName,
- "cru-menu-item-icon"
- )}
- />
- ) : null}
- {convertI18nText(item.text, t)}
- </div>
- );
- }
- })}
- </div>
- );
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.css b/FrontEnd/src/views/common/menu/PopupMenu.css
deleted file mode 100644
index f6654f68..00000000
--- a/FrontEnd/src/views/common/menu/PopupMenu.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.cru-popup-menu-menu-container {
- z-index: 1040;
- border-radius: 5px;
- border: var(--cru-primary-color) 1px solid;
- background-color: white;
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.tsx b/FrontEnd/src/views/common/menu/PopupMenu.tsx
deleted file mode 100644
index 74ca7aba..00000000
--- a/FrontEnd/src/views/common/menu/PopupMenu.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import classNames from "classnames";
-import * as React from "react";
-import { createPortal } from "react-dom";
-import { usePopper } from "react-popper";
-
-import { useClickOutside } from "@/utilities/hooks";
-
-import Menu, { MenuItems } from "./Menu";
-
-import "./PopupMenu.css";
-
-export interface PopupMenuProps {
- items: MenuItems;
- children?: React.ReactNode;
- containerClassName?: string;
- containerStyle?: React.CSSProperties;
-}
-
-const PopupMenu: React.FC<PopupMenuProps> = ({
- items,
- children,
- containerClassName,
- containerStyle,
-}) => {
- const [show, setShow] = React.useState<boolean>(false);
-
- const [referenceElement, setReferenceElement] =
- React.useState<HTMLDivElement | null>(null);
- const [popperElement, setPopperElement] =
- React.useState<HTMLDivElement | null>(null);
- const { styles, attributes } = usePopper(referenceElement, popperElement);
-
- useClickOutside(popperElement, () => setShow(false), true);
-
- return (
- <>
- <div
- ref={setReferenceElement}
- className={classNames(
- "cru-popup-menu-trigger-container",
- containerClassName
- )}
- style={containerStyle}
- onClick={() => setShow(true)}
- >
- {children}
- </div>
- {show
- ? createPortal(
- <div
- ref={setPopperElement}
- className="cru-popup-menu-menu-container"
- style={styles.popper}
- {...attributes.popper}
- >
- <Menu
- items={items}
- onItemClicked={() => {
- setShow(false);
- }}
- />
- </div>,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- document.getElementById("portal")!
- )
- : null}
- </>
- );
-};
-
-export default PopupMenu;
diff --git a/FrontEnd/src/views/common/tab/TabPages.tsx b/FrontEnd/src/views/common/tab/TabPages.tsx
deleted file mode 100644
index cdb988e0..00000000
--- a/FrontEnd/src/views/common/tab/TabPages.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import * as React from "react";
-
-import { I18nText, UiLogicError } from "@/common";
-
-import Tabs from "./Tabs";
-
-export interface TabPage {
- name: string;
- text: I18nText;
- page: React.ReactNode;
-}
-
-export interface TabPagesProps {
- pages: TabPage[];
- actions?: React.ReactNode;
- dense?: boolean;
- className?: string;
- style?: React.CSSProperties;
- navClassName?: string;
- navStyle?: React.CSSProperties;
- pageContainerClassName?: string;
- pageContainerStyle?: React.CSSProperties;
-}
-
-const TabPages: React.FC<TabPagesProps> = ({
- pages,
- actions,
- dense,
- className,
- style,
- navClassName,
- navStyle,
- pageContainerClassName,
- pageContainerStyle,
-}) => {
- if (pages.length === 0) {
- throw new UiLogicError("Page list can't be empty.");
- }
-
- const [tab, setTab] = React.useState<string>(pages[0].name);
-
- const currentPage = pages.find((p) => p.name === tab);
-
- if (currentPage == null) {
- throw new UiLogicError("Current tab value is bad.");
- }
-
- return (
- <div className={className} style={style}>
- <Tabs
- tabs={pages.map((page) => ({
- name: page.name,
- text: page.text,
- onClick: () => {
- setTab(page.name);
- },
- }))}
- dense={dense}
- activeTabName={tab}
- className={navClassName}
- style={navStyle}
- actions={actions}
- />
- <div className={pageContainerClassName} style={pageContainerStyle}>
- {currentPage.page}
- </div>
- </div>
- );
-};
-
-export default TabPages;
diff --git a/FrontEnd/src/views/common/tab/Tabs.css b/FrontEnd/src/views/common/tab/Tabs.css
deleted file mode 100644
index 395d16a7..00000000
--- a/FrontEnd/src/views/common/tab/Tabs.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.cru-nav {
- border-bottom: var(--cru-primary-color) 1px solid;
- display: flex;
-}
-
-.cru-nav-item {
- color: var(--cru-primary-color);
- border: var(--cru-background-2-color) 0.5px solid;
- border-bottom: none;
- padding: 0.5em 1.5em;
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- transition: all 0.5s;
- cursor: pointer;
-}
-
-.cru-nav.dense .cru-nav-item {
- padding: 0.2em 1em;
-}
-
-.cru-nav-item:hover {
- background-color: var(--cru-background-1-color);
-}
-
-.cru-nav-item.active {
- color: var(--cru-primary-t-color);
- background-color: var(--cru-primary-color);
- border-color: var(--cru-primary-color);
-}
-
-.cru-nav-action-area {
- margin-left: auto;
-}
diff --git a/FrontEnd/src/views/common/tab/Tabs.tsx b/FrontEnd/src/views/common/tab/Tabs.tsx
deleted file mode 100644
index 3e3ef6fa..00000000
--- a/FrontEnd/src/views/common/tab/Tabs.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as React from "react";
-import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import classnames from "classnames";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import "./Tabs.css";
-
-export interface Tab {
- name: string;
- text: I18nText;
- link?: string;
- onClick?: () => void;
-}
-
-export interface TabsProps {
- activeTabName?: string;
- actions?: React.ReactNode;
- dense?: boolean;
- tabs: Tab[];
- className?: string;
- style?: React.CSSProperties;
-}
-
-export default function Tabs(props: TabsProps): React.ReactElement | null {
- const { tabs, activeTabName, className, style, dense, actions } = props;
-
- const { t } = useTranslation();
-
- return (
- <div
- className={classnames("cru-nav", dense && "dense", className)}
- style={style}
- >
- {tabs.map((tab) => {
- const active = activeTabName === tab.name;
- const className = classnames("cru-nav-item", active && "active");
-
- if (tab.link != null) {
- return (
- <Link
- key={tab.name}
- to={tab.link}
- onClick={tab.onClick}
- className={className}
- >
- {convertI18nText(tab.text, t)}
- </Link>
- );
- } else {
- return (
- <span key={tab.name} onClick={tab.onClick} className={className}>
- {convertI18nText(tab.text, t)}
- </span>
- );
- }
- })}
- <div className="cru-nav-action-area">{actions}</div>
- </div>
- );
-}
diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx
deleted file mode 100644
index fcff8c69..00000000
--- a/FrontEnd/src/views/common/user/UserAvatar.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react";
-
-import { getHttpUserClient } from "@/http/user";
-
-export interface UserAvatarProps
- extends React.ImgHTMLAttributes<HTMLImageElement> {
- username: string;
-}
-
-const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => {
- return (
- <img
- src={getHttpUserClient().generateAvatarUrl(username)}
- {...otherProps}
- />
- );
-};
-
-export default UserAvatar;
diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx
deleted file mode 100644
index fbcdc9b0..00000000
--- a/FrontEnd/src/views/home/TimelineListView.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { Link } from "react-router-dom";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import { TimelineBookmark } from "@/http/bookmark";
-
-import IconButton from "../common/button/IconButton";
-
-interface TimelineListItemProps {
- timeline: TimelineBookmark;
-}
-
-const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => {
- return (
- <div className="home-timeline-list-item home-timeline-list-item-timeline">
- <svg className="home-timeline-list-item-line" viewBox="0 0 120 100">
- <path
- d="M 80,50 m 0,-12 a 12 12 180 1 1 0,24 12 12 180 1 1 0,-24 z M 60,0 h 40 v 100 h -40 z"
- fillRule="evenodd"
- fill="#007bff"
- />
- </svg>
- <div>
- {timeline.timelineOwner}/{timeline.timelineName}
- </div>
- <Link to={`${timeline.timelineOwner}/${timeline.timelineName}`}>
- <IconButton icon="arrow-right" className="ms-3" />
- </Link>
- </div>
- );
-};
-
-const TimelineListArrow: React.FC = () => {
- return (
- <div>
- <div className="home-timeline-list-item">
- <svg className="home-timeline-list-item-line" viewBox="0 0 120 60">
- <path d="M 60,0 h 40 v 20 l -20,20 l -20,-20 z" fill="#007bff" />
- </svg>
- </div>
- <div className="home-timeline-list-item">
- <svg
- className="home-timeline-list-item-line home-timeline-list-loading-head"
- viewBox="0 0 120 40"
- >
- <path
- d="M 60,10 l 20,20 l 20,-20"
- fill="none"
- stroke="#007bff"
- strokeWidth="5"
- />
- </svg>
- </div>
- </div>
- );
-};
-
-interface TimelineListViewProps {
- headerText?: I18nText;
- timelines?: TimelineBookmark[];
-}
-
-const TimelineListView: React.FC<TimelineListViewProps> = ({
- headerText,
- timelines,
-}) => {
- const { t } = useTranslation();
-
- return (
- <div className="home-timeline-list">
- <div className="home-timeline-list-item">
- <svg className="home-timeline-list-item-line" viewBox="0 0 120 120">
- <path
- d="M 0,20 Q 80,20 80,80 l 0,40"
- stroke="#007bff"
- strokeWidth="40"
- fill="none"
- />
- </svg>
- <h3>{convertI18nText(headerText, t)}</h3>
- </div>
- {timelines != null
- ? timelines.map((t) => (
- <TimelineListItem
- key={`${t.timelineOwner}/${t.timelineName}`}
- timeline={t}
- />
- ))
- : null}
- <TimelineListArrow />
- </div>
- );
-};
-
-export default TimelineListView;
diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx
deleted file mode 100644
index e843c325..00000000
--- a/FrontEnd/src/views/home/WebsiteIntroduction.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { Link } from "react-router-dom";
-
-const WebsiteIntroduction: React.FC<{
- className?: string;
- style?: React.CSSProperties;
-}> = ({ className, style }) => {
- const { i18n } = useTranslation();
-
- if (i18n.language.startsWith("zh")) {
- return (
- <div className={className} style={style}>
- <h2>
- 欢迎来到<strong>时间线</strong>!🎉🎉🎉
- </h2>
- <p>
- 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。
- </p>
- <p>
- 如果你拥有一个账号,<Link to="/login">登陆</Link>
- 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。
- </p>
- <p>
- 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。
- </p>
- <p>
- 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在
- <Link to="/about">关于</Link>页面找到一些信息。
- </p>
- <p>
- <small className="text-secondary">
- 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅
- </small>
- </p>
- </div>
- );
- } else {
- return (
- <div className={className} style={style}>
- <h2>
- Welcome to <strong>Timeline</strong>!🎉🎉🎉
- </h2>
- <p>
- This website consists of many individual timelines. Each timeline is a
- list of messages just like a chat app.
- </p>
- <p>
- If you do have an account, you can <Link to="/login">login</Link> and
- post messages, which supports Markdown and images, in your timelines.
- You can also create a new timeline to open a new topic. You can set
- the permission of a timeline to only allow specified people to see the
- content of the timeline.
- </p>
- <p>
- If you don&apos;t have an account, you can view some public timelines
- like highlight timelines below set by website manager.
- </p>
- <p>
- Since this website is hosted on my tiny server, so account registry is
- not opened. If you want to host this service on your own server, you
- can find some useful information on <Link to="/about">about</Link>{" "}
- page.
- </p>
- <p>
- <small className="text-secondary">
- This introduction is added after my lover complained a lot of times
- about the obscuration of my website. May you understand the logic of
- it!😅
- </small>
- </p>
- </div>
- );
- }
-};
-
-export default WebsiteIntroduction;
diff --git a/FrontEnd/src/views/home/index.css b/FrontEnd/src/views/home/index.css
deleted file mode 100644
index 89d36f0d..00000000
--- a/FrontEnd/src/views/home/index.css
+++ /dev/null
@@ -1,42 +0,0 @@
-.home-timeline-list-item {
- display: flex;
- align-items: center;
-}
-
-.home-timeline-list-item-timeline {
- transition: background 0.8s;
- animation: 0.8s home-timeline-list-item-timeline-enter;
-}
-.home-timeline-list-item-timeline:hover {
- background: #e9ecef;
-}
-
-@keyframes home-timeline-list-item-timeline-enter {
- from {
- transform: translate(-100%, 0);
- opacity: 0;
- }
-}
-.home-timeline-list-item-line {
- width: 80px;
- flex-shrink: 0;
-}
-
-@keyframes home-timeline-list-loading-head-animation {
- from {
- transform: translate(0, -30px);
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-.home-timeline-list-loading-head {
- animation: 1s infinite home-timeline-list-loading-head-animation;
-}
-
-@media (min-width: 576px) {
- .home-search {
- float: right;
- }
-}
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx
deleted file mode 100644
index 3c80fb0c..00000000
--- a/FrontEnd/src/views/home/index.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { highlightTimelineUsername } from "@/common";
-
-import { Page } from "@/http/common";
-import { getHttpBookmarkClient, TimelineBookmark } from "@/http/bookmark";
-
-import SearchInput from "../common/SearchInput";
-import TimelineListView from "./TimelineListView";
-import WebsiteIntroduction from "./WebsiteIntroduction";
-
-import "./index.css";
-
-const highlightTimelineMessageMap = {
- loading: "home.loadingHighlightTimelines",
- done: "home.loadedHighlightTimelines",
- error: "home.errorHighlightTimelines",
-} as const;
-
-const HomeV2: React.FC = () => {
- const navigate = useNavigate();
-
- const [navText, setNavText] = React.useState<string>("");
-
- const [highlightTimelineState, setHighlightTimelineState] = React.useState<
- "loading" | "done" | "error"
- >("loading");
- const [highlightTimelines, setHighlightTimelines] = React.useState<
- Page<TimelineBookmark> | undefined
- >();
-
- React.useEffect(() => {
- if (highlightTimelineState === "loading") {
- let subscribe = true;
- void getHttpBookmarkClient()
- .list(highlightTimelineUsername)
- .then(
- (data) => {
- if (subscribe) {
- setHighlightTimelineState("done");
- setHighlightTimelines(data);
- }
- },
- () => {
- if (subscribe) {
- setHighlightTimelineState("error");
- setHighlightTimelines(undefined);
- }
- }
- );
- return () => {
- subscribe = false;
- };
- }
- }, [highlightTimelineState]);
-
- return (
- <>
- <SearchInput
- className="mx-2 my-3 home-search"
- value={navText}
- onChange={setNavText}
- onButtonClick={() => {
- navigate(`search?q=${navText}`);
- }}
- alwaysOneline
- />
- <WebsiteIntroduction className="m-2" />
- <TimelineListView
- headerText={highlightTimelineMessageMap[highlightTimelineState]}
- timelines={highlightTimelines?.items}
- />
- </>
- );
-};
-
-export default HomeV2;
diff --git a/FrontEnd/src/views/login/index.css b/FrontEnd/src/views/login/index.css
deleted file mode 100644
index aefe57e8..00000000
--- a/FrontEnd/src/views/login/index.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.login-container {
- max-width: 25em;
-}
-
-.login-container input[type="text"],
-.login-container input[type="password"] {
- width: 100%;
-}
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx
deleted file mode 100644
index cc1d9865..00000000
--- a/FrontEnd/src/views/login/index.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import * as React from "react";
-import { Link, useNavigate } from "react-router-dom";
-import { useTranslation, Trans } from "react-i18next";
-
-import { useUser, userService } from "@/services/user";
-
-import AppBar from "../common/AppBar";
-import LoadingButton from "../common/button/LoadingButton";
-
-import "./index.css";
-
-const LoginPage: React.FC = () => {
- const { t } = useTranslation();
-
- const navigate = useNavigate();
-
- 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(() => navigate("/"), 3000);
- return () => {
- clearTimeout(id);
- };
- }
- }, [navigate, user]);
-
- if (user != null) {
- return (
- <>
- <AppBar />
- <p>{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) {
- navigate("/");
- } else {
- navigate(-1);
- }
- },
- (e: Error) => {
- setProcess(false);
- setError(e.message);
- }
- );
- };
-
- const onEnterPressInPassword: React.KeyboardEventHandler = (e) => {
- if (e.key === "Enter") {
- submit();
- }
- };
-
- return (
- <div className="login-container container-fluid mt-2">
- <h1 className="cru-text-center cru-color-primary">{t("welcome")}</h1>
- <div className="cru-operation-dialog-group">
- <label className="cru-operation-dialog-label" htmlFor="username">
- {t("user.username")}
- </label>
- <input
- id="username"
- type="text"
- disabled={process}
- onChange={(e) => {
- setUsername(e.target.value);
- setUsernameDirty(true);
- }}
- value={username}
- />
- {usernameDirty && username === "" && (
- <div className="cru-operation-dialog-error-text">
- {t("login.emptyUsername")}
- </div>
- )}
- </div>
- <div className="cru-operation-dialog-group">
- <label className="cru-operation-dialog-label" htmlFor="password">
- {t("user.password")}
- </label>
- <input
- id="password"
- type="password"
- disabled={process}
- onChange={(e) => {
- setPassword(e.target.value);
- setPasswordDirty(true);
- }}
- value={password}
- onKeyDown={onEnterPressInPassword}
- />
- {passwordDirty && password === "" && (
- <div className="cru-operation-dialog-error-text">
- {t("login.emptyPassword")}
- </div>
- )}
- </div>
- <div className="cru-operation-dialog-group">
- <input
- id="remember-me"
- type="checkbox"
- checked={rememberMe}
- onChange={(e) => {
- setRememberMe(e.currentTarget.checked);
- }}
- />
- <label className="cru-operation-dialog-inline-label">
- {t("user.rememberMe")}
- </label>
- </div>
- {error ? <p className="cru-color-danger">{t(error)}</p> : null}
- <div className="cru-text-end">
- <LoadingButton
- loading={process}
- onClick={(e) => {
- submit();
- e.preventDefault();
- }}
- disabled={username === "" || password === "" ? true : undefined}
- >
- {t("user.login")}
- </LoadingButton>
- </div>
- <Trans i18nKey="login.noAccount">
- 0<Link to="/register">1</Link>2
- </Trans>
- </div>
- );
-};
-
-export default LoginPage;
diff --git a/FrontEnd/src/views/register/index.tsx b/FrontEnd/src/views/register/index.tsx
deleted file mode 100644
index c1b95ff7..00000000
--- a/FrontEnd/src/views/register/index.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router-dom";
-
-import { HttpBadRequestError } from "@/http/common";
-import { getHttpTokenClient } from "@/http/token";
-import { userService, useUser } from "@/services/user";
-
-import { LoadingButton } from "../common/button";
-import InputPanel, {
- hasError,
- InputPanelError,
-} from "../common/input/InputPanel";
-
-import "./index.css";
-
-const validate = (values: string[], dirties: boolean[]): InputPanelError => {
- const e: InputPanelError = {};
- if (dirties[0] && values[0].length === 0) {
- e[0] = "register.error.usernameEmpty";
- }
- if (dirties[1] && values[1].length === 0) {
- e[1] = "register.error.passwordEmpty";
- }
- if (dirties[2] && values[2] !== values[1]) {
- e[2] = "register.error.confirmPasswordWrong";
- }
- if (dirties[3] && values[3].length === 0) {
- e[3] = "register.error.registerCodeEmpty";
- }
- return e;
-};
-
-const RegisterPage: React.FC = () => {
- const navigate = useNavigate();
-
- const { t } = useTranslation();
-
- const [username, setUsername] = React.useState<string>("");
- const [password, setPassword] = React.useState<string>("");
- const [confirmPassword, setConfirmPassword] = React.useState<string>("");
- const [registerCode, setRegisterCode] = React.useState<string>("");
-
- const [dirty, setDirty] = React.useState<boolean[]>(new Array(4).fill(false));
-
- const [process, setProcess] = React.useState<boolean>(false);
-
- const [inputError, setInputError] = React.useState<InputPanelError>();
- const [resultError, setResultError] = React.useState<string | null>(null);
-
- const user = useUser();
-
- React.useEffect(() => {
- if (user != null) {
- navigate("/");
- }
- });
-
- return (
- <div className="container register-page">
- <InputPanel
- scheme={[
- {
- type: "text",
- label: "register.username",
- },
- {
- type: "text",
- label: "register.password",
- password: true,
- },
- {
- type: "text",
- label: "register.confirmPassword",
- password: true,
- },
- { type: "text", label: "register.registerCode" },
- ]}
- values={[username, password, confirmPassword, registerCode]}
- onChange={(values, index) => {
- setUsername(values[0]);
- setPassword(values[1]);
- setConfirmPassword(values[2]);
- setRegisterCode(values[3]);
- const newDirty = dirty.slice();
- newDirty[index] = true;
- setDirty(newDirty);
-
- setInputError(validate(values, newDirty));
- }}
- error={inputError}
- disable={process}
- />
- {resultError && <div className="cru-color-danger">{t(resultError)}</div>}
- <LoadingButton
- text="register.register"
- loading={process}
- disabled={hasError(inputError)}
- onClick={() => {
- const newDirty = dirty.slice().fill(true);
- setDirty(newDirty);
- const e = validate(
- [username, password, confirmPassword, registerCode],
- newDirty
- );
- if (hasError(e)) {
- setInputError(e);
- } else {
- setProcess(true);
- void getHttpTokenClient()
- .register({
- username,
- password,
- registerCode,
- })
- .then(
- () => {
- void userService
- .login({ username, password }, true)
- .then(() => {
- navigate("/");
- });
- },
- (error) => {
- if (error instanceof HttpBadRequestError) {
- setResultError("register.error.registerCodeInvalid");
- } else {
- setResultError("error.network");
- }
- setProcess(false);
- }
- );
- }
- }}
- />
- </div>
- );
-};
-
-export default RegisterPage;
diff --git a/FrontEnd/src/views/search/index.css b/FrontEnd/src/views/search/index.css
deleted file mode 100644
index 6ff4d9fa..00000000
--- a/FrontEnd/src/views/search/index.css
+++ /dev/null
@@ -1,15 +0,0 @@
-.timeline-search-result-item {
- border: 1px solid;
- border-color: #e9ecef;
- background: #f8f9fa;
- transition: all 0.3s;
-}
-.timeline-search-result-item:hover {
- border-color: #0d6efd;
-}
-
-.timeline-search-result-item-avatar {
- width: 2em;
- height: 2em;
- border-radius: 50%;
-}
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx
deleted file mode 100644
index 58257465..00000000
--- a/FrontEnd/src/views/search/index.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { useNavigate, useLocation } from "react-router-dom";
-import { Link } from "react-router-dom";
-
-import { HttpNetworkError } from "@/http/common";
-import { getHttpSearchClient } from "@/http/search";
-import { HttpTimelineInfo } from "@/http/timeline";
-
-import SearchInput from "../common/SearchInput";
-import UserAvatar from "../common/user/UserAvatar";
-
-import "./index.css";
-
-const TimelineSearchResultItemView: React.FC<{
- timeline: HttpTimelineInfo;
-}> = ({ timeline }) => {
- return (
- <div className="timeline-search-result-item my-2 p-3">
- <h4>
- <Link
- to={`/${timeline.owner.username}/${timeline.nameV2}`}
- className="mb-2 text-primary"
- >
- {timeline.title}
- <small className="ms-3 text-secondary">{timeline.nameV2}</small>
- </Link>
- </h4>
- <div>
- <UserAvatar
- username={timeline.owner.username}
- className="timeline-search-result-item-avatar me-2"
- />
- {timeline.owner.nickname}
- <small className="ms-3 text-secondary">
- @{timeline.owner.username}
- </small>
- </div>
- </div>
- );
-};
-
-const SearchPage: React.FC = () => {
- const { t } = useTranslation();
-
- const navigate = useNavigate();
- const location = useLocation();
- const searchParams = new URLSearchParams(location.search);
- const queryParam = searchParams.get("q");
-
- const [searchText, setSearchText] = React.useState<string>("");
- const [state, setState] = React.useState<
- HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error"
- >("init");
-
- const [forceResearchKey, setForceResearchKey] = React.useState<number>(0);
-
- React.useEffect(() => {
- setState("init");
- if (queryParam != null && queryParam.length > 0) {
- setSearchText(queryParam);
- setState("loading");
- void getHttpSearchClient()
- .searchTimelines(queryParam)
- .then(
- (ts) => {
- setState(ts);
- },
- (e) => {
- if (e instanceof HttpNetworkError) {
- setState("network-error");
- } else {
- setState("error");
- }
- }
- );
- }
- }, [queryParam, forceResearchKey]);
-
- return (
- <div className="container my-3">
- <div className="row justify-content-center">
- <SearchInput
- className="col-12 col-sm-9 col-md-6"
- value={searchText}
- onChange={setSearchText}
- loading={state === "loading"}
- onButtonClick={() => {
- if (queryParam === searchText) {
- setForceResearchKey((old) => old + 1);
- } else {
- navigate(`/search?q=${searchText}`);
- }
- }}
- />
- </div>
- {(() => {
- switch (state) {
- case "init": {
- if (queryParam == null || queryParam.length === 0) {
- return <div>{t("searchPage.input")}</div>;
- }
- break;
- }
- case "loading": {
- return <div>{t("searchPage.loading")}</div>;
- }
- case "network-error": {
- return <div className="text-danger">{t("error.network")}</div>;
- }
- case "error": {
- return <div className="text-danger">{t("error.unknown")}</div>;
- }
- default: {
- if (state.length === 0) {
- return <div>{t("searchPage.noResult")}</div>;
- }
- return state.map((t) => (
- <TimelineSearchResultItemView
- key={`${t.owner.username}/${t.nameV2}`}
- timeline={t}
- />
- ));
- }
- }
- })()}
- </div>
- );
-};
-
-export default SearchPage;
diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
deleted file mode 100644
index 44bd2c68..00000000
--- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-import { useState, useEffect } from "react";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { AxiosError } from "axios";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-
-import { useUser } from "@/services/user";
-
-import { getHttpUserClient } from "@/http/user";
-
-import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
-
-export interface ChangeAvatarDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
- const { t } = useTranslation();
-
- const user = useUser();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [clip, setClip] = React.useState<Clip | null>(null);
- const [cropImgElement, setCropImgElement] =
- React.useState<HTMLImageElement | null>(null);
- const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
- const [resultUrl, setResultUrl] = React.useState<string | null>(null);
-
- const [state, setState] = React.useState<
- | "select"
- | "crop"
- | "processcrop"
- | "preview"
- | "uploading"
- | "success"
- | "error"
- >("select");
-
- const [message, setMessage] = useState<I18nText>(
- "settings.dialogChangeAvatar.prompt.select"
- );
-
- const trueMessage = convertI18nText(message, t);
-
- const closeDialog = props.close;
-
- const close = React.useCallback((): void => {
- if (!(state === "uploading")) {
- closeDialog();
- }
- }, [state, closeDialog]);
-
- useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setClip(null);
- setFileUrl(url);
- setState("crop");
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setFileUrl(null);
- setState("select");
- }
- }, [file]);
-
- React.useEffect(() => {
- if (resultBlob != null) {
- const url = URL.createObjectURL(resultBlob);
- setResultUrl(url);
- setState("preview");
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setResultUrl(null);
- }
- }, [resultBlob]);
-
- const onSelectFile = React.useCallback(
- (e: React.ChangeEvent<HTMLInputElement>): void => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- } else {
- setFile(files[0]);
- }
- },
- []
- );
-
- const onCropNext = React.useCallback(() => {
- if (
- cropImgElement == null ||
- clip == null ||
- clip.width === 0 ||
- file == null
- ) {
- throw new UiLogicError();
- }
-
- setState("processcrop");
- void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
- setResultBlob(b);
- });
- }, [cropImgElement, clip, file]);
-
- const onCropPrevious = React.useCallback(() => {
- setFile(null);
- setState("select");
- }, []);
-
- const onPreviewPrevious = React.useCallback(() => {
- setResultBlob(null);
- setState("crop");
- }, []);
-
- const upload = React.useCallback(() => {
- if (resultBlob == null) {
- throw new UiLogicError();
- }
-
- if (user == null) {
- throw new UiLogicError();
- }
-
- setState("uploading");
- getHttpUserClient()
- .putAvatar(user.username, resultBlob)
- .then(
- () => {
- setState("success");
- },
- (e: unknown) => {
- setState("error");
- setMessage({ type: "custom", value: (e as AxiosError).message });
- }
- );
- }, [user, resultBlob]);
-
- const createPreviewRow = (): React.ReactElement => {
- if (resultUrl == null) {
- throw new UiLogicError();
- }
- return (
- <div className="row justify-content-center">
- <div className="col col-auto">
- <img
- className="change-avatar-img"
- src={resultUrl}
- alt={t("settings.dialogChangeAvatar.previewImgAlt") ?? undefined}
- />
- </div>
- </div>
- );
- };
-
- return (
- <Dialog open={props.open} onClose={close}>
- <h3 className="cru-color-primary">
- {t("settings.dialogChangeAvatar.title")}
- </h3>
- <hr />
- {(() => {
- if (state === "select") {
- return (
- <>
- <div className="container">
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.select")}
- </div>
- <div className="row">
- <input
- className="px-0"
- type="file"
- accept="image/*"
- onChange={onSelectFile}
- />
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- onClick={close}
- />
- </div>
- </>
- );
- } else if (state === "crop") {
- if (fileUrl == null) {
- throw new UiLogicError();
- }
- return (
- <>
- <div className="container">
- <div className="row justify-content-center">
- <ImageCropper
- clip={clip}
- onChange={setClip}
- imageUrl={fileUrl}
- imageElementCallback={setCropImgElement}
- />
- </div>
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.crop")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={close}
- />
- <Button
- text="operationDialog.previousStep"
- color="secondary"
- outline
- onClick={onCropPrevious}
- />
- <Button
- text="operationDialog.nextStep"
- color="primary"
- onClick={onCropNext}
- disabled={
- cropImgElement == null || clip == null || clip.width === 0
- }
- />
- </div>
- </>
- );
- } else if (state === "processcrop") {
- return (
- <>
- <div className="container">
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.processingCrop")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- onClick={close}
- outline
- />
- <Button
- text="operationDialog.previousStep"
- color="secondary"
- onClick={onPreviewPrevious}
- outline
- />
- </div>
- </>
- );
- } else if (state === "preview") {
- return (
- <>
- <div className="container">
- {createPreviewRow()}
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.preview")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={close}
- />
- <Button
- text="operationDialog.previousStep"
- color="secondary"
- outline
- onClick={onPreviewPrevious}
- />
- <Button
- text="settings.dialogChangeAvatar.upload"
- color="primary"
- onClick={upload}
- />
- </div>
- </>
- );
- } else if (state === "uploading") {
- return (
- <>
- <div className="container">
- {createPreviewRow()}
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.uploading")}
- </div>
- </div>
- </>
- );
- } else if (state === "success") {
- return (
- <>
- <div className="container">
- <div className="row p-4 text-success">
- {t("operationDialog.success")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.ok"
- color="success"
- onClick={close}
- />
- </div>
- </>
- );
- } else {
- return (
- <>
- <div className="container">
- {createPreviewRow()}
- <div className="row text-danger">{trueMessage}</div>
- </div>
- <hr />
- <div>
- <Button
- text="operationDialog.cancel"
- color="secondary"
- onClick={close}
- />
- <Button
- text="operationDialog.retry"
- color="primary"
- onClick={upload}
- />
- </div>
- </>
- );
- }
- })()}
- </Dialog>
- );
-};
-
-export default ChangeAvatarDialog;
diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
deleted file mode 100644
index 7ba12de8..00000000
--- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { getHttpUserClient } from "@/http/user";
-import { useUser } from "@/services/user";
-import * as React from "react";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface ChangeNicknameDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
- const user = useUser();
-
- if (user == null) return null;
-
- return (
- <OperationDialog
- open={props.open}
- title="settings.dialogChangeNickname.title"
- inputScheme={[
- { type: "text", label: "settings.dialogChangeNickname.inputLabel" },
- ]}
- onProcess={([newNickname]) => {
- return getHttpUserClient().patch(user.username, {
- nickname: newNickname,
- });
- }}
- onClose={props.close}
- />
- );
-};
-
-export default ChangeNicknameDialog;
diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
deleted file mode 100644
index a34ca4a7..00000000
--- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { userService } from "@/services/user";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface ChangePasswordDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
- const navigate = useNavigate();
-
- const [redirect, setRedirect] = useState<boolean>(false);
-
- return (
- <OperationDialog
- open={props.open}
- title="settings.dialogChangePassword.title"
- themeColor="danger"
- inputPrompt="settings.dialogChangePassword.prompt"
- inputScheme={[
- {
- type: "text",
- label: "settings.dialogChangePassword.inputOldPassword",
- password: true,
- },
- {
- type: "text",
- label: "settings.dialogChangePassword.inputNewPassword",
- password: true,
- },
- {
- type: "text",
- label: "settings.dialogChangePassword.inputRetypeNewPassword",
- password: true,
- },
- ]}
- inputValidator={([oldPassword, newPassword, retypedNewPassword]) => {
- const result: Record<number, string> = {};
- if (oldPassword === "") {
- result[0] = "settings.dialogChangePassword.errorEmptyOldPassword";
- }
- if (newPassword === "") {
- result[1] = "settings.dialogChangePassword.errorEmptyNewPassword";
- }
- if (retypedNewPassword !== newPassword) {
- result[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
- }
- return result;
- }}
- onProcess={async ([oldPassword, newPassword]) => {
- await userService.changePassword(oldPassword, newPassword);
- setRedirect(true);
- }}
- onClose={() => {
- props.close();
- if (redirect) {
- navigate("/login");
- }
- }}
- />
- );
-};
-
-export default ChangePasswordDialog;
diff --git a/FrontEnd/src/views/settings/index.css b/FrontEnd/src/views/settings/index.css
deleted file mode 100644
index ccf7a97a..00000000
--- a/FrontEnd/src/views/settings/index.css
+++ /dev/null
@@ -1,31 +0,0 @@
-.change-avatar-cropper-row {
- max-height: 400px;
-}
-
-.change-avatar-img {
- min-width: 50%;
- max-width: 100%;
- max-height: 400px;
-}
-
-.settings-item {
- padding: 0.5em 1em;
- transition: background 0.3s;
- border-bottom: 1px solid #e9ecef;
- align-items: center;
-}
-.settings-item.first {
- border-top: 1px solid #e9ecef;
-}
-.settings-item.clickable {
- cursor: pointer;
-}
-.settings-item:hover {
- background: #dee2e6;
-}
-
-.register-code {
- border: 1px solid black;
- border-radius: 3px;
- padding: 0.2em;
-} \ No newline at end of file
diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx
deleted file mode 100644
index 6647826f..00000000
--- a/FrontEnd/src/views/settings/index.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import classNames from "classnames";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-import { useUser, userService } from "@/services/user";
-import { getHttpUserClient } from "@/http/user";
-import { TimelineVisibility } from "@/http/timeline";
-
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import Card from "../common/Card";
-import Spinner from "../common/Spinner";
-import ChangePasswordDialog from "./ChangePasswordDialog";
-import ChangeAvatarDialog from "./ChangeAvatarDialog";
-import ChangeNicknameDialog from "./ChangeNicknameDialog";
-
-import "./index.css";
-import { pushAlert } from "@/services/alert";
-
-interface SettingSectionProps {
- title: I18nText;
- children: React.ReactNode;
-}
-
-const SettingSection: React.FC<SettingSectionProps> = ({ title, children }) => {
- const { t } = useTranslation();
-
- return (
- <Card className="my-3 py-3">
- <h3 className="px-3 mb-3 cru-color-primary">
- {convertI18nText(title, t)}
- </h3>
- {children}
- </Card>
- );
-};
-
-interface SettingItemContainerWithoutChildrenProps {
- title: I18nText;
- subtext?: I18nText;
- first?: boolean;
- danger?: boolean;
- style?: React.CSSProperties;
- className?: string;
- onClick?: () => void;
-}
-
-interface SettingItemContainerProps
- extends SettingItemContainerWithoutChildrenProps {
- children?: React.ReactNode;
-}
-
-function SettingItemContainer({
- title,
- subtext,
- first,
- danger,
- children,
- style,
- className,
- onClick,
-}: SettingItemContainerProps): JSX.Element {
- const { t } = useTranslation();
-
- return (
- <div
- style={style}
- className={classNames(
- "row settings-item mx-0",
- first && "first",
- onClick && "clickable",
- className,
- )}
- onClick={onClick}
- >
- <div className="px-0 col col-auto">
- <div className={classNames(danger && "cru-color-danger")}>
- {convertI18nText(title, t)}
- </div>
- <small className="d-block cru-color-secondary">
- {convertI18nText(subtext, t)}
- </small>
- </div>
- <div className="col col-auto">{children}</div>
- </div>
- );
-}
-
-type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps;
-
-const ButtonSettingItem: React.FC<ButtonSettingItemProps> = ({ ...props }) => {
- return <SettingItemContainer {...props} />;
-};
-
-interface SelectSettingItemProps
- extends SettingItemContainerWithoutChildrenProps {
- options: {
- value: string;
- label: I18nText;
- }[];
- value?: string;
- onSelect: (value: string) => void;
-}
-
-const SelectSettingsItem: React.FC<SelectSettingItemProps> = ({
- options,
- value,
- onSelect,
- ...props
-}) => {
- const { t } = useTranslation();
-
- return (
- <SettingItemContainer {...props}>
- {value == null ? (
- <Spinner />
- ) : (
- <select
- value={value}
- onChange={(e) => {
- onSelect(e.target.value);
- }}
- >
- {options.map(({ value, label }) => (
- <option key={value} value={value}>
- {convertI18nText(label, t)}
- </option>
- ))}
- </select>
- )}
- </SettingItemContainer>
- );
-};
-
-const SettingsPage: React.FC = () => {
- const { i18n } = useTranslation();
- const user = useUser();
- const navigate = useNavigate();
-
- const [dialog, setDialog] = useState<
- | null
- | "changepassword"
- | "changeavatar"
- | "changenickname"
- | "logout"
- | "renewregistercode"
- >(null);
-
- const [registerCode, setRegisterCode] = useState<undefined | null | string>(
- undefined,
- );
-
- const [bookmarkVisibility, setBookmarkVisibility] =
- useState<TimelineVisibility>();
-
- React.useEffect(() => {
- if (user != null) {
- void getHttpUserClient()
- .getBookmarkVisibility(user.username)
- .then(({ visibility }) => {
- setBookmarkVisibility(visibility);
- });
- } else {
- setBookmarkVisibility(undefined);
- }
- }, [user]);
-
- React.useEffect(() => {
- setRegisterCode(undefined);
- }, [user]);
-
- React.useEffect(() => {
- if (user != null && registerCode === undefined) {
- void getHttpUserClient()
- .getRegisterCode(user.username)
- .then((code) => {
- setRegisterCode(code.registerCode ?? null);
- });
- }
- }, [user, registerCode]);
-
- const language = i18n.language.slice(0, 2);
-
- return (
- <>
- <div className="container">
- {user ? (
- <SettingSection title="settings.subheaders.account">
- <SettingItemContainer
- title="settings.myRegisterCode"
- subtext="settings.myRegisterCodeDesc"
- onClick={() => setDialog("renewregistercode")}
- >
- {registerCode === undefined ? (
- <Spinner />
- ) : registerCode === null ? (
- <span>Noop</span>
- ) : (
- <code
- className="register-code"
- onClick={(event) => {
- void navigator.clipboard
- .writeText(registerCode)
- .then(() => {
- pushAlert({
- type: "success",
- message: "settings.myRegisterCodeCopied",
- });
- });
- event.stopPropagation();
- }}
- >
- {registerCode}
- </code>
- )}
- </SettingItemContainer>
- <ButtonSettingItem
- title="settings.changeAvatar"
- onClick={() => setDialog("changeavatar")}
- first
- />
- <ButtonSettingItem
- title="settings.changeNickname"
- onClick={() => setDialog("changenickname")}
- />
- <SelectSettingsItem
- title="settings.changeBookmarkVisibility"
- options={[
- {
- value: "Private",
- label: "visibility.private",
- },
- {
- value: "Register",
- label: "visibility.register",
- },
- {
- value: "Public",
- label: "visibility.public",
- },
- ]}
- value={bookmarkVisibility}
- onSelect={(value) => {
- void getHttpUserClient()
- .putBookmarkVisibility(user.username, {
- visibility: value as TimelineVisibility,
- })
- .then(() => {
- setBookmarkVisibility(value as TimelineVisibility);
- });
- }}
- />
- <ButtonSettingItem
- title="settings.changePassword"
- onClick={() => setDialog("changepassword")}
- danger
- />
- <ButtonSettingItem
- title="settings.logout"
- onClick={() => {
- setDialog("logout");
- }}
- danger
- />
- </SettingSection>
- ) : null}
- <SettingSection title="settings.subheaders.customization">
- <SelectSettingsItem
- title="settings.languagePrimary"
- subtext="settings.languageSecondary"
- options={[
- {
- value: "zh",
- label: {
- type: "custom",
- value: "中文",
- },
- },
- {
- value: "en",
- label: {
- type: "custom",
- value: "English",
- },
- },
- ]}
- value={language}
- onSelect={(value) => {
- void i18n.changeLanguage(value);
- }}
- first
- />
- </SettingSection>
- </div>
- <ChangePasswordDialog
- open={dialog === "changepassword"}
- close={() => setDialog(null)}
- />
- <ConfirmDialog
- title="settings.dialogConfirmLogout.title"
- body="settings.dialogConfirmLogout.prompt"
- onClose={() => setDialog(null)}
- open={dialog === "logout"}
- onConfirm={() => {
- void userService.logout().then(() => {
- navigate("/");
- });
- }}
- />
- <ConfirmDialog
- title="settings.renewRegisterCode"
- body="settings.renewRegisterCodeDesc"
- onClose={() => setDialog(null)}
- open={dialog === "renewregistercode"}
- onConfirm={() => {
- if (user == null) throw new UiLogicError();
- void getHttpUserClient()
- .renewRegisterCode(user.username)
- .then(() => {
- setRegisterCode(undefined);
- });
- }}
- />
- <ChangeAvatarDialog
- open={dialog === "changeavatar"}
- close={() => setDialog(null)}
- />
- <ChangeNicknameDialog
- open={dialog === "changenickname"}
- close={() => setDialog(null)}
- />
- </>
- );
-};
-
-export default SettingsPage;
diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx
deleted file mode 100644
index 374ccc2e..00000000
--- a/FrontEnd/src/views/timeline/CollapseButton.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react";
-
-import IconButton from "../common/button/IconButton";
-
-const CollapseButton: React.FC<{
- collapse: boolean;
- onClick: () => void;
- className?: string;
- style?: React.CSSProperties;
-}> = ({ collapse, onClick, className, style }) => {
- return (
- <IconButton
- icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"}
- onClick={onClick}
- className={className}
- style={style}
- />
- );
-};
-
-export default CollapseButton;
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css
deleted file mode 100644
index e36be992..00000000
--- a/FrontEnd/src/views/timeline/MarkdownPostEdit.css
+++ /dev/null
@@ -1,21 +0,0 @@
-.timeline-markdown-post-edit-page {
- overflow: auto;
- max-height: 300px;
-}
-
-.timeline-markdown-post-edit-image-container {
- position: relative;
- text-align: center;
- margin-bottom: 1em;
-}
-
-.timeline-markdown-post-edit-image {
- max-width: 100%;
- max-height: 200px;
-}
-
-.timeline-markdown-post-edit-image-delete-button {
- position: absolute;
- right: 10px;
- top: 2px;
-}
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
deleted file mode 100644
index 6401cfaa..00000000
--- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import TimelinePostBuilder from "@/services/TimelinePostBuilder";
-
-import FlatButton from "../common/button/FlatButton";
-import TabPages from "../common/tab/TabPages";
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import Spinner from "../common/Spinner";
-import IconButton from "../common/button/IconButton";
-
-import "./MarkdownPostEdit.css";
-
-export interface MarkdownPostEditProps {
- owner: string;
- timeline: string;
- onPosted: (post: HttpTimelinePostInfo) => void;
- onPostError: () => void;
- onClose: () => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
- owner: ownerUsername,
- timeline: timelineName,
- onPosted,
- onClose,
- onPostError,
- className,
- style,
-}) => {
- const { t } = useTranslation();
-
- const [canLeave, setCanLeave] = React.useState<boolean>(true);
-
- const [process, setProcess] = React.useState<boolean>(false);
-
- const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] =
- React.useState<boolean>(false);
-
- const [text, _setText] = React.useState<string>("");
- const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
- []
- );
- const [previewHtml, _setPreviewHtml] = React.useState<string>("");
-
- const _builder = React.useRef<TimelinePostBuilder | null>(null);
-
- const getBuilder = (): TimelinePostBuilder => {
- if (_builder.current == null) {
- const builder = new TimelinePostBuilder(() => {
- setCanLeave(builder.isEmpty);
- _setText(builder.text);
- _setImages(builder.images);
- _setPreviewHtml(builder.renderHtml());
- });
- _builder.current = builder;
- }
- return _builder.current;
- };
-
- const canSend = text.length > 0;
-
- React.useEffect(() => {
- return () => {
- getBuilder().dispose();
- };
- }, []);
-
- React.useEffect(() => {
- window.onbeforeunload = (): unknown => {
- if (!canLeave) {
- return t("timeline.confirmLeave");
- }
- };
-
- return () => {
- window.onbeforeunload = null;
- };
- }, [canLeave, t]);
-
- const send = async (): Promise<void> => {
- setProcess(true);
- try {
- const dataList = await getBuilder().build();
- const post = await getHttpTimelineClient().postPost(
- ownerUsername,
- timelineName,
- {
- dataList,
- }
- );
- onPosted(post);
- onClose();
- } catch (e) {
- setProcess(false);
- onPostError();
- }
- };
-
- return (
- <>
- <TabPages
- className={className}
- style={style}
- pageContainerClassName="py-2"
- dense
- actions={
- process ? (
- <Spinner />
- ) : (
- <div>
- <IconButton
- icon="x"
- color="danger"
- large
- className="cru-align-middle me-2"
- onClick={() => {
- if (canLeave) {
- onClose();
- } else {
- setShowLeaveConfirmDialog(true);
- }
- }}
- />
- {canSend && (
- <FlatButton text="timeline.send" onClick={() => void send()} />
- )}
- </div>
- )
- }
- pages={[
- {
- name: "text",
- text: "edit",
- page: (
- <textarea
- value={text}
- disabled={process}
- className="cru-fill-parent"
- onChange={(event) => {
- getBuilder().setMarkdownText(event.currentTarget.value);
- }}
- />
- ),
- },
- {
- name: "images",
- text: "image",
- page: (
- <div className="timeline-markdown-post-edit-page">
- {images.map((image, index) => (
- <div
- key={image.url}
- className="timeline-markdown-post-edit-image-container"
- >
- <img
- src={image.url}
- className="timeline-markdown-post-edit-image"
- />
- <IconButton
- icon="trash"
- color="danger"
- className={classnames(
- "timeline-markdown-post-edit-image-delete-button",
- process && "d-none"
- )}
- onClick={() => {
- getBuilder().deleteImage(index);
- }}
- />
- </div>
- ))}
- <input
- type="file"
- accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
- onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
- const { files } = event.currentTarget;
- if (files != null && files.length !== 0) {
- getBuilder().appendImage(files[0]);
- }
- }}
- disabled={process}
- />
- </div>
- ),
- },
- {
- name: "preview",
- text: "preview",
- page: (
- <div
- className="markdown-container timeline-markdown-post-edit-page"
- dangerouslySetInnerHTML={{ __html: previewHtml }}
- />
- ),
- },
- ]}
- />
- <ConfirmDialog
- onClose={() => setShowLeaveConfirmDialog(false)}
- onConfirm={onClose}
- open={showLeaveConfirmDialog}
- title="timeline.dropDraft"
- body="timeline.confirmLeave"
- />
- </>
- );
-};
-
-export default MarkdownPostEdit;
diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
deleted file mode 100644
index fc55185c..00000000
--- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import * as React from "react";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-function PostPropertyChangeDialog(props: {
- open: boolean;
- onClose: () => void;
- post: HttpTimelinePostInfo;
- onSuccess: (post: HttpTimelinePostInfo) => void;
-}): React.ReactElement | null {
- const { open, onClose, post, onSuccess } = props;
-
- return (
- <OperationDialog
- title="timeline.changePostPropertyDialog.title"
- onClose={onClose}
- open={open}
- inputScheme={[
- {
- label: "timeline.changePostPropertyDialog.time",
- type: "datetime",
- initValue: post.time,
- },
- ]}
- onProcess={([time]) => {
- return getHttpTimelineClient().patchPost(
- post.timelineOwnerV2,
- post.timelineNameV2,
- post.id,
- {
- time: time === "" ? undefined : new Date(time).toISOString(),
- }
- );
- }}
- onSuccessAndClose={onSuccess}
- />
- );
-}
-
-export default PostPropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css
deleted file mode 100644
index 4dd4fdcc..00000000
--- a/FrontEnd/src/views/timeline/Timeline.css
+++ /dev/null
@@ -1,244 +0,0 @@
-.timeline {
- z-index: 0;
- position: relative;
- width: 100%;
-}
-
-@keyframes timeline-line-node {
- to {
- box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
- }
-}
-
-@keyframes timeline-line-node-current {
- to {
- box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color);
- }
-}
-
-@keyframes timeline-line-node-loading {
- to {
- box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
- }
-}
-
-@keyframes timeline-line-node-loading-edge {
- from {
- transform: rotate(0turn);
- }
- to {
- transform: rotate(1turn);
- }
-}
-
-@keyframes timeline-top-loading-enter {
- from {
- transform: translate(0, -100%);
- }
-}
-
-@keyframes timeline-post-enter {
- from {
- transform: translate(0, 100%);
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-.timeline-top-loading-enter {
- animation: 1s timeline-top-loading-enter;
-}
-
-.timeline-line {
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 30px;
- position: absolute;
- z-index: 1;
- left: 2em;
- top: 0;
- bottom: 0;
- transition: left 0.5s;
-}
-
-@media (max-width: 575.98px) {
- .timeline-line {
- left: 1em;
- }
-}
-
-.timeline-line .segment {
- width: 7px;
- background: var(--cru-primary-color);
-}
-.timeline-line .segment.start {
- height: 1.8em;
- flex: 0 0 auto;
-}
-.timeline-line .segment.end {
- flex: 1 1 auto;
-}
-.timeline-line .segment.current-end {
- height: 2em;
- flex: 0 0 auto;
- background: linear-gradient(var(--cru-primary-enhance-color), white);
-}
-.timeline-line .node-container {
- flex: 0 0 auto;
- position: relative;
- width: 18px;
- height: 18px;
-}
-.timeline-line .node {
- width: 20px;
- height: 20px;
- position: absolute;
- background: var(--cru-primary-color);
- left: -1px;
- top: -1px;
- border-radius: 50%;
- box-sizing: border-box;
- z-index: 1;
- animation: 1s infinite alternate;
- animation-name: timeline-line-node;
-}
-.timeline-line .node-loading-edge {
- color: var(--cru-primary-color);
- width: 38px;
- height: 38px;
- position: absolute;
- left: -10px;
- top: -10px;
- box-sizing: border-box;
- z-index: 2;
- animation: 1.5s linear infinite timeline-line-node-loading-edge;
-}
-.timeline-line.current .segment.start {
- background: linear-gradient(
- var(--cru-primary-color),
- var(--cru-primary-enhance-color)
- );
-}
-
-.timeline-line.current .segment.end {
- background: var(--cru-primary-enhance-color);
-}
-
-.timeline-line.current .node {
- background: var(--cru-primary-enhance-color);
- animation-name: timeline-line-node-current;
-}
-
-.timeline-line.loading .node {
- background: var(--cru-primary-color);
- animation-name: timeline-line-node-loading;
-}
-
-.timeline-item {
- position: relative;
- padding: 0.5em;
-}
-
-.timeline-item-card {
- position: relative;
- padding: 0.5em 0.5em 0.5em 4em;
-}
-
-.timeline-item-card.enter-animation {
- animation: 0.6s forwards;
- opacity: 0;
-}
-
-@media (max-width: 575.98px) {
- .timeline-item-card {
- padding-left: 3em;
- }
-}
-
-.timeline-item-header {
- display: flex;
- align-items: center;
-}
-
-.timeline-avatar {
- border-radius: 50%;
- width: 2em;
- height: 2em;
-}
-
-.timeline-item-delete-button {
- position: absolute;
- right: 0;
- bottom: 0;
-}
-
-.timeline-content {
- white-space: pre-line;
-}
-
-.timeline-content-image {
- max-width: 80%;
- max-height: 200px;
-}
-
-.timeline-date-item {
- position: relative;
- padding: 0.3em 0 0.3em 4em;
-}
-
-.timeline-date-item-badge {
- display: inline-block;
- padding: 0.1em 0.4em;
- border-radius: 0.4em;
- background: #7c7c7c;
- color: white;
- font-size: 0.8em;
-}
-
-.timeline-post-item-options-mask {
- background: rgba(255, 255, 255, 0.85);
- z-index: 100;
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
-
- display: flex;
- justify-content: space-around;
- align-items: center;
-
- border-radius: var(--cru-card-border-radius);
-}
-
-.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-card {
- position: fixed;
- z-index: 1029;
- top: 56px;
- right: 0;
- margin: 0.5em;
-}
-
-.timeline-top {
- position: sticky;
- top: 56px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx
deleted file mode 100644
index fdf7f0a0..00000000
--- a/FrontEnd/src/views/timeline/TimelineCard.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import classnames from "classnames";
-import { HubConnectionState } from "@microsoft/signalr";
-
-import { useIsSmallScreen } from "@/utilities/hooks";
-import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
-import { useUser } from "@/services/user";
-import { pushAlert } from "@/services/alert";
-import { HttpTimelineInfo } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
-
-import UserAvatar from "../common/user/UserAvatar";
-import PopupMenu from "../common/menu/PopupMenu";
-import FullPageDialog from "../common/dialog/FullPageDialog";
-import Card from "../common/Card";
-import TimelineDeleteDialog from "./TimelineDeleteDialog";
-import ConnectionStatusBadge from "./ConnectionStatusBadge";
-import CollapseButton from "./CollapseButton";
-import { TimelineMemberDialog } from "./TimelineMember";
-import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import IconButton from "../common/button/IconButton";
-
-export interface TimelinePageCardProps {
- timeline: HttpTimelineInfo;
- connectionStatus: HubConnectionState;
- className?: string;
- onReload: () => void;
-}
-
-const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
- const { timeline, connectionStatus, onReload, className } = props;
-
- const { t } = useTranslation();
-
- const [dialog, setDialog] = React.useState<
- "member" | "property" | "delete" | null
- >(null);
-
- const [collapse, setCollapse] = React.useState(true);
- const toggleCollapse = (): void => {
- setCollapse((o) => !o);
- };
-
- const isSmallScreen = useIsSmallScreen();
-
- const user = useUser();
-
- const content = (
- <>
- <h3 className="cru-color-primary d-inline-block align-middle">
- {timeline.title}
- <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small>
- </h3>
- <div>
- <UserAvatar
- username={timeline.owner.username}
- className="cru-avatar small cru-round me-3"
- />
- {timeline.owner.nickname}
- <small className="ms-3 cru-color-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="mt-2 cru-text-end">
- {user != null ? (
- <IconButton
- icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
- className="me-3"
- onClick={() => {
- getHttpBookmarkClient()
- [timeline.isBookmark ? "delete" : "post"](
- user.username,
- timeline.owner.username,
- timeline.nameV2
- )
- .then(onReload, () => {
- pushAlert({
- message: timeline.isBookmark
- ? "timeline.removeBookmarkFail"
- : "timeline.addBookmarkFail",
- type: "danger",
- });
- });
- }}
- />
- ) : null}
- <IconButton
- icon="people"
- className="me-3"
- onClick={() => setDialog("member")}
- />
- {timeline.manageable ? (
- <PopupMenu
- items={[
- {
- type: "button",
- text: "timeline.manageItem.property",
- onClick: () => setDialog("property"),
- },
- { type: "divider" },
- {
- type: "button",
- onClick: () => setDialog("delete"),
- color: "danger",
- text: "timeline.manageItem.delete",
- },
- ]}
- containerClassName="d-inline"
- >
- <IconButton icon="three-dots-vertical" />
- </PopupMenu>
- ) : null}
- </div>
- </>
- );
-
- return (
- <>
- <Card className={classnames("p-2 cru-clearfix", className)}>
- <div
- className={classnames(
- "cru-float-right d-flex align-items-center",
- !collapse && "ms-3"
- )}
- >
- <ConnectionStatusBadge status={connectionStatus} className="me-2" />
- <CollapseButton collapse={collapse} onClick={toggleCollapse} />
- </div>
- {isSmallScreen ? (
- <FullPageDialog
- onBack={toggleCollapse}
- show={!collapse}
- contentContainerClassName="p-2"
- >
- {content}
- </FullPageDialog>
- ) : (
- <div style={{ display: collapse ? "none" : "inline" }}>{content}</div>
- )}
- </Card>
- <TimelineMemberDialog
- timeline={timeline}
- onClose={() => setDialog(null)}
- open={dialog === "member"}
- onChange={onReload}
- />
- <TimelinePropertyChangeDialog
- timeline={timeline}
- close={() => setDialog(null)}
- open={dialog === "property"}
- onChange={onReload}
- />
- <TimelineDeleteDialog
- timeline={timeline}
- open={dialog === "delete"}
- close={() => setDialog(null)}
- />
- </>
- );
-};
-
-export default TimelineCard;
diff --git a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
deleted file mode 100644
index 5f4ac706..00000000
--- a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react";
-import TimelineLine from "./TimelineLine";
-
-export interface TimelineDateItemProps {
- date: Date;
-}
-
-const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
- return (
- <div className="timeline-date-item">
- <TimelineLine center="none" />
- <div className="timeline-date-item-badge">
- {date.toLocaleDateString()}
- </div>
- </div>
- );
-};
-
-export default TimelineDateLabel;
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
deleted file mode 100644
index c960b3c2..00000000
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { Trans } from "react-i18next";
-
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-interface TimelineDeleteDialog {
- timeline: HttpTimelineInfo;
- open: boolean;
- close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
- const navigate = useNavigate();
-
- const { timeline } = props;
-
- return (
- <OperationDialog
- open={props.open}
- onClose={props.close}
- title="timeline.deleteDialog.title"
- themeColor="danger"
- inputPrompt={() => {
- return (
- <Trans
- i18nKey="timeline.deleteDialog.inputPrompt"
- values={{ name: timeline.nameV2 }}
- >
- 0<code className="mx-2">1</code>2
- </Trans>
- );
- }}
- inputScheme={[
- {
- type: "text",
- },
- ]}
- inputValidator={([value]) => {
- if (value !== timeline.nameV2) {
- return { 0: "timeline.deleteDialog.notMatch" };
- } else {
- return null;
- }
- }}
- onProcess={() => {
- return getHttpTimelineClient().deleteTimeline(
- timeline.owner.username,
- timeline.nameV2
- );
- }}
- onSuccessAndClose={() => {
- navigate("/", { replace: true });
- }}
- />
- );
-};
-
-export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
deleted file mode 100644
index 5e0728d4..00000000
--- a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import TimelineLine, { TimelineLineProps } from "./TimelineLine";
-
-export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> {
- height?: number | string;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => {
- const { height, style, className, center, ...lineProps } = props;
-
- return (
- <div
- style={{ ...style, height: height }}
- className={classnames("timeline-item", className)}
- >
- <TimelineLine center={center ?? "none"} {...lineProps} />
- </div>
- );
-};
-
-export default TimelineEmptyItem;
diff --git a/FrontEnd/src/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx
deleted file mode 100644
index 4a87e6e0..00000000
--- a/FrontEnd/src/views/timeline/TimelineLine.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-export interface TimelineLineProps {
- current?: boolean;
- startSegmentLength?: string | number;
- center: "node" | "loading" | "none";
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineLine: React.FC<TimelineLineProps> = ({
- startSegmentLength,
- center,
- current,
- className,
- style,
-}) => {
- return (
- <div
- className={classnames(
- "timeline-line",
- current && "current",
- center === "loading" && "loading",
- className
- )}
- style={style}
- >
- <div className="segment start" style={{ height: startSegmentLength }} />
- {center !== "none" ? (
- <div className="node-container">
- <div className="node"></div>
- {center === "loading" ? (
- <svg className="node-loading-edge" viewBox="0 0 100 100">
- <path
- d="M 50,10 A 40 40 45 0 1 78.28,21.72"
- stroke="currentcolor"
- strokeLinecap="square"
- strokeWidth="8"
- />
- </svg>
- ) : null}
- </div>
- ) : null}
- {center !== "loading" ? <div className="segment end"></div> : null}
- {current && <div className="segment current-end" />}
- </div>
- );
-};
-
-export default TimelineLine;
diff --git a/FrontEnd/src/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx
deleted file mode 100644
index f876cba9..00000000
--- a/FrontEnd/src/views/timeline/TimelineLoading.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as React from "react";
-
-import TimelineEmptyItem from "./TimelineEmptyItem";
-
-const TimelineLoading: React.FC = () => {
- return (
- <TimelineEmptyItem
- className="timeline-top-loading-enter"
- height={100}
- center="loading"
- startSegmentLength={56}
- />
- );
-};
-
-export default TimelineLoading;
diff --git a/FrontEnd/src/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css
deleted file mode 100644
index adb78764..00000000
--- a/FrontEnd/src/views/timeline/TimelineMember.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.timeline-member-item {
- border: var(--cru-background-1-color) solid;
- border-width: 0.5px 1px;
-}
-
-.timeline-member-item > div {
- padding: 0.5em;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
deleted file mode 100644
index 9ed192e5..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { marked } from "marked";
-
-import { UiLogicError } from "@/common";
-
-import { HttpNetworkError } from "@/http/common";
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import { useUser } from "@/services/user";
-
-import Skeleton from "../common/Skeleton";
-import LoadFailReload from "../common/LoadFailReload";
-
-const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
- const { post, className, style } = props;
-
- const [text, setText] = React.useState<string | null>(null);
- const [error, setError] = React.useState<"offline" | "error" | null>(null);
-
- const [reloadKey, setReloadKey] = React.useState<number>(0);
-
- React.useEffect(() => {
- let subscribe = true;
-
- setText(null);
- setError(null);
-
- void getHttpTimelineClient()
- .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id)
- .then(
- (data) => {
- if (subscribe) setText(data);
- },
- (error) => {
- if (subscribe) {
- if (error instanceof HttpNetworkError) {
- setError("offline");
- } else {
- setError("error");
- }
- }
- }
- );
-
- return () => {
- subscribe = false;
- };
- }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
-
- if (error != null) {
- return (
- <LoadFailReload
- className={className}
- style={style}
- onReload={() => setReloadKey(reloadKey + 1)}
- />
- );
- } else if (text == null) {
- return <Skeleton />;
- } else {
- return (
- <div className={className} style={style}>
- {text}
- </div>
- );
- }
-};
-
-const ImageView: React.FC<TimelinePostContentViewProps> = (props) => {
- const { post, className, style } = props;
-
- useUser();
-
- return (
- <img
- src={getHttpTimelineClient().generatePostDataUrl(
- post.timelineOwnerV2,
- post.timelineNameV2,
- post.id
- )}
- className={classnames(className, "timeline-content-image")}
- style={style}
- />
- );
-};
-
-const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
- const { post, className, style } = props;
-
- const [markdown, setMarkdown] = React.useState<string | null>(null);
- const [error, setError] = React.useState<"offline" | "error" | null>(null);
-
- const [reloadKey, setReloadKey] = React.useState<number>(0);
-
- React.useEffect(() => {
- let subscribe = true;
-
- setMarkdown(null);
- setError(null);
-
- void getHttpTimelineClient()
- .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id)
- .then(
- (data) => {
- if (subscribe) setMarkdown(data);
- },
- (error) => {
- if (subscribe) {
- if (error instanceof HttpNetworkError) {
- setError("offline");
- } else {
- setError("error");
- }
- }
- }
- );
-
- return () => {
- subscribe = false;
- };
- }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
-
- const markdownHtml = React.useMemo<string | null>(() => {
- if (markdown == null) return null;
- return marked.parse(markdown);
- }, [markdown]);
-
- if (error != null) {
- return (
- <LoadFailReload
- className={className}
- style={style}
- onReload={() => setReloadKey(reloadKey + 1)}
- />
- );
- } else if (markdown == null) {
- return <Skeleton />;
- } else {
- if (markdownHtml == null) {
- throw new UiLogicError("Markdown is not null but markdown html is.");
- }
- return (
- <div
- className={classnames(className, "markdown-container")}
- style={style}
- dangerouslySetInnerHTML={{
- __html: markdownHtml,
- }}
- />
- );
- }
-};
-
-export interface TimelinePostContentViewProps {
- post: HttpTimelinePostInfo;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
- "text/plain": TextView,
- "text/markdown": MarkdownView,
- "image/png": ImageView,
- "image/jpeg": ImageView,
- "image/gif": ImageView,
- "image/webp": ImageView,
-};
-
-const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = (
- props
-) => {
- const { post, className, style } = props;
-
- const type = post.dataList[0].kind;
-
- if (type in viewMap) {
- const View = viewMap[type];
- return <View post={post} className={className} style={style} />;
- } else {
- // TODO: i18n
- console.error("Unknown post type", post);
- return <div>Error, unknown post type!</div>;
- }
-};
-
-export default TimelinePostContentView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css
deleted file mode 100644
index 9b7629e2..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEdit.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.timeline-post-edit {
- position: sticky !important;
- top: 106px;
- z-index: 100;
-}
-
-.timeline-post-edit-image {
- max-width: 100px;
- max-height: 100px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
deleted file mode 100644
index 38e72264..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-import {
- getHttpTimelineClient,
- HttpTimelineInfo,
- HttpTimelinePostInfo,
- HttpTimelinePostPostRequestData,
-} from "@/http/timeline";
-
-import { pushAlert } from "@/services/alert";
-
-import base64 from "@/utilities/base64";
-
-import BlobImage from "../common/BlobImage";
-import LoadingButton from "../common/button/LoadingButton";
-import PopupMenu from "../common/menu/PopupMenu";
-import MarkdownPostEdit from "./MarkdownPostEdit";
-import TimelinePostEditCard from "./TimelinePostEditCard";
-import IconButton from "../common/button/IconButton";
-
-import "./TimelinePostEdit.css";
-
-interface TimelinePostEditTextProps {
- text: string;
- disabled: boolean;
- onChange: (text: string) => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => {
- const { text, disabled, onChange, className, style } = props;
-
- return (
- <textarea
- value={text}
- disabled={disabled}
- onChange={(event) => {
- onChange(event.target.value);
- }}
- className={className}
- style={style}
- />
- );
-};
-
-interface TimelinePostEditImageProps {
- onSelect: (file: File | null) => void;
- disabled: boolean;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
- const { onSelect, disabled } = props;
-
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [error, setError] = React.useState<boolean>(false);
-
- const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
- setError(false);
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- onSelect(null);
- } else {
- setFile(files[0]);
- }
- };
-
- React.useEffect(() => {
- return () => {
- onSelect(null);
- };
- }, [onSelect]);
-
- return (
- <>
- <input
- type="file"
- onChange={onInputChange}
- accept="image/*"
- disabled={disabled}
- className="mx-3 my-1"
- />
- {file != null && !error && (
- <BlobImage
- blob={file}
- className="timeline-post-edit-image"
- onLoad={() => onSelect(file)}
- onError={() => {
- onSelect(null);
- setError(true);
- }}
- />
- )}
- {error ? <div className="text-danger">{t("loadImageError")}</div> : null}
- </>
- );
-};
-
-type PostKind = "text" | "markdown" | "image";
-
-const postKindIconMap: Record<PostKind, string> = {
- text: "fonts",
- markdown: "markdown",
- image: "image",
-};
-
-export interface TimelinePostEditProps {
- className?: string;
- style?: React.CSSProperties;
- timeline: HttpTimelineInfo;
- onPosted: (newPost: HttpTimelinePostInfo) => void;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { timeline, style, className, onPosted } = props;
-
- const { t } = useTranslation();
-
- const [process, setProcess] = React.useState<boolean>(false);
-
- const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text");
- const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false);
-
- const [text, setText] = React.useState<string>("");
- const [image, setImage] = React.useState<File | null>(null);
-
- const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
-
- React.useEffect(() => {
- setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
- }, [draftTextLocalStorageKey]);
-
- const canSend =
- (kind === "text" && text.length !== 0) ||
- (kind === "image" && image != null);
-
- const onPostError = (): void => {
- pushAlert({
- type: "danger",
- message: "timeline.sendPostFailed",
- });
- };
-
- const onSend = async (): Promise<void> => {
- setProcess(true);
-
- let requestData: HttpTimelinePostPostRequestData;
- switch (kind) {
- case "text":
- requestData = {
- contentType: "text/plain",
- data: await base64(text),
- };
- break;
- case "image":
- if (image == null) {
- throw new UiLogicError(
- "Content type is image but image blob is null.",
- );
- }
- requestData = {
- contentType: image.type,
- data: await base64(image),
- };
- break;
- default:
- throw new UiLogicError("Unknown content type.");
- }
-
- getHttpTimelineClient()
- .postPost(timeline.owner.username, timeline.nameV2, {
- dataList: [requestData],
- })
- .then(
- (data) => {
- if (kind === "text") {
- setText("");
- window.localStorage.removeItem(draftTextLocalStorageKey);
- }
- setProcess(false);
- setKind("text");
- onPosted(data);
- },
- () => {
- setProcess(false);
- onPostError();
- },
- );
- };
-
- return (
- <TimelinePostEditCard className={className} style={style}>
- {showMarkdown ? (
- <MarkdownPostEdit
- className="cru-fill-parent"
- onClose={() => setShowMarkdown(false)}
- owner={timeline.owner.username}
- timeline={timeline.nameV2}
- onPosted={onPosted}
- onPostError={onPostError}
- />
- ) : (
- <div className="row">
- <div className="col px-1 py-1">
- {(() => {
- if (kind === "text") {
- return (
- <TimelinePostEditText
- className="cru-fill-parent timeline-post-edit"
- text={text}
- disabled={process}
- onChange={(t) => {
- setText(t);
- window.localStorage.setItem(draftTextLocalStorageKey, t);
- }}
- />
- );
- } else if (kind === "image") {
- return (
- <TimelinePostEditImage
- onSelect={setImage}
- disabled={process}
- />
- );
- }
- })()}
- </div>
- <div className="col col-auto align-self-end m-1">
- <div className="d-block cru-text-center mt-1 mb-2">
- <PopupMenu
- items={(["text", "image", "markdown"] as const).map((kind) => ({
- type: "button",
- text: `timeline.post.type.${kind}`,
- iconClassName: postKindIconMap[kind],
- onClick: () => {
- if (kind === "markdown") {
- setShowMarkdown(true);
- } else {
- setKind(kind);
- }
- },
- }))}
- >
- <IconButton large icon={postKindIconMap[kind]} />
- </PopupMenu>
- </div>
- <LoadingButton
- onClick={() => void onSend()}
- disabled={!canSend}
- loading={process}
- >
- {t("timeline.send")}
- </LoadingButton>
- </div>
- </div>
- )}
- </TimelinePostEditCard>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
deleted file mode 100644
index d2f7bd72..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import Card from "../common/Card";
-import TimelineLine from "./TimelineLine";
-
-import "./TimelinePostEdit.css";
-
-export interface TimelinePostEditCardProps {
- className?: string;
- style?: React.CSSProperties;
- children?: React.ReactNode;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({
- className,
- style,
- children,
-}) => {
- return (
- <div
- className={classnames("timeline-item timeline-post-edit", className)}
- style={style}
- >
- <TimelineLine center="node" />
- <Card className="timeline-item-card">{children}</Card>
- </div>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
deleted file mode 100644
index 1ef0a287..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import * as React from "react";
-import { Trans } from "react-i18next";
-import { Link } from "react-router-dom";
-
-import TimelinePostEditCard from "./TimelinePostEditCard";
-
-export default function TimelinePostEditNoLogin(): React.ReactElement | null {
- return (
- <TimelinePostEditCard>
- <div className="mt-3 mb-4">
- <Trans
- i18nKey="timeline.postNoLogin"
- components={{ l: <Link to="/login" /> }}
- />
- </div>
- </TimelinePostEditCard>
- );
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx
deleted file mode 100644
index e3eac0f4..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostView.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import { pushAlert } from "@/services/alert";
-
-import { useClickOutside } from "@/utilities/hooks";
-
-import UserAvatar from "../common/user/UserAvatar";
-import Card from "../common/Card";
-import FlatButton from "../common/button/FlatButton";
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import TimelineLine from "./TimelineLine";
-import TimelinePostContentView from "./TimelinePostContentView";
-import PostPropertyChangeDialog from "./PostPropertyChangeDialog";
-import IconButton from "../common/button/IconButton";
-
-export interface TimelinePostViewProps {
- post: HttpTimelinePostInfo;
- className?: string;
- style?: React.CSSProperties;
- cardStyle?: React.CSSProperties;
- onChanged: (post: HttpTimelinePostInfo) => void;
- onDeleted: () => void;
-}
-
-const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
- const { post, className, style, cardStyle, onChanged, onDeleted } = props;
-
- const [operationMaskVisible, setOperationMaskVisible] =
- React.useState<boolean>(false);
- const [dialog, setDialog] = React.useState<
- "delete" | "changeproperty" | null
- >(null);
-
- const [maskElement, setMaskElement] = React.useState<HTMLElement | null>(
- null
- );
-
- useClickOutside(maskElement, () => setOperationMaskVisible(false));
-
- const cardRef = React.useRef<HTMLDivElement>(null);
- React.useEffect(() => {
- const cardIntersectionObserver = new IntersectionObserver(([e]) => {
- if (e.intersectionRatio > 0) {
- if (cardRef.current != null) {
- cardRef.current.style.animationName = "timeline-post-enter";
- }
- }
- });
- if (cardRef.current) {
- cardIntersectionObserver.observe(cardRef.current);
- }
-
- return () => {
- cardIntersectionObserver.disconnect();
- };
- }, []);
-
- return (
- <div
- id={`timeline-post-${post.id}`}
- className={classnames("timeline-item", className)}
- style={style}
- >
- <TimelineLine center="node" />
- <Card
- ref={cardRef}
- className="timeline-item-card enter-animation"
- style={cardStyle}
- >
- {post.editable ? (
- <IconButton
- icon="chevron-down"
- color="primary-enhance"
- className="cru-float-right"
- onClick={(e) => {
- setOperationMaskVisible(true);
- e.stopPropagation();
- }}
- />
- ) : null}
- <div className="timeline-item-header">
- <span className="me-2">
- <span>
- <UserAvatar
- username={post.author.username}
- className="timeline-avatar me-1"
- />
- <small className="text-dark me-2">{post.author.nickname}</small>
- <small className="text-secondary white-space-no-wrap">
- {new Date(post.time).toLocaleTimeString()}
- </small>
- </span>
- </span>
- </div>
- <div className="timeline-content">
- <TimelinePostContentView post={post} />
- </div>
- {operationMaskVisible ? (
- <div
- ref={setMaskElement}
- className="timeline-post-item-options-mask"
- onClick={() => {
- setOperationMaskVisible(false);
- }}
- >
- <FlatButton
- text="changeProperty"
- onClick={(e) => {
- setDialog("changeproperty");
- e.stopPropagation();
- }}
- />
- <FlatButton
- text="delete"
- color="danger"
- onClick={(e) => {
- setDialog("delete");
- e.stopPropagation();
- }}
- />
- </div>
- ) : null}
- </Card>
- <ConfirmDialog
- title="timeline.post.deleteDialog.title"
- body="timeline.post.deleteDialog.prompt"
- open={dialog === "delete"}
- onClose={() => {
- setDialog(null);
- setOperationMaskVisible(false);
- }}
- onConfirm={() => {
- void getHttpTimelineClient()
- .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
- .then(onDeleted, () => {
- pushAlert({
- type: "danger",
- message: "timeline.deletePostFailed",
- });
- });
- }}
- />
- <PostPropertyChangeDialog
- open={dialog === "changeproperty"}
- onClose={() => {
- setDialog(null);
- setOperationMaskVisible(false);
- }}
- post={post}
- onSuccess={onChanged}
- />
- </div>
- );
-};
-
-export default TimelinePostView;
diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index 63750445..00000000
--- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import * as React from "react";
-
-import {
- getHttpTimelineClient,
- HttpTimelineInfo,
- HttpTimelinePatchRequest,
- kTimelineVisibilities,
- TimelineVisibility,
-} from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface TimelinePropertyChangeDialogProps {
- open: boolean;
- close: () => void;
- timeline: HttpTimelineInfo;
- onChange: () => void;
-}
-
-const labelMap: { [key in TimelineVisibility]: string } = {
- Private: "timeline.visibility.private",
- Public: "timeline.visibility.public",
- Register: "timeline.visibility.register",
-};
-
-const TimelinePropertyChangeDialog: React.FC<
- TimelinePropertyChangeDialogProps
-> = (props) => {
- const { timeline, onChange } = props;
-
- return (
- <OperationDialog
- title={"timeline.dialogChangeProperty.title"}
- inputScheme={
- [
- {
- type: "text",
- label: "timeline.dialogChangeProperty.titleField",
- initValue: timeline.title,
- },
- {
- type: "select",
- label: "timeline.dialogChangeProperty.visibility",
- options: kTimelineVisibilities.map((v) => ({
- label: labelMap[v],
- value: v,
- })),
- initValue: timeline.visibility,
- },
- {
- type: "text",
- label: "timeline.dialogChangeProperty.description",
- initValue: timeline.description,
- },
- {
- type: "color",
- label: "timeline.dialogChangeProperty.color",
- initValue: timeline.color ?? null,
- canBeNull: true,
- },
- ] as const
- }
- open={props.open}
- onClose={props.close}
- onProcess={([newTitle, newVisibility, newDescription, newColor]) => {
- const req: HttpTimelinePatchRequest = {};
- if (newTitle !== timeline.title) {
- req.title = newTitle;
- }
- if (newVisibility !== timeline.visibility) {
- req.visibility = newVisibility as TimelineVisibility;
- }
- if (newDescription !== timeline.description) {
- req.description = newDescription;
- }
- const nc = newColor ?? "";
- if (nc !== timeline.color) {
- req.color = nc;
- }
- return getHttpTimelineClient()
- .patchTimeline(timeline.owner.username, timeline.nameV2, req)
- .then(onChange);
- }}
- />
- );
-};
-
-export default TimelinePropertyChangeDialog;