From 47587812b809fee2a95c76266d9d0e42fc4ac1ca Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 15 Jun 2021 14:14:28 +0800 Subject: ... --- FrontEnd/src/App.tsx | 83 ++++ FrontEnd/src/app/App.tsx | 83 ---- FrontEnd/src/app/common.ts | 39 -- FrontEnd/src/app/http/bookmark.ts | 49 --- FrontEnd/src/app/http/common.ts | 214 ---------- FrontEnd/src/app/http/highlight.ts | 49 --- FrontEnd/src/app/http/search.ts | 36 -- FrontEnd/src/app/http/timeline.ts | 234 ---------- FrontEnd/src/app/http/token.ts | 71 ---- FrontEnd/src/app/http/user.ts | 161 ------- FrontEnd/src/app/i18n.ts | 88 ---- FrontEnd/src/app/index.ejs | 29 -- FrontEnd/src/app/index.sass | 120 ------ FrontEnd/src/app/index.tsx | 21 - FrontEnd/src/app/locales/en/admin.json | 35 -- FrontEnd/src/app/locales/en/translation.json | 224 ---------- FrontEnd/src/app/locales/zh/admin.json | 35 -- FrontEnd/src/app/locales/zh/translation.json | 224 ---------- FrontEnd/src/app/palette.ts | 116 ----- FrontEnd/src/app/service-worker.tsx | 104 ----- FrontEnd/src/app/services/TimelinePostBuilder.ts | 116 ----- FrontEnd/src/app/services/alert.ts | 63 --- FrontEnd/src/app/services/timeline.ts | 85 ---- FrontEnd/src/app/services/user.ts | 228 ---------- FrontEnd/src/app/tsconfig.json | 13 - FrontEnd/src/app/typings.d.ts | 24 -- FrontEnd/src/app/utilities/mediaQuery.ts | 5 - FrontEnd/src/app/utilities/url.ts | 17 - .../utilities/useReverseScrollPositionRemember.ts | 77 ---- FrontEnd/src/app/utilities/useScrollToTop.ts | 43 -- FrontEnd/src/app/views/about/about.sass | 4 - FrontEnd/src/app/views/about/author-avatar.png | Bin 12038 -> 0 bytes FrontEnd/src/app/views/about/github.png | Bin 4268 -> 0 bytes FrontEnd/src/app/views/about/index.tsx | 156 ------- FrontEnd/src/app/views/admin/Admin.tsx | 48 --- FrontEnd/src/app/views/admin/AdminNav.tsx | 44 -- FrontEnd/src/app/views/admin/MoreAdmin.tsx | 13 - FrontEnd/src/app/views/admin/UserAdmin.tsx | 396 ----------------- FrontEnd/src/app/views/admin/admin.sass | 22 - FrontEnd/src/app/views/center/CenterBoards.tsx | 107 ----- FrontEnd/src/app/views/center/TimelineBoard.tsx | 370 ---------------- .../src/app/views/center/TimelineCreateDialog.tsx | 53 --- FrontEnd/src/app/views/center/center.sass | 36 -- FrontEnd/src/app/views/center/index.tsx | 64 --- FrontEnd/src/app/views/common/AppBar.tsx | 80 ---- FrontEnd/src/app/views/common/BlobImage.tsx | 27 -- FrontEnd/src/app/views/common/ConfirmDialog.tsx | 40 -- FrontEnd/src/app/views/common/FlatButton.tsx | 36 -- FrontEnd/src/app/views/common/FullPage.tsx | 39 -- FrontEnd/src/app/views/common/ImageCropper.tsx | 306 ------------- FrontEnd/src/app/views/common/LoadFailReload.tsx | 37 -- FrontEnd/src/app/views/common/LoadingButton.tsx | 29 -- FrontEnd/src/app/views/common/LoadingPage.tsx | 12 - FrontEnd/src/app/views/common/Menu.tsx | 92 ---- FrontEnd/src/app/views/common/OperationDialog.tsx | 471 --------------------- FrontEnd/src/app/views/common/SearchInput.tsx | 78 ---- FrontEnd/src/app/views/common/Skeleton.tsx | 30 -- FrontEnd/src/app/views/common/TabPages.tsx | 74 ---- FrontEnd/src/app/views/common/TimelineLogo.tsx | 26 -- FrontEnd/src/app/views/common/ToggleIconButton.tsx | 30 -- FrontEnd/src/app/views/common/UserTimelineLogo.tsx | 26 -- FrontEnd/src/app/views/common/alert/AlertHost.tsx | 106 ----- FrontEnd/src/app/views/common/alert/alert.sass | 15 - FrontEnd/src/app/views/common/common.sass | 191 --------- FrontEnd/src/app/views/common/user/UserAvatar.tsx | 19 - FrontEnd/src/app/views/home/TimelineListView.tsx | 101 ----- .../src/app/views/home/WebsiteIntroduction.tsx | 77 ---- FrontEnd/src/app/views/home/home.sass | 29 -- FrontEnd/src/app/views/home/index.tsx | 74 ---- FrontEnd/src/app/views/login/index.tsx | 151 ------- FrontEnd/src/app/views/login/login.sass | 2 - FrontEnd/src/app/views/search/index.tsx | 128 ------ FrontEnd/src/app/views/search/search.sass | 13 - .../src/app/views/settings/ChangeAvatarDialog.tsx | 305 ------------- .../app/views/settings/ChangeNicknameDialog.tsx | 32 -- .../app/views/settings/ChangePasswordDialog.tsx | 68 --- FrontEnd/src/app/views/settings/index.tsx | 138 ------ FrontEnd/src/app/views/settings/settings.sass | 14 - .../app/views/timeline-common/CollapseButton.tsx | 23 - .../timeline-common/ConnectionStatusBadge.tsx | 39 -- .../app/views/timeline-common/MarkdownPostEdit.tsx | 205 --------- .../timeline-common/PostPropertyChangeDialog.tsx | 36 -- .../src/app/views/timeline-common/Timeline.tsx | 143 ------- .../views/timeline-common/TimelineDateLabel.tsx | 19 - .../src/app/views/timeline-common/TimelineLine.tsx | 51 --- .../app/views/timeline-common/TimelineLoading.tsx | 18 - .../app/views/timeline-common/TimelineMember.tsx | 195 --------- .../timeline-common/TimelinePageCardTemplate.tsx | 158 ------- .../views/timeline-common/TimelinePageTemplate.tsx | 190 --------- .../timeline-common/TimelinePagedPostListView.tsx | 43 -- .../timeline-common/TimelinePostContentView.tsx | 197 --------- .../TimelinePostDeleteConfirmDialog.tsx | 37 -- .../app/views/timeline-common/TimelinePostEdit.tsx | 291 ------------- .../views/timeline-common/TimelinePostListView.tsx | 79 ---- .../app/views/timeline-common/TimelinePostView.tsx | 151 ------- .../TimelinePropertyChangeDialog.tsx | 87 ---- .../src/app/views/timeline-common/TimelineTop.tsx | 27 -- .../app/views/timeline-common/timeline-common.sass | 259 ----------- FrontEnd/src/app/views/timeline/TimelineCard.tsx | 74 ---- .../app/views/timeline/TimelineDeleteDialog.tsx | 55 --- FrontEnd/src/app/views/timeline/index.tsx | 23 - FrontEnd/src/app/views/timeline/timeline.sass | 0 FrontEnd/src/app/views/user/UserCard.tsx | 51 --- FrontEnd/src/app/views/user/index.tsx | 28 -- FrontEnd/src/app/views/user/user.sass | 7 - FrontEnd/src/common.ts | 39 ++ FrontEnd/src/http/bookmark.ts | 49 +++ FrontEnd/src/http/common.ts | 214 ++++++++++ FrontEnd/src/http/highlight.ts | 49 +++ FrontEnd/src/http/search.ts | 36 ++ FrontEnd/src/http/timeline.ts | 234 ++++++++++ FrontEnd/src/http/token.ts | 71 ++++ FrontEnd/src/http/user.ts | 161 +++++++ FrontEnd/src/i18n.ts | 88 ++++ FrontEnd/src/index.ejs | 29 ++ FrontEnd/src/index.sass | 120 ++++++ FrontEnd/src/index.tsx | 21 + FrontEnd/src/locales/en/admin.json | 35 ++ FrontEnd/src/locales/en/translation.json | 224 ++++++++++ FrontEnd/src/locales/zh/admin.json | 35 ++ FrontEnd/src/locales/zh/translation.json | 224 ++++++++++ FrontEnd/src/palette.ts | 116 +++++ FrontEnd/src/service-worker.txt | 104 +++++ FrontEnd/src/services/TimelinePostBuilder.ts | 116 +++++ FrontEnd/src/services/alert.ts | 63 +++ FrontEnd/src/services/timeline.ts | 85 ++++ FrontEnd/src/services/user.ts | 228 ++++++++++ FrontEnd/src/sw/sw.ts | 62 --- FrontEnd/src/sw/tsconfig.json | 12 - FrontEnd/src/tsconfig.json | 10 +- FrontEnd/src/utilities/mediaQuery.ts | 5 + FrontEnd/src/utilities/url.ts | 17 + .../utilities/useReverseScrollPositionRemember.ts | 77 ++++ FrontEnd/src/utilities/useScrollToTop.ts | 43 ++ FrontEnd/src/views/about/about.sass | 4 + FrontEnd/src/views/about/author-avatar.png | Bin 0 -> 12038 bytes FrontEnd/src/views/about/github.png | Bin 0 -> 4268 bytes FrontEnd/src/views/about/index.tsx | 156 +++++++ FrontEnd/src/views/admin/Admin.tsx | 48 +++ FrontEnd/src/views/admin/AdminNav.tsx | 44 ++ FrontEnd/src/views/admin/MoreAdmin.tsx | 13 + FrontEnd/src/views/admin/UserAdmin.tsx | 396 +++++++++++++++++ FrontEnd/src/views/admin/admin.sass | 22 + FrontEnd/src/views/center/CenterBoards.tsx | 107 +++++ FrontEnd/src/views/center/TimelineBoard.tsx | 370 ++++++++++++++++ FrontEnd/src/views/center/TimelineCreateDialog.tsx | 53 +++ FrontEnd/src/views/center/center.sass | 36 ++ FrontEnd/src/views/center/index.tsx | 64 +++ FrontEnd/src/views/common/AppBar.tsx | 80 ++++ FrontEnd/src/views/common/BlobImage.tsx | 27 ++ FrontEnd/src/views/common/ConfirmDialog.tsx | 40 ++ FrontEnd/src/views/common/FlatButton.tsx | 36 ++ FrontEnd/src/views/common/FullPage.tsx | 39 ++ FrontEnd/src/views/common/ImageCropper.tsx | 306 +++++++++++++ FrontEnd/src/views/common/LoadFailReload.tsx | 37 ++ FrontEnd/src/views/common/LoadingButton.tsx | 29 ++ FrontEnd/src/views/common/LoadingPage.tsx | 12 + FrontEnd/src/views/common/Menu.tsx | 92 ++++ FrontEnd/src/views/common/OperationDialog.tsx | 471 +++++++++++++++++++++ FrontEnd/src/views/common/SearchInput.tsx | 78 ++++ FrontEnd/src/views/common/Skeleton.tsx | 30 ++ FrontEnd/src/views/common/TabPages.tsx | 74 ++++ FrontEnd/src/views/common/TimelineLogo.tsx | 26 ++ FrontEnd/src/views/common/ToggleIconButton.tsx | 30 ++ FrontEnd/src/views/common/UserTimelineLogo.tsx | 26 ++ FrontEnd/src/views/common/alert/AlertHost.tsx | 106 +++++ FrontEnd/src/views/common/alert/alert.sass | 15 + FrontEnd/src/views/common/common.sass | 191 +++++++++ FrontEnd/src/views/common/user/UserAvatar.tsx | 19 + FrontEnd/src/views/home/TimelineListView.tsx | 101 +++++ FrontEnd/src/views/home/WebsiteIntroduction.tsx | 77 ++++ FrontEnd/src/views/home/home.sass | 29 ++ FrontEnd/src/views/home/index.tsx | 74 ++++ FrontEnd/src/views/login/index.tsx | 151 +++++++ FrontEnd/src/views/login/login.sass | 2 + FrontEnd/src/views/search/index.tsx | 128 ++++++ FrontEnd/src/views/search/search.sass | 13 + FrontEnd/src/views/settings/ChangeAvatarDialog.tsx | 305 +++++++++++++ .../src/views/settings/ChangeNicknameDialog.tsx | 32 ++ .../src/views/settings/ChangePasswordDialog.tsx | 68 +++ FrontEnd/src/views/settings/index.tsx | 138 ++++++ FrontEnd/src/views/settings/settings.sass | 14 + .../src/views/timeline-common/CollapseButton.tsx | 23 + .../timeline-common/ConnectionStatusBadge.tsx | 39 ++ .../src/views/timeline-common/MarkdownPostEdit.tsx | 205 +++++++++ .../timeline-common/PostPropertyChangeDialog.tsx | 36 ++ FrontEnd/src/views/timeline-common/Timeline.tsx | 143 +++++++ .../views/timeline-common/TimelineDateLabel.tsx | 19 + .../src/views/timeline-common/TimelineLine.tsx | 51 +++ .../src/views/timeline-common/TimelineLoading.tsx | 18 + .../src/views/timeline-common/TimelineMember.tsx | 195 +++++++++ .../timeline-common/TimelinePageCardTemplate.tsx | 158 +++++++ .../views/timeline-common/TimelinePageTemplate.tsx | 190 +++++++++ .../timeline-common/TimelinePagedPostListView.tsx | 43 ++ .../timeline-common/TimelinePostContentView.tsx | 197 +++++++++ .../TimelinePostDeleteConfirmDialog.tsx | 37 ++ .../src/views/timeline-common/TimelinePostEdit.tsx | 291 +++++++++++++ .../views/timeline-common/TimelinePostListView.tsx | 79 ++++ .../src/views/timeline-common/TimelinePostView.tsx | 151 +++++++ .../TimelinePropertyChangeDialog.tsx | 87 ++++ FrontEnd/src/views/timeline-common/TimelineTop.tsx | 27 ++ .../src/views/timeline-common/timeline-common.sass | 259 +++++++++++ FrontEnd/src/views/timeline/TimelineCard.tsx | 74 ++++ .../src/views/timeline/TimelineDeleteDialog.tsx | 55 +++ FrontEnd/src/views/timeline/index.tsx | 23 + FrontEnd/src/views/timeline/timeline.sass | 0 FrontEnd/src/views/user/UserCard.tsx | 51 +++ FrontEnd/src/views/user/index.tsx | 28 ++ FrontEnd/src/views/user/user.sass | 7 + 209 files changed, 9267 insertions(+), 9376 deletions(-) create mode 100644 FrontEnd/src/App.tsx delete mode 100644 FrontEnd/src/app/App.tsx delete mode 100644 FrontEnd/src/app/common.ts delete mode 100644 FrontEnd/src/app/http/bookmark.ts delete mode 100644 FrontEnd/src/app/http/common.ts delete mode 100644 FrontEnd/src/app/http/highlight.ts delete mode 100644 FrontEnd/src/app/http/search.ts delete mode 100644 FrontEnd/src/app/http/timeline.ts delete mode 100644 FrontEnd/src/app/http/token.ts delete mode 100644 FrontEnd/src/app/http/user.ts delete mode 100644 FrontEnd/src/app/i18n.ts delete mode 100644 FrontEnd/src/app/index.ejs delete mode 100644 FrontEnd/src/app/index.sass delete mode 100644 FrontEnd/src/app/index.tsx delete mode 100644 FrontEnd/src/app/locales/en/admin.json delete mode 100644 FrontEnd/src/app/locales/en/translation.json delete mode 100644 FrontEnd/src/app/locales/zh/admin.json delete mode 100644 FrontEnd/src/app/locales/zh/translation.json delete mode 100644 FrontEnd/src/app/palette.ts delete mode 100644 FrontEnd/src/app/service-worker.tsx delete mode 100644 FrontEnd/src/app/services/TimelinePostBuilder.ts delete mode 100644 FrontEnd/src/app/services/alert.ts delete mode 100644 FrontEnd/src/app/services/timeline.ts delete mode 100644 FrontEnd/src/app/services/user.ts delete mode 100644 FrontEnd/src/app/tsconfig.json delete mode 100644 FrontEnd/src/app/typings.d.ts delete mode 100644 FrontEnd/src/app/utilities/mediaQuery.ts delete mode 100644 FrontEnd/src/app/utilities/url.ts delete mode 100644 FrontEnd/src/app/utilities/useReverseScrollPositionRemember.ts delete mode 100644 FrontEnd/src/app/utilities/useScrollToTop.ts delete mode 100644 FrontEnd/src/app/views/about/about.sass delete mode 100644 FrontEnd/src/app/views/about/author-avatar.png delete mode 100644 FrontEnd/src/app/views/about/github.png delete mode 100644 FrontEnd/src/app/views/about/index.tsx delete mode 100644 FrontEnd/src/app/views/admin/Admin.tsx delete mode 100644 FrontEnd/src/app/views/admin/AdminNav.tsx delete mode 100644 FrontEnd/src/app/views/admin/MoreAdmin.tsx delete mode 100644 FrontEnd/src/app/views/admin/UserAdmin.tsx delete mode 100644 FrontEnd/src/app/views/admin/admin.sass delete mode 100644 FrontEnd/src/app/views/center/CenterBoards.tsx delete mode 100644 FrontEnd/src/app/views/center/TimelineBoard.tsx delete mode 100644 FrontEnd/src/app/views/center/TimelineCreateDialog.tsx delete mode 100644 FrontEnd/src/app/views/center/center.sass delete mode 100644 FrontEnd/src/app/views/center/index.tsx delete mode 100644 FrontEnd/src/app/views/common/AppBar.tsx delete mode 100644 FrontEnd/src/app/views/common/BlobImage.tsx delete mode 100644 FrontEnd/src/app/views/common/ConfirmDialog.tsx delete mode 100644 FrontEnd/src/app/views/common/FlatButton.tsx delete mode 100644 FrontEnd/src/app/views/common/FullPage.tsx delete mode 100644 FrontEnd/src/app/views/common/ImageCropper.tsx delete mode 100644 FrontEnd/src/app/views/common/LoadFailReload.tsx delete mode 100644 FrontEnd/src/app/views/common/LoadingButton.tsx delete mode 100644 FrontEnd/src/app/views/common/LoadingPage.tsx delete mode 100644 FrontEnd/src/app/views/common/Menu.tsx delete mode 100644 FrontEnd/src/app/views/common/OperationDialog.tsx delete mode 100644 FrontEnd/src/app/views/common/SearchInput.tsx delete mode 100644 FrontEnd/src/app/views/common/Skeleton.tsx delete mode 100644 FrontEnd/src/app/views/common/TabPages.tsx delete mode 100644 FrontEnd/src/app/views/common/TimelineLogo.tsx delete mode 100644 FrontEnd/src/app/views/common/ToggleIconButton.tsx delete mode 100644 FrontEnd/src/app/views/common/UserTimelineLogo.tsx delete mode 100644 FrontEnd/src/app/views/common/alert/AlertHost.tsx delete mode 100644 FrontEnd/src/app/views/common/alert/alert.sass delete mode 100644 FrontEnd/src/app/views/common/common.sass delete mode 100644 FrontEnd/src/app/views/common/user/UserAvatar.tsx delete mode 100644 FrontEnd/src/app/views/home/TimelineListView.tsx delete mode 100644 FrontEnd/src/app/views/home/WebsiteIntroduction.tsx delete mode 100644 FrontEnd/src/app/views/home/home.sass delete mode 100644 FrontEnd/src/app/views/home/index.tsx delete mode 100644 FrontEnd/src/app/views/login/index.tsx delete mode 100644 FrontEnd/src/app/views/login/login.sass delete mode 100644 FrontEnd/src/app/views/search/index.tsx delete mode 100644 FrontEnd/src/app/views/search/search.sass delete mode 100644 FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx delete mode 100644 FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx delete mode 100644 FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx delete mode 100644 FrontEnd/src/app/views/settings/index.tsx delete mode 100644 FrontEnd/src/app/views/settings/settings.sass delete mode 100644 FrontEnd/src/app/views/timeline-common/CollapseButton.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/Timeline.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelineLine.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelineMember.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/TimelineTop.tsx delete mode 100644 FrontEnd/src/app/views/timeline-common/timeline-common.sass delete mode 100644 FrontEnd/src/app/views/timeline/TimelineCard.tsx delete mode 100644 FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx delete mode 100644 FrontEnd/src/app/views/timeline/index.tsx delete mode 100644 FrontEnd/src/app/views/timeline/timeline.sass delete mode 100644 FrontEnd/src/app/views/user/UserCard.tsx delete mode 100644 FrontEnd/src/app/views/user/index.tsx delete mode 100644 FrontEnd/src/app/views/user/user.sass create mode 100644 FrontEnd/src/common.ts create mode 100644 FrontEnd/src/http/bookmark.ts create mode 100644 FrontEnd/src/http/common.ts create mode 100644 FrontEnd/src/http/highlight.ts create mode 100644 FrontEnd/src/http/search.ts create mode 100644 FrontEnd/src/http/timeline.ts create mode 100644 FrontEnd/src/http/token.ts create mode 100644 FrontEnd/src/http/user.ts create mode 100644 FrontEnd/src/i18n.ts create mode 100644 FrontEnd/src/index.ejs create mode 100644 FrontEnd/src/index.sass create mode 100644 FrontEnd/src/index.tsx create mode 100644 FrontEnd/src/locales/en/admin.json create mode 100644 FrontEnd/src/locales/en/translation.json create mode 100644 FrontEnd/src/locales/zh/admin.json create mode 100644 FrontEnd/src/locales/zh/translation.json create mode 100644 FrontEnd/src/palette.ts create mode 100644 FrontEnd/src/service-worker.txt create mode 100644 FrontEnd/src/services/TimelinePostBuilder.ts create mode 100644 FrontEnd/src/services/alert.ts create mode 100644 FrontEnd/src/services/timeline.ts create mode 100644 FrontEnd/src/services/user.ts delete mode 100644 FrontEnd/src/sw/sw.ts delete mode 100644 FrontEnd/src/sw/tsconfig.json create mode 100644 FrontEnd/src/utilities/mediaQuery.ts create mode 100644 FrontEnd/src/utilities/url.ts create mode 100644 FrontEnd/src/utilities/useReverseScrollPositionRemember.ts create mode 100644 FrontEnd/src/utilities/useScrollToTop.ts create mode 100644 FrontEnd/src/views/about/about.sass create mode 100644 FrontEnd/src/views/about/author-avatar.png create mode 100644 FrontEnd/src/views/about/github.png create mode 100644 FrontEnd/src/views/about/index.tsx create mode 100644 FrontEnd/src/views/admin/Admin.tsx create mode 100644 FrontEnd/src/views/admin/AdminNav.tsx create mode 100644 FrontEnd/src/views/admin/MoreAdmin.tsx create mode 100644 FrontEnd/src/views/admin/UserAdmin.tsx create mode 100644 FrontEnd/src/views/admin/admin.sass create mode 100644 FrontEnd/src/views/center/CenterBoards.tsx create mode 100644 FrontEnd/src/views/center/TimelineBoard.tsx create mode 100644 FrontEnd/src/views/center/TimelineCreateDialog.tsx create mode 100644 FrontEnd/src/views/center/center.sass create mode 100644 FrontEnd/src/views/center/index.tsx create mode 100644 FrontEnd/src/views/common/AppBar.tsx create mode 100644 FrontEnd/src/views/common/BlobImage.tsx create mode 100644 FrontEnd/src/views/common/ConfirmDialog.tsx create mode 100644 FrontEnd/src/views/common/FlatButton.tsx create mode 100644 FrontEnd/src/views/common/FullPage.tsx create mode 100644 FrontEnd/src/views/common/ImageCropper.tsx create mode 100644 FrontEnd/src/views/common/LoadFailReload.tsx create mode 100644 FrontEnd/src/views/common/LoadingButton.tsx create mode 100644 FrontEnd/src/views/common/LoadingPage.tsx create mode 100644 FrontEnd/src/views/common/Menu.tsx create mode 100644 FrontEnd/src/views/common/OperationDialog.tsx create mode 100644 FrontEnd/src/views/common/SearchInput.tsx create mode 100644 FrontEnd/src/views/common/Skeleton.tsx create mode 100644 FrontEnd/src/views/common/TabPages.tsx create mode 100644 FrontEnd/src/views/common/TimelineLogo.tsx create mode 100644 FrontEnd/src/views/common/ToggleIconButton.tsx create mode 100644 FrontEnd/src/views/common/UserTimelineLogo.tsx create mode 100644 FrontEnd/src/views/common/alert/AlertHost.tsx create mode 100644 FrontEnd/src/views/common/alert/alert.sass create mode 100644 FrontEnd/src/views/common/common.sass create mode 100644 FrontEnd/src/views/common/user/UserAvatar.tsx create mode 100644 FrontEnd/src/views/home/TimelineListView.tsx create mode 100644 FrontEnd/src/views/home/WebsiteIntroduction.tsx create mode 100644 FrontEnd/src/views/home/home.sass create mode 100644 FrontEnd/src/views/home/index.tsx create mode 100644 FrontEnd/src/views/login/index.tsx create mode 100644 FrontEnd/src/views/login/login.sass create mode 100644 FrontEnd/src/views/search/index.tsx create mode 100644 FrontEnd/src/views/search/search.sass create mode 100644 FrontEnd/src/views/settings/ChangeAvatarDialog.tsx create mode 100644 FrontEnd/src/views/settings/ChangeNicknameDialog.tsx create mode 100644 FrontEnd/src/views/settings/ChangePasswordDialog.tsx create mode 100644 FrontEnd/src/views/settings/index.tsx create mode 100644 FrontEnd/src/views/settings/settings.sass create mode 100644 FrontEnd/src/views/timeline-common/CollapseButton.tsx create mode 100644 FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx create mode 100644 FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx create mode 100644 FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx create mode 100644 FrontEnd/src/views/timeline-common/Timeline.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelineLine.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelineLoading.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelineMember.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePostListView.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePostView.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx create mode 100644 FrontEnd/src/views/timeline-common/TimelineTop.tsx create mode 100644 FrontEnd/src/views/timeline-common/timeline-common.sass create mode 100644 FrontEnd/src/views/timeline/TimelineCard.tsx create mode 100644 FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx create mode 100644 FrontEnd/src/views/timeline/index.tsx create mode 100644 FrontEnd/src/views/timeline/timeline.sass create mode 100644 FrontEnd/src/views/user/UserCard.tsx create mode 100644 FrontEnd/src/views/user/index.tsx create mode 100644 FrontEnd/src/views/user/user.sass (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx new file mode 100644 index 00000000..a4363ff5 --- /dev/null +++ b/FrontEnd/src/App.tsx @@ -0,0 +1,83 @@ +import React, { ReactElement } from "react"; +import { BrowserRouter as Router, Route, Switch } 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 Settings from "./views/settings"; +import About from "./views/about"; +import User from "./views/user"; +import TimelinePage from "./views/timeline"; +import Search from "./views/search"; +import AlertHost from "./views/common/alert/AlertHost"; + +import { useRawUser } from "./services/user"; + +const NoMatch: React.FC = () => { + return
Ah-oh, 404!
; +}; + +const LazyAdmin = React.lazy( + () => import(/* webpackChunkName: "admin" */ "./views/admin/Admin") +); + +function App(): ReactElement | null { + const user = useRawUser(); + + if (user === undefined) { + return ; + } else { + return ( + }> + + +
+ + + {user == null ? :
} + + + + + {user != null ? ( + +
+ + ) : null} + + + + + + + + + + + + + + + + + + + {user && user.hasAdministrationPermission && ( + + + + )} + + + + + + + + ); + } +} + +export default App; diff --git a/FrontEnd/src/app/App.tsx b/FrontEnd/src/app/App.tsx deleted file mode 100644 index a4363ff5..00000000 --- a/FrontEnd/src/app/App.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { ReactElement } from "react"; -import { BrowserRouter as Router, Route, Switch } 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 Settings from "./views/settings"; -import About from "./views/about"; -import User from "./views/user"; -import TimelinePage from "./views/timeline"; -import Search from "./views/search"; -import AlertHost from "./views/common/alert/AlertHost"; - -import { useRawUser } from "./services/user"; - -const NoMatch: React.FC = () => { - return
Ah-oh, 404!
; -}; - -const LazyAdmin = React.lazy( - () => import(/* webpackChunkName: "admin" */ "./views/admin/Admin") -); - -function App(): ReactElement | null { - const user = useRawUser(); - - if (user === undefined) { - return ; - } else { - return ( - }> - - -
- - - {user == null ? :
} - - - - - {user != null ? ( - -
- - ) : null} - - - - - - - - - - - - - - - - - - - {user && user.hasAdministrationPermission && ( - - - - )} - - - - - - - - ); - } -} - -export default App; diff --git a/FrontEnd/src/app/common.ts b/FrontEnd/src/app/common.ts deleted file mode 100644 index 1a4f6dda..00000000 --- a/FrontEnd/src/app/common.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TFunction } from "i18next"; - -export type BootstrapThemeColor = - | "primary" - | "secondary" - | "success" - | "danger" - | "warning" - | "info"; - -// This error is thrown when ui goes wrong with bad logic. -// Such as a variable should not be null, but it does. -// This error should never occur. If it does, it indicates there is some logic bug in codes. -export class UiLogicError extends Error {} - -export type I18nText = - | string - | { type: "custom"; value: string } - | { type: "i18n"; value: string }; - -export function convertI18nText(text: I18nText, t: TFunction): string; -export function convertI18nText( - text: I18nText | null | undefined, - t: TFunction -): string | null; -export function convertI18nText( - text: I18nText | null | undefined, - t: TFunction -): string | null { - if (text == null) { - return null; - } else if (typeof text === "string") { - return t(text); - } else if (text.type === "i18n") { - return t(text.value); - } else { - return text.value; - } -} diff --git a/FrontEnd/src/app/http/bookmark.ts b/FrontEnd/src/app/http/bookmark.ts deleted file mode 100644 index 3e5be229..00000000 --- a/FrontEnd/src/app/http/bookmark.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { axios, apiBaseUrl, extractResponseData } from "./common"; - -import { HttpTimelineInfo } from "./timeline"; - -export interface HttpHighlightMoveRequest { - timeline: string; - newPosition: number; -} - -export interface IHttpBookmarkClient { - list(): Promise; - put(timeline: string): Promise; - delete(timeline: string): Promise; - move(req: HttpHighlightMoveRequest): Promise; -} - -export class HttpHighlightClient implements IHttpBookmarkClient { - list(): Promise { - return axios - .get(`${apiBaseUrl}/bookmarks`) - .then(extractResponseData); - } - - put(timeline: string): Promise { - return axios.put(`${apiBaseUrl}/bookmarks/${timeline}`).then(); - } - - delete(timeline: string): Promise { - return axios.delete(`${apiBaseUrl}/bookmarks/${timeline}`).then(); - } - - move(req: HttpHighlightMoveRequest): Promise { - return axios.post(`${apiBaseUrl}/bookmarkop/move`, req).then(); - } -} - -let client: IHttpBookmarkClient = new HttpHighlightClient(); - -export function getHttpBookmarkClient(): IHttpBookmarkClient { - return client; -} - -export function setHttpBookmarkClient( - newClient: IHttpBookmarkClient -): IHttpBookmarkClient { - const old = client; - client = newClient; - return old; -} diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts deleted file mode 100644 index e1672985..00000000 --- a/FrontEnd/src/app/http/common.ts +++ /dev/null @@ -1,214 +0,0 @@ -import rawAxios, { AxiosError, AxiosResponse } from "axios"; -import { Base64 } from "js-base64"; -import { BehaviorSubject, Observable } from "rxjs"; - -export const apiBaseUrl = "/api"; - -export const axios = rawAxios.create(); - -function convertToNetworkError(error: AxiosError): never { - if (error.isAxiosError && error.response == null) { - throw new HttpNetworkError(error); - } else { - throw error; - } -} - -function convertToForbiddenError(error: AxiosError): never { - if ( - error.isAxiosError && - error.response != null && - (error.response.status == 401 || error.response.status == 403) - ) { - throw new HttpForbiddenError(error); - } else { - throw error; - } -} - -function convertToNotFoundError(error: AxiosError): never { - if ( - error.isAxiosError && - error.response != null && - error.response.status == 404 - ) { - throw new HttpNotFoundError(error); - } else { - throw error; - } -} - -rawAxios.interceptors.response.use(undefined, convertToNetworkError); -rawAxios.interceptors.response.use(undefined, convertToForbiddenError); -rawAxios.interceptors.response.use(undefined, convertToNotFoundError); -axios.interceptors.response.use(undefined, convertToNetworkError); -axios.interceptors.response.use(undefined, convertToForbiddenError); -axios.interceptors.response.use(undefined, convertToNotFoundError); - -const tokenSubject = new BehaviorSubject(null); - -export function getHttpToken(): string | null { - return tokenSubject.value; -} - -export function setHttpToken(token: string | null): void { - tokenSubject.next(token); - - if (token == null) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - delete axios.defaults.headers.common["Authorization"]; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; - } -} - -export const token$: Observable = tokenSubject.asObservable(); - -export function base64(blob: Blob | string): Promise { - if (typeof blob === "string") { - return Promise.resolve(Base64.encode(blob)); - } - - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = function () { - resolve((reader.result as string).replace(/^data:.*;base64,/, "")); - }; - reader.readAsDataURL(blob); - }); -} - -export function extractStatusCode(error: AxiosError): number | null { - if (error.isAxiosError) { - const code = error?.response?.status; - if (typeof code === "number") { - return code; - } - } - return null; -} - -export interface CommonErrorResponse { - code: number; - message: string; -} - -export function extractErrorCode( - error: AxiosError -): number | null { - if (error.isAxiosError) { - const code = error.response?.data?.code; - if (typeof code === "number") { - return code; - } - } - return null; -} - -export class HttpNetworkError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpForbiddenError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpNotFoundError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class NotModified {} - -export interface BlobWithEtag { - data: Blob; - etag: string; -} - -export function extractResponseData(res: AxiosResponse): T { - return res.data; -} - -export function catchIfStatusCodeIs< - TResult, - TErrorHandlerResult extends TResult | PromiseLike | null | undefined ->( - statusCode: number, - errorHandler: (error: AxiosError) => TErrorHandlerResult -): (error: AxiosError) => TErrorHandlerResult { - return (error: AxiosError) => { - if (extractStatusCode(error) == statusCode) { - return errorHandler(error); - } else { - throw error; - } - }; -} - -export function convertToIfStatusCodeIs( - statusCode: number, - newErrorType: { - new (innerError: AxiosError): NewError; - } -): (error: AxiosError) => never { - return catchIfStatusCodeIs(statusCode, (error) => { - throw new newErrorType(error); - }); -} - -export function catchIfErrorCodeIs< - TResult, - TErrorHandlerResult extends TResult | PromiseLike | null | undefined ->( - errorCode: number, - errorHandler: (error: AxiosError) => TErrorHandlerResult -): (error: AxiosError) => TErrorHandlerResult { - return (error: AxiosError) => { - if (extractErrorCode(error) == errorCode) { - return errorHandler(error); - } else { - throw error; - } - }; -} -export function convertToIfErrorCodeIs( - errorCode: number, - newErrorType: { - new (innerError: AxiosError): NewError; - } -): (error: AxiosError) => never { - return catchIfErrorCodeIs(errorCode, (error) => { - throw new newErrorType(error); - }); -} - -export function convertToNotModified( - error: AxiosError -): NotModified { - if ( - error.isAxiosError && - error.response != null && - error.response.status == 304 - ) { - return new NotModified(); - } else { - throw error; - } -} - -export function convertToBlobWithEtag(res: AxiosResponse): BlobWithEtag { - return { - data: res.data, - etag: (res.headers as Record<"etag", string>)["etag"], - }; -} - -export function extractEtag(res: AxiosResponse): string { - return (res.headers as Record<"etag", string>)["etag"]; -} diff --git a/FrontEnd/src/app/http/highlight.ts b/FrontEnd/src/app/http/highlight.ts deleted file mode 100644 index fddf0729..00000000 --- a/FrontEnd/src/app/http/highlight.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { axios, apiBaseUrl, extractResponseData } from "./common"; - -import { HttpTimelineInfo } from "./timeline"; - -export interface HttpHighlightMoveRequest { - timeline: string; - newPosition: number; -} - -export interface IHttpHighlightClient { - list(): Promise; - put(timeline: string): Promise; - delete(timeline: string): Promise; - move(req: HttpHighlightMoveRequest): Promise; -} - -export class HttpHighlightClient implements IHttpHighlightClient { - list(): Promise { - return axios - .get(`${apiBaseUrl}/highlights`) - .then(extractResponseData); - } - - put(timeline: string): Promise { - return axios.put(`${apiBaseUrl}/highlights/${timeline}`).then(); - } - - delete(timeline: string): Promise { - return axios.delete(`${apiBaseUrl}/highlights/${timeline}`).then(); - } - - move(req: HttpHighlightMoveRequest): Promise { - return axios.post(`${apiBaseUrl}/highlightop/move`, req).then(); - } -} - -let client: IHttpHighlightClient = new HttpHighlightClient(); - -export function getHttpHighlightClient(): IHttpHighlightClient { - return client; -} - -export function setHttpHighlightClient( - newClient: IHttpHighlightClient -): IHttpHighlightClient { - const old = client; - client = newClient; - return old; -} diff --git a/FrontEnd/src/app/http/search.ts b/FrontEnd/src/app/http/search.ts deleted file mode 100644 index 8ca48fe9..00000000 --- a/FrontEnd/src/app/http/search.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { apiBaseUrl, axios, extractResponseData } from "./common"; -import { HttpTimelineInfo } from "./timeline"; -import { HttpUser } from "./user"; - -export interface IHttpSearchClient { - searchTimelines(query: string): Promise; - searchUsers(query: string): Promise; -} - -export class HttpSearchClient implements IHttpSearchClient { - searchTimelines(query: string): Promise { - return axios - .get(`${apiBaseUrl}/search/timelines?q=${query}`) - .then(extractResponseData); - } - - searchUsers(query: string): Promise { - return axios - .get(`${apiBaseUrl}/search/users?q=${query}`) - .then(extractResponseData); - } -} - -let client: IHttpSearchClient = new HttpSearchClient(); - -export function getHttpSearchClient(): IHttpSearchClient { - return client; -} - -export function setHttpSearchClient( - newClient: IHttpSearchClient -): IHttpSearchClient { - const old = client; - client = newClient; - return old; -} diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts deleted file mode 100644 index 9697c1a0..00000000 --- a/FrontEnd/src/app/http/timeline.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { AxiosError } from "axios"; - -import { applyQueryParameters } from "../utilities/url"; - -import { - axios, - apiBaseUrl, - extractResponseData, - convertToIfErrorCodeIs, - getHttpToken, -} from "./common"; -import { HttpUser } from "./user"; - -export const kTimelineVisibilities = ["Public", "Register", "Private"] as const; - -export type TimelineVisibility = typeof kTimelineVisibilities[number]; - -export interface HttpTimelineInfo { - uniqueId: string; - title: string; - name: string; - description: string; - owner: HttpUser; - visibility: TimelineVisibility; - color: string; - lastModified: string; - members: HttpUser[]; - isHighlight: boolean; - isBookmark: boolean; - manageable: boolean; - postable: boolean; -} - -export interface HttpTimelineListQuery { - visibility?: TimelineVisibility; - relate?: string; - relateType?: "own" | "join"; -} - -export interface HttpTimelinePostRequest { - name: string; -} - -export interface HttpTimelinePostDataDigest { - kind: string; - eTag: string; - lastUpdated: string; -} - -export interface HttpTimelinePostInfo { - id: number; - time: string; - author: HttpUser; - dataList: HttpTimelinePostDataDigest[]; - color: string; - lastUpdated: string; - timelineName: string; - editable: boolean; -} - -export interface HttpTimelinePostPostRequestData { - contentType: string; - data: string; -} - -export interface HttpTimelinePostPostRequest { - time?: string; - color?: string; - dataList: HttpTimelinePostPostRequestData[]; -} - -export interface HttpTimelinePatchRequest { - name?: string; - title?: string; - color?: string; - visibility?: TimelineVisibility; - description?: string; -} - -export interface HttpTimelinePostPatchRequest { - time?: string; - color?: string; -} - -export class HttpTimelineNameConflictError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export interface IHttpTimelineClient { - listTimeline(query: HttpTimelineListQuery): Promise; - getTimeline(timelineName: string): Promise; - postTimeline(req: HttpTimelinePostRequest): Promise; - patchTimeline( - timelineName: string, - req: HttpTimelinePatchRequest - ): Promise; - deleteTimeline(timelineName: string): Promise; - memberPut(timelineName: string, username: string): Promise; - memberDelete(timelineName: string, username: string): Promise; - listPost(timelineName: string): Promise; - generatePostDataUrl(timelineName: string, postId: number): string; - getPostDataAsString(timelineName: string, postId: number): Promise; - postPost( - timelineName: string, - req: HttpTimelinePostPostRequest - ): Promise; - patchPost( - timelineName: string, - postId: number, - req: HttpTimelinePostPatchRequest - ): Promise; - deletePost(timelineName: string, postId: number): Promise; -} - -export class HttpTimelineClient implements IHttpTimelineClient { - listTimeline(query: HttpTimelineListQuery): Promise { - return axios - .get( - applyQueryParameters(`${apiBaseUrl}/timelines`, query) - ) - .then(extractResponseData); - } - - getTimeline(timelineName: string): Promise { - return axios - .get(`${apiBaseUrl}/timelines/${timelineName}`) - .then(extractResponseData); - } - - postTimeline(req: HttpTimelinePostRequest): Promise { - return axios - .post(`${apiBaseUrl}/timelines`, req) - .then(extractResponseData) - .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)); - } - - patchTimeline( - timelineName: string, - req: HttpTimelinePatchRequest - ): Promise { - return axios - .patch(`${apiBaseUrl}/timelines/${timelineName}`, req) - .then(extractResponseData); - } - - deleteTimeline(timelineName: string): Promise { - return axios.delete(`${apiBaseUrl}/timelines/${timelineName}`).then(); - } - - memberPut(timelineName: string, username: string): Promise { - return axios - .put(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`) - .then(); - } - - memberDelete(timelineName: string, username: string): Promise { - return axios - .delete(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`) - .then(); - } - - listPost(timelineName: string): Promise { - return axios - .get( - `${apiBaseUrl}/timelines/${timelineName}/posts` - ) - .then(extractResponseData); - } - - generatePostDataUrl(timelineName: string, postId: number): string { - return applyQueryParameters( - `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`, - { token: getHttpToken() } - ); - } - - getPostDataAsString(timelineName: string, postId: number): Promise { - return axios - .get( - `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`, - { - responseType: "text", - } - ) - .then(extractResponseData); - } - - postPost( - timelineName: string, - req: HttpTimelinePostPostRequest - ): Promise { - return axios - .post( - `${apiBaseUrl}/timelines/${timelineName}/posts`, - req - ) - .then(extractResponseData); - } - - patchPost( - timelineName: string, - postId: number, - req: HttpTimelinePostPatchRequest - ): Promise { - return axios - .patch( - `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}`, - req - ) - .then(extractResponseData); - } - - deletePost(timelineName: string, postId: number): Promise { - return axios - .delete(`${apiBaseUrl}/timelines/${timelineName}/posts/${postId}`) - .then(); - } -} - -let client: IHttpTimelineClient = new HttpTimelineClient(); - -export function getHttpTimelineClient(): IHttpTimelineClient { - return client; -} - -export function setHttpTimelineClient( - newClient: IHttpTimelineClient -): IHttpTimelineClient { - const old = client; - client = newClient; - return old; -} diff --git a/FrontEnd/src/app/http/token.ts b/FrontEnd/src/app/http/token.ts deleted file mode 100644 index f8b09d63..00000000 --- a/FrontEnd/src/app/http/token.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Don't use axios in common because it will contains -// authorization header, which shouldn't be used in token apis. -import axios, { AxiosError } from "axios"; - -import { - apiBaseUrl, - convertToIfErrorCodeIs, - extractResponseData, -} from "./common"; -import { HttpUser } from "./user"; - -export interface HttpCreateTokenRequest { - username: string; - password: string; - expire: number; -} - -export interface HttpCreateTokenResponse { - token: string; - user: HttpUser; -} - -export interface HttpVerifyTokenRequest { - token: string; -} - -export interface HttpVerifyTokenResponse { - user: HttpUser; -} - -export class HttpCreateTokenBadCredentialError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export interface IHttpTokenClient { - create(req: HttpCreateTokenRequest): Promise; - verify(req: HttpVerifyTokenRequest): Promise; -} - -export class HttpTokenClient implements IHttpTokenClient { - create(req: HttpCreateTokenRequest): Promise { - return axios - .post(`${apiBaseUrl}/token/create`, req) - .then(extractResponseData) - .catch( - convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError) - ); - } - - verify(req: HttpVerifyTokenRequest): Promise { - return axios - .post(`${apiBaseUrl}/token/verify`, req) - .then(extractResponseData); - } -} - -let client: IHttpTokenClient = new HttpTokenClient(); - -export function getHttpTokenClient(): IHttpTokenClient { - return client; -} - -export function setHttpTokenClient( - newClient: IHttpTokenClient -): IHttpTokenClient { - const old = client; - client = newClient; - return old; -} diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts deleted file mode 100644 index dcf24cba..00000000 --- a/FrontEnd/src/app/http/user.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AxiosError } from "axios"; - -import { - axios, - apiBaseUrl, - extractResponseData, - convertToIfStatusCodeIs, - convertToIfErrorCodeIs, - extractEtag, -} from "./common"; - -export const kUserManagement = "UserManagement"; -export const kAllTimelineManagement = "AllTimelineManagement"; -export const kHighlightTimelineManagement = "HighlightTimelineManagement"; - -export const kUserPermissionList = [ - kUserManagement, - kAllTimelineManagement, - kHighlightTimelineManagement, -] as const; - -export type UserPermission = typeof kUserPermissionList[number]; - -export interface HttpUser { - uniqueId: string; - username: string; - permissions: UserPermission[]; - nickname: string; -} - -export interface HttpUserPatchRequest { - username?: string; - password?: string; - nickname?: string; -} - -export interface HttpChangePasswordRequest { - oldPassword: string; - newPassword: string; -} - -export interface HttpCreateUserRequest { - username: string; - password: string; -} - -export class HttpUserNotExistError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export class HttpChangePasswordBadCredentialError extends Error { - constructor(public innerError?: AxiosError) { - super(); - } -} - -export interface IHttpUserClient { - list(): Promise; - get(username: string): Promise; - post(req: HttpCreateUserRequest): Promise; - patch(username: string, req: HttpUserPatchRequest): Promise; - delete(username: string): Promise; - generateAvatarUrl(username: string): string; - putAvatar(username: string, data: Blob): Promise; - changePassword(req: HttpChangePasswordRequest): Promise; - putUserPermission( - username: string, - permission: UserPermission - ): Promise; - deleteUserPermission( - username: string, - permission: UserPermission - ): Promise; -} - -export class HttpUserClient implements IHttpUserClient { - list(): Promise { - return axios - .get(`${apiBaseUrl}/users`) - .then(extractResponseData); - } - - get(username: string): Promise { - return axios - .get(`${apiBaseUrl}/users/${username}`) - .then(extractResponseData) - .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)); - } - - post(req: HttpCreateUserRequest): Promise { - return axios - .post(`${apiBaseUrl}/users`, req) - .then(extractResponseData) - .then(); - } - - patch(username: string, req: HttpUserPatchRequest): Promise { - return axios - .patch(`${apiBaseUrl}/users/${username}`, req) - .then(extractResponseData); - } - - delete(username: string): Promise { - return axios.delete(`${apiBaseUrl}/users/${username}`).then(); - } - - generateAvatarUrl(username: string): string { - return `${apiBaseUrl}/users/${username}/avatar`; - } - - putAvatar(username: string, data: Blob): Promise { - return axios - .put(`${apiBaseUrl}/users/${username}/avatar`, data, { - headers: { - "Content-Type": data.type, - }, - }) - .then(extractEtag); - } - - changePassword(req: HttpChangePasswordRequest): Promise { - return axios - .post(`${apiBaseUrl}/userop/changepassword`, req) - .catch( - convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError) - ) - .then(); - } - - putUserPermission( - username: string, - permission: UserPermission - ): Promise { - return axios - .put(`${apiBaseUrl}/users/${username}/permissions/${permission}`) - .then(); - } - - deleteUserPermission( - username: string, - permission: UserPermission - ): Promise { - return axios - .delete(`${apiBaseUrl}/users/${username}/permissions/${permission}`) - .then(); - } -} - -let client: IHttpUserClient = new HttpUserClient(); - -export function getHttpUserClient(): IHttpUserClient { - return client; -} - -export function setHttpUserClient(newClient: IHttpUserClient): IHttpUserClient { - const old = client; - client = newClient; - return old; -} diff --git a/FrontEnd/src/app/i18n.ts b/FrontEnd/src/app/i18n.ts deleted file mode 100644 index 5b8e9d41..00000000 --- a/FrontEnd/src/app/i18n.ts +++ /dev/null @@ -1,88 +0,0 @@ -import i18n, { BackendModule, ResourceKey } from "i18next"; -import LanguageDetector from "i18next-browser-languagedetector"; -import { initReactI18next } from "react-i18next"; - -const backend: BackendModule = { - type: "backend", - async read(language, namespace, callback) { - function error(message: string): void { - callback(new Error(message), false); - } - - function success(result: ResourceKey): void { - callback(null, result); - } - - const promise = (() => { - if (namespace === "translation") { - if (language === "en") { - return import("./locales/en/translation.json"); - } else if (language === "zh") { - return import("./locales/zh/translation.json"); - } else { - error(`Language ${language} is not supported.`); - } - } else if (namespace === "admin") { - if (language === "en") { - return import("./locales/en/admin.json"); - } else if (language === "zh") { - return import("./locales/zh/admin.json"); - } else { - error(`Language ${language} is not supported.`); - } - } else { - error(`Namespace ${namespace} is not supported.`); - } - })(); - - if (promise) { - success((await promise).default); - } - }, - init() {}, // eslint-disable-line @typescript-eslint/no-empty-function - create() {}, // eslint-disable-line @typescript-eslint/no-empty-function -}; - -export const i18nPromise = i18n - .use(LanguageDetector) - .use(backend) - .use(initReactI18next) // bind react-i18next to the instance - .init({ - fallbackLng: false, - lowerCaseLng: true, - - debug: process.env.NODE_ENV === "development", - - interpolation: { - escapeValue: false, // not needed for react!! - }, - - // react i18next special options (optional) - // override if needed - omit if ok with defaults - /* - react: { - bindI18n: 'languageChanged', - bindI18nStore: '', - transEmptyNodeValue: '', - transSupportBasicHtmlNodes: true, - transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'], - useSuspense: true, - } - */ - }); - -if (module.hot) { - module.hot.accept( - [ - "./locales/en/translation.json", - "./locales/zh/translation.json", - "./locales/en/admin.json", - "./locales/zh/admin.json", - ], - () => { - void i18n.reloadResources(); - } - ); -} - -export default i18n; diff --git a/FrontEnd/src/app/index.ejs b/FrontEnd/src/app/index.ejs deleted file mode 100644 index c2ff4182..00000000 --- a/FrontEnd/src/app/index.ejs +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - <%= htmlWebpackPlugin.options.title %> - - - -
- - - diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass deleted file mode 100644 index 4cee155f..00000000 --- a/FrontEnd/src/app/index.sass +++ /dev/null @@ -1,120 +0,0 @@ -@import 'bootstrap/scss/bootstrap' -@import 'bootstrap-icons/font/bootstrap-icons.css' - -@import './views/common/common' -@import './views/common/alert/alert' -@import './views/center/center' -@import './views/home/home' -@import './views/about/about' -@import './views/login/login' -@import './views/settings/settings' -@import './views/timeline-common/timeline-common' -@import './views/timeline/timeline' -@import './views/user/user' -@import './views/search/search' - -@import './views/admin/admin' - -.tl-color-primary - color: var(--tl-primary-color) - -.tl-color-danger - color: var(--tl-danger-color) - -small - line-height: 1.2 - -.flex-fix-length - flex-grow: 0 - flex-shrink: 0 - -.position-lt - left: 0 - top: 0 - -.avatar - width: 60px - height: 60px - &.large - width: 100px - height: 100px - &.small - width: 40px - height: 40px - -.icon-button - font-size: 1.4rem - cursor: pointer - &.large - font-size: 1.6rem - -.flat-button - cursor: pointer - padding: 0.2em 0.5em - border-radius: 0.2em - &:hover:not(.disabled) - background-color: $gray-200 - &.disabled - cursor: default - @each $color, $value in $theme-colors - &.#{$color} - color: $value - &.disabled - color: adjust-color($value, $lightness: +15%) - -.cursor-pointer - cursor: pointer - -textarea - resize: none - -.white-space-no-wrap - white-space: nowrap - -.cru-card - @extend .shadow - @extend .rounded - border: 1px solid - border-color: $gray-200 - background: $gray-100 - transition: all 0.3s - &:hover - border-color: var(--tl-primary-color) - -.full-viewport-center-child - position: fixed - width: 100vw - height: 100vh - display: flex - justify-content: center - align-items: center - -.text-orange - color: $orange - -.text-yellow - color: $yellow - -.text-button - background: transparent - border: none - @each $color, $value in $theme-colors - &.#{$color} - color: $value - &:hover - color: adjust-color($value, $lightness: +15%) - -.touch-action-none - touch-action: none - -i - line-height: 1 - -.markdown-container - white-space: initial - img - max-height: 200px - max-width: 100% - -a - text-decoration: none diff --git a/FrontEnd/src/app/index.tsx b/FrontEnd/src/app/index.tsx deleted file mode 100644 index fb0c8899..00000000 --- a/FrontEnd/src/app/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import "regenerator-runtime"; -import "core-js/modules/es.promise"; -import "core-js/modules/es.array.iterator"; -import "pepjs"; - -import React from "react"; -import ReactDOM from "react-dom"; - -import "./index.sass"; - -import "./i18n"; - -import App from "./App"; - -import "./palette"; - -import { userService } from "./services/user"; - -void userService.checkLoginState(); - -ReactDOM.render(, document.getElementById("app")); diff --git a/FrontEnd/src/app/locales/en/admin.json b/FrontEnd/src/app/locales/en/admin.json deleted file mode 100644 index ddb3ffad..00000000 --- a/FrontEnd/src/app/locales/en/admin.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "nav": { - "users": "Users", - "more": "More" - }, - "create": "Create", - "user": { - "username": "Username: ", - "password": "Password: ", - "nickname": "Nickname: ", - "uniqueId": "Unique ID: ", - "permissions": "Permissions: ", - "modify": "Modify", - "modifyPermissions": "Modify Permissions", - "delete": "Delete", - "dialog": { - "create": { - "title": "Create User", - "prompt": "You are creating a new user." - }, - "delete": { - "title": "Delete user", - "prompt": "You are deleting <1>username . Caution: This can't be undo." - }, - "modify": { - "title": "Modify User", - "prompt": "You are modifying user <1>username ." - }, - "modifyPermissions": { - "title": "Modify User Permissions", - "prompt": "You are modifying permissions of user <1>username ." - } - } - } -} diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json deleted file mode 100644 index a2766b4e..00000000 --- a/FrontEnd/src/app/locales/en/translation.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "welcome": "Welcome!", - "search": "Search", - "edit": "Edit", - "image": "Image", - "done": "Done", - "preview": "Preview", - "delete": "Delete", - "changeProperty": "Change Property", - "loadFailReload": "Load failed, <1>click here to reload.", - "error": { - "network": "Network error.", - "unknown": "Unknown error." - }, - "connectionState": { - "Connected": "Connected", - "Connecting": "Connecting", - "Disconnected": "Disconnected", - "Disconnecting": "Disconnecting", - "Reconnecting": "Reconnecting" - }, - "serviceWorker": { - "availableOffline": "Timeline is now cached in your computer and you can use it offline. 🎉🎉🎉", - "upgradePrompt": "App is getting a new version!", - "upgradeNow": "Update Now", - "upgradeSuccess": "Congratulations! App update succeeded! Still you can use it offline. 🎉🎉🎉", - "externalActivatedPrompt": "A new version of app is activated. Please refresh the page. Or it may be broken.", - "reloadNow": "Refresh Now" - }, - "nav": { - "settings": "Settings", - "login": "Login", - "about": "About", - "administration": "Administration" - }, - "chooseImage": "Choose a image", - "loadImageError": "Failed to load image.", - "home": { - "loadingHighlightTimelines": "Loading highlight timelines...", - "loadedHighlightTimelines": "Here are some highlight timelines💡", - "errorHighlightTimelines": "Failed to load highlight timelines, please try reloading!", - "bookmarkTimeline": "Bookmark Timelines", - "highlightTimeline": "Highlight Timelines", - "relatedTimeline": "Timelines You Participate", - "message": { - "moveHighlightFail": "Failed to move highlight timeline.", - "deleteHighlightFail": "Failed to delete highlight timeline.", - "moveBookmarkFail": "Failed to move bookmark timeline.", - "deleteBookmarkFail": "Failed to delete bookmark timeline." - }, - "createButton": "Create Timeline", - "createDialog": { - "title": "Create Timeline!", - "name": "Name", - "nameFormat": "Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.", - "badFormat": "Bad format.", - "noEmpty": "Empty is not allowed.", - "tooLong": "Too long." - } - }, - "operationDialog": { - "retry": "Retry", - "nextStep": "Next", - "previousStep": "Previous", - "confirm": "Confirm", - "cancel": "Cancel", - "ok": "OK!", - "processing": "Processing...", - "success": "Success!", - "error": "An error occured." - }, - "timeline": { - "messageCantSee": "Sorry, you are not allowed to see this timeline.😅", - "userNotExist": "The user does not exist!", - "timelineNotExist": "The timeline does not exist!", - "manage": "Manage", - "memberButton": "Member", - "send": "Send", - "deletePostFailed": "Failed to delete post.", - "sendPostFailed": "Failed to send post.", - "dropDraft": "Drop Draft", - "confirmLeave": "Are you sure to leave? All content you typed would be lost.", - "visibility": { - "public": "public to everyone", - "register": "only registed people can see", - "private": "only members can see" - }, - "visibilityTooltip": { - "public": "Everyone including those without accounts can see content of the timeline.", - "register": "Only those who have an account and logined can see content of the timeline.", - "private": "Only members of this timeline can see content of the timeline." - }, - "dialogChangeProperty": { - "title": "Change Timeline Properties", - "titleField": "Title", - "visibility": "Visibility", - "description": "Description", - "color": "Color" - }, - "changePostPropertyDialog": { - "title": "Change Post Properties", - "time": "Date and time", - "timeEmpty": "You must select a time." - }, - "member": { - "noUserAvailableToAdd": "Sorry, no user available to be a member in search result.", - "add": "Add", - "remove": "Remove" - }, - "manageItem": { - "nickname": "Nickname", - "avatar": "Avatar", - "property": "Timeline Property", - "member": "Timeline Member", - "delete": "Delete Timeline" - }, - "deleteDialog": { - "title": "Delete Timeline", - "inputPrompt": "This is a dangerous action. If you are sure to delete timeline<1>{{name}}, please input its name below and click confirm button.", - "notMatch": "Name does not match." - }, - "post": { - "type": { - "text": "Plain Text", - "markdown": "Markdown", - "image": "Image" - }, - "deleteDialog": { - "title": "Confirm Delete", - "prompt": "Are you sure to delete the post? This operation is not recoverable." - } - }, - "addHighlightFail": "Failed to add highlight.", - "removeHighlightFail": "Failed to remove highlight.", - "addBookmarkFail": "Failed to add bookmark.", - "removeBookmarkFail": "Failed to remove bookmark." - }, - "searchPage": { - "loading": "Loading search result...", - "input": "Input something and search!", - "noResult": "Sorry, there is no satisfied results." - }, - "user": { - "username": "username", - "password": "password", - "login": "login", - "rememberMe": "Remember Me", - "welcomeBack": "Welcome back!", - "verifyTokenFailed": "User login info is expired. Please login again!", - "verifyTokenFailedNetwork": "Verifying user login info failed. Please check your network and refresh page!" - }, - "login": { - "emptyUsername": "Username can't be empty.", - "emptyPassword": "Password can't be empty.", - "badCredential": "Username or password is invalid.", - "alreadyLogin": "Already login! Redirect to home page in 3s!" - }, - "settings": { - "subheaders": { - "account": "Account", - "customization": "Customization" - }, - "languagePrimary": "Choose display language.", - "languageSecondary": "You language preference will be saved locally. Next time you visit this page, last language option will be used.", - "changePassword": "Change account's password.", - "logout": "Log out this account.", - "changeAvatar": "Change avatar.", - "changeNickname": "Change nickname.", - "dialogChangePassword": { - "title": "Change Password", - "prompt": "You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.", - "inputOldPassword": "Old password", - "inputNewPassword": "New password", - "inputRetypeNewPassword": "Retype new password", - "errorEmptyOldPassword": "Old password can't be empty.", - "errorEmptyNewPassword": "New password can't be empty.", - "errorRetypeNotMatch": "Password retyped does not match." - }, - "dialogConfirmLogout": { - "title": "Confirm Logout", - "prompt": "Are you sure to log out? All cached data in the browser will be deleted." - }, - "dialogChangeNickname": { - "title": "Change Nickname", - "inputLabel": "New nickname" - }, - "dialogChangeAvatar": { - "title": "Change Avatar", - "previewImgAlt": "preview", - "prompt": { - "select": "Please select a picture.", - "crop": "Please crop the picture.", - "processingCrop": "Cropping picture...", - "uploading": "Uploading...", - "preview": "Please preview avatar" - }, - "upload": "upload" - } - }, - "about": { - "author": { - "title": "Site Developer", - "fullname": "Fullname: ", - "nickname": "Nickname: ", - "introduction": "Introduction: ", - "introductionContent": "A programmer coding based on coincidence", - "links": "Links: " - }, - "site": { - "title": "Site Information", - "content": "The name of this site is <1>Timeline, which is a Web App with <3>timeline as its core concept. Its frontend and backend are both developed by <5>me, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.", - "repo": "GitHub Repo" - }, - "credits": { - "title": "Credits", - "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", - "frontend": "Frontend: ", - "backend": "Backend: " - } - }, - "admin": { - "title": "admin" - } -} diff --git a/FrontEnd/src/app/locales/zh/admin.json b/FrontEnd/src/app/locales/zh/admin.json deleted file mode 100644 index edd1cabd..00000000 --- a/FrontEnd/src/app/locales/zh/admin.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "nav": { - "users": "用户", - "more": "更多" - }, - "create": "创建", - "user": { - "username": "用户名:", - "password": "密码:", - "nickname": "昵称:", - "uniqueId": "唯一ID:", - "permissions": "权限:", - "modify": "修改", - "modifyPermissions": "修改权限", - "delete": "删除", - "dialog": { - "create": { - "title": "创建用户", - "prompt": "您正在创建一个新用户。" - }, - "delete": { - "title": "删除用户", - "prompt": "您正在删除用户 <1>username 。注意:此操作不可撤销。" - }, - "modify": { - "title": "修改用户", - "prompt": "您正在修改用户 <1>username 。" - }, - "modifyPermissions": { - "title": "修改用户权限", - "prompt": "您正在修改用户 <1>username 的权限。" - } - } - } -} diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json deleted file mode 100644 index 5a5a6843..00000000 --- a/FrontEnd/src/app/locales/zh/translation.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "welcome": "欢迎!", - "search": "搜索", - "edit": "编辑", - "image": "图片", - "done": "完成", - "preview": "预览", - "loadFailReload": "加载失败,<1>点击重试。", - "delete": "删除", - "changeProperty": "修改属性", - "error": { - "network": "网络错误。", - "unknown": "未知错误。" - }, - "connectionState": { - "Connected": "已连接", - "Connecting": "正在连接", - "Disconnected": "已断开连接", - "Disconnecting": "正在断开连接", - "Reconnecting": "正在重新连接" - }, - "serviceWorker": { - "availableOffline": "Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉", - "upgradePrompt": "App 有新版本!", - "upgradeNow": "现在升级", - "upgradeSuccess": "App 升级成功,当然,你仍可以离线使用它。 🎉🎉🎉", - "externalActivatedPrompt": "一个新的 App 版本已经激活,请刷新页面使用,否则页面可能会出现故障。", - "reloadNow": "立刻刷新" - }, - "nav": { - "settings": "设置", - "login": "登陆", - "about": "关于", - "administration": "管理" - }, - "chooseImage": "选择一个图片", - "loadImageError": "加载图片失败", - "home": { - "loadingHighlightTimelines": "正在加载高光时间线...", - "loadedHighlightTimelines": "康康以下这些高光时间线💡", - "errorHighlightTimelines": "加载高光时间线失败,刷新试试!", - "bookmarkTimeline": "书签时间线", - "highlightTimeline": "高光时间线", - "relatedTimeline": "参与的时间线", - "message": { - "moveHighlightFail": "移动高光时间线失败。", - "deleteHighlightFail": "删除高光时间线失败。", - "moveBookmarkFail": "移动书签时间线失败。", - "deleteBookmarkFail": "删除书签时间线失败。" - }, - "createButton": "创建时间线", - "createDialog": { - "title": "创建时间线!", - "name": "名字", - "nameFormat": "名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.", - "badFormat": "格式错误", - "noEmpty": "不能为空", - "tooLong": "太长了" - } - }, - "operationDialog": { - "retry": "重试", - "nextStep": "下一步", - "previousStep": "上一步", - "confirm": "确定", - "cancel": "取消", - "ok": "好的!", - "processing": "处理中...", - "success": "成功!", - "error": "出错啦!" - }, - "timeline": { - "messageCantSee": "不好意思,你没有权限查看这个时间线。😅", - "userNotExist": "该用户不存在!", - "timelineNotExist": "该时间线不存在!", - "manage": "管理", - "memberButton": "成员", - "send": "发送", - "deletePostFailed": "删除消息失败。", - "sendPostFailed": "发送消息失败。", - "dropDraft": "放弃草稿", - "confirmLeave": "确定要离开吗?所有输入的内容将会丢失。", - "visibility": { - "public": "对所有人公开", - "register": "仅注册可见", - "private": "仅成员可见" - }, - "visibilityTooltip": { - "public": "所有人都可以看到这个时间线的内容,包括没有注册的人。", - "register": "只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。", - "private": "只有这个时间线的成员可以看到这个时间线的内容。" - }, - "dialogChangeProperty": { - "title": "修改时间线属性", - "titleField": "标题", - "visibility": "可见性", - "description": "描述", - "color": "颜色" - }, - "changePostPropertyDialog": { - "title": "修改消息属性", - "time": "时间", - "timeEmpty": "你必须选择一个时间。" - }, - "member": { - "noUserAvailableToAdd": "搜索结果显示没有可以添加为成员的用户。", - "add": "添加", - "remove": "移除" - }, - "manageItem": { - "nickname": "昵称", - "avatar": "头像", - "property": "时间线属性", - "member": "时间线成员", - "delete": "删除时间线" - }, - "deleteDialog": { - "title": "删除时间线", - "inputPrompt": "这是一个危险的操作。如果您确认要删除时间线<1>{{name}},请在下面输入它的名字并点击确认。", - "notMatch": "名字不匹配" - }, - "post": { - "type": { - "text": "纯文本", - "markdown": "Markdown", - "image": "图片" - }, - "deleteDialog": { - "title": "确认删除", - "prompt": "确定删除这个消息?这个操作不可撤销。" - } - }, - "addHighlightFail": "添加高光失败。", - "removeHighlightFail": "删除高光失败。", - "addBookmarkFail": "添加书签失败。", - "removeBookmarkFail": "删除书签失败。" - }, - "searchPage": { - "loading": "加载搜索结果中...", - "input": "输入一些东西来搜索!", - "noResult": "对不起,没有符合条件的结果。" - }, - "user": { - "username": "用户名", - "password": "密码", - "login": "登录", - "rememberMe": "记住我", - "welcomeBack": "欢迎回来!", - "verifyTokenFailed": "用户登录信息已过期,请重新登陆!", - "verifyTokenFailedNetwork": "验证用户登录信息失败,请检查网络连接并刷新页面!" - }, - "login": { - "emptyUsername": "用户名不能为空。", - "emptyPassword": "密码不能为空。", - "badCredential": "用户名或密码错误。", - "alreadyLogin": "已经登陆,三秒后导航到首页!" - }, - "settings": { - "subheaders": { - "account": "账户", - "customization": "个性化" - }, - "languagePrimary": "选择显示的语言。", - "languageSecondary": "您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。", - "changePassword": "更改账号的密码。", - "logout": "注销此账号。", - "changeAvatar": "更改头像。", - "changeNickname": "更改昵称。", - "dialogChangePassword": { - "title": "修改密码", - "prompt": "您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。", - "inputOldPassword": "旧密码", - "inputNewPassword": "新密码", - "inputRetypeNewPassword": "再次输入新密码", - "errorEmptyOldPassword": "旧密码不能为空。", - "errorEmptyNewPassword": "新密码不能为空", - "errorRetypeNotMatch": "两次输入的密码不一致" - }, - "dialogConfirmLogout": { - "title": "确定注销", - "prompt": "您确定注销此账号?这将删除所有已经缓存在浏览器的数据。" - }, - "dialogChangeNickname": { - "title": "更改昵称", - "inputLabel": "新昵称" - }, - "dialogChangeAvatar": { - "title": "修改头像", - "previewImgAlt": "预览", - "prompt": { - "select": "请选择一个图片", - "crop": "请裁剪图片", - "processingCrop": "正在裁剪图片", - "uploading": "正在上传", - "preview": "请预览图片" - }, - "upload": "上传" - } - }, - "about": { - "author": { - "title": "网站作者", - "fullname": "姓名:", - "nickname": "昵称:", - "introduction": "简介:", - "introductionContent": "一个基于巧合编程的代码爱好者。", - "links": "链接:" - }, - "site": { - "title": "网站信息", - "content": "这个网站的名字叫 <1>Timeline,是一个以<3>时间线为核心概念的 Web App . 它的前端和后端都是由<5>我开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。", - "repo": "GitHub 仓库" - }, - "credits": { - "title": "鸣谢", - "content": "Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。", - "frontend": "前端:", - "backend": "后端:" - } - }, - "admin": { - "title": "管理" - } -} diff --git a/FrontEnd/src/app/palette.ts b/FrontEnd/src/app/palette.ts deleted file mode 100644 index c4f4f4f9..00000000 --- a/FrontEnd/src/app/palette.ts +++ /dev/null @@ -1,116 +0,0 @@ -import Color from "color"; -import { BehaviorSubject, Observable } from "rxjs"; - -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; - inactive: string; - lighter: string; - darker: string; - [key: string]: string; -} - -export interface Palette { - primary: PaletteColor; - primaryEnhance: PaletteColor; - secondary: PaletteColor; - textPrimary: PaletteColor; - textOnPrimary: PaletteColor; - danger: PaletteColor; - success: PaletteColor; - [key: string]: PaletteColor; -} - -export function generatePaletteColor(color: string): PaletteColor { - const c = Color(color); - return { - color: c.toString(), - inactive: (c.lightness() > 60 - ? darkenBy(c, 0.1) - : lightenBy(c, 0.2) - ).toString(), - lighter: lightenBy(c, 0.1).fade(0.1).toString(), - darker: darkenBy(c, 0.1).toString(), - }; -} - -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 ? p.rotate(90) : Color(secondary); - - return { - primary: generatePaletteColor(p.toString()), - primaryEnhance: generatePaletteColor(pe.toString()), - secondary: generatePaletteColor(s.toString()), - textPrimary: generatePaletteColor("#111111"), - textOnPrimary: generatePaletteColor(p.lightness() > 60 ? "black" : "white"), - danger: generatePaletteColor("red"), - success: generatePaletteColor("green"), - }; -} - -export function generatePaletteCSS(palette: Palette): string { - function toSnakeCase(s: string): string { - return s.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); - } - - const colors: [string, string][] = []; - for (const paletteColorName in palette) { - const paletteColor = palette[paletteColorName]; - for (const variant in paletteColor) { - let key = `--tl-${toSnakeCase(paletteColorName)}`; - if (variant !== "color") key += `-${toSnakeCase(variant)}`; - key += "-color"; - colors.push([key, paletteColor[variant]]); - } - } - - return `:root {${colors - .map(([key, color]) => `${key} : ${color};`) - .join("")}}`; -} - -const paletteSubject: BehaviorSubject = new BehaviorSubject( - generatePalette({ primary: "#007bff" }) -); - -export const palette$: Observable = paletteSubject.asObservable(); - -palette$.subscribe((palette) => { - const styleTagId = "timeline-palette-css"; - let styleTag = document.getElementById(styleTagId); - if (styleTag == null) { - styleTag = document.createElement("style"); - styleTag.id = styleTagId; - document.head.append(styleTag); - } - styleTag.innerHTML = generatePaletteCSS(palette); -}); - -export function setPalette(palette: Palette): () => void { - const old = paletteSubject.value; - - paletteSubject.next(palette); - - return () => { - paletteSubject.next(old); - }; -} diff --git a/FrontEnd/src/app/service-worker.tsx b/FrontEnd/src/app/service-worker.tsx deleted file mode 100644 index ea8dfc32..00000000 --- a/FrontEnd/src/app/service-worker.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "react-bootstrap"; - -import { pushAlert } from "./services/alert"; - -if ("serviceWorker" in navigator) { - let isThisTriggerUpgrade = false; - - const upgradeSuccessLocalStorageKey = "TIMELINE_UPGRADE_SUCCESS"; - - if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) { - pushAlert({ - message: "serviceWorker.upgradeSuccess", - type: "success", - }); - window.localStorage.removeItem(upgradeSuccessLocalStorageKey); - } - - void import("workbox-window").then(({ Workbox, messageSW }) => { - const wb = new Workbox("/sw.js"); - let registration: ServiceWorkerRegistration | undefined; - - // externalactivated is not usable but I still use its name. - wb.addEventListener("controlling", () => { - const upgradeReload = (): void => { - window.localStorage.setItem(upgradeSuccessLocalStorageKey, "true"); - window.location.reload(); - }; - - if (isThisTriggerUpgrade) { - upgradeReload(); - } else { - const Message: React.FC = () => { - const { t } = useTranslation(); - return ( - <> - {t("serviceWorker.externalActivatedPrompt")} - - - ); - }; - - pushAlert({ - message: Message, - dismissTime: "never", - type: "warning", - }); - } - }); - - wb.addEventListener("activated", (event) => { - if (!event.isUpdate) { - pushAlert({ - message: "serviceWorker.availableOffline", - type: "success", - }); - } - }); - - // Add an event listener to detect when the registered - // service worker has installed but is waiting to activate. - wb.addEventListener("waiting", (): void => { - const upgrade = (): void => { - isThisTriggerUpgrade = true; - if (registration && registration.waiting) { - // Send a message to the waiting service worker, - // instructing it to activate. - // Note: for this to work, you have to add a message - // listener in your service worker. See below. - void messageSW(registration.waiting, { type: "SKIP_WAITING" }); - } - }; - - const UpgradeMessage: React.FC = () => { - const { t } = useTranslation(); - return ( - <> - {t("serviceWorker.upgradePrompt")} - - - ); - }; - - pushAlert({ - message: UpgradeMessage, - dismissTime: "never", - type: "success", - }); - }); - - void wb.register().then((reg) => { - registration = reg; - }); - }); -} diff --git a/FrontEnd/src/app/services/TimelinePostBuilder.ts b/FrontEnd/src/app/services/TimelinePostBuilder.ts deleted file mode 100644 index 40279eca..00000000 --- a/FrontEnd/src/app/services/TimelinePostBuilder.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Remarkable } from "remarkable"; - -import { UiLogicError } from "@/common"; - -import { base64 } from "@/http/common"; -import { HttpTimelinePostPostRequest } from "@/http/timeline"; - -export default class TimelinePostBuilder { - private _onChange: () => void; - private _text = ""; - private _images: { file: File; url: string }[] = []; - private _md: Remarkable = new Remarkable(); - - constructor(onChange: () => void) { - this._onChange = onChange; - const oldImageRenderer = this._md.renderer.rules.image; - this._md.renderer.rules.image = (( - _t: TimelinePostBuilder - ): Remarkable.Rule => - function (tokens, idx, options /*, env */) { - const i = parseInt(tokens[idx].src); - if (!isNaN(i) && i > 0 && i <= _t._images.length) { - tokens[idx].src = _t._images[i - 1].url; - } - return oldImageRenderer(tokens, idx, options); - })(this); - } - - 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 this._md.render(this._text); - } - - dispose(): void { - for (const image of this._images) { - URL.revokeObjectURL(image.url); - } - this._images = []; - } - - async build(): Promise { - 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/app/services/alert.ts b/FrontEnd/src/app/services/alert.ts deleted file mode 100644 index 48d482ea..00000000 --- a/FrontEnd/src/app/services/alert.ts +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; -import pull from "lodash/pull"; - -import { BootstrapThemeColor, I18nText } from "@/common"; - -export interface AlertInfo { - type?: BootstrapThemeColor; - message: React.FC | I18nText; - dismissTime?: number | "never"; -} - -export interface AlertInfoEx extends AlertInfo { - id: number; -} - -export type AlertConsumer = (alerts: AlertInfoEx) => void; - -export class AlertService { - private consumers: AlertConsumer[] = []; - private savedAlerts: AlertInfoEx[] = []; - private currentId = 1; - - private produce(alert: AlertInfoEx): void { - for (const consumer of this.consumers) { - consumer(alert); - } - } - - registerConsumer(consumer: AlertConsumer): void { - this.consumers.push(consumer); - if (this.savedAlerts.length !== 0) { - for (const alert of this.savedAlerts) { - this.produce(alert); - } - this.savedAlerts = []; - } - } - - unregisterConsumer(consumer: AlertConsumer): void { - pull(this.consumers, consumer); - } - - push(alert: AlertInfo): void { - const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ }; - if (this.consumers.length === 0) { - this.savedAlerts.push(newAlert); - } else { - this.produce(newAlert); - } - } -} - -export const alertService = new AlertService(); - -export function pushAlert(alert: AlertInfo): void { - alertService.push(alert); -} - -export const kAlertHostId = "alert-host"; - -export function getAlertHost(): HTMLElement | null { - return document.getElementById(kAlertHostId); -} diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts deleted file mode 100644 index d8c0ae00..00000000 --- a/FrontEnd/src/app/services/timeline.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { TimelineVisibility } from "@/http/timeline"; -import XRegExp from "xregexp"; -import { Observable } from "rxjs"; -import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; - -import { getHttpToken } from "@/http/common"; - -const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); - -export function validateTimelineName(name: string): boolean { - return timelineNameReg.test(name); -} - -export const timelineVisibilityTooltipTranslationMap: Record< - TimelineVisibility, - string -> = { - Public: "timeline.visibilityTooltip.public", - Register: "timeline.visibilityTooltip.register", - Private: "timeline.visibilityTooltip.private", -}; - -export function getTimelinePostUpdate$( - timelineName: string -): Observable<{ update: boolean; state: HubConnectionState }> { - return new Observable((subscriber) => { - subscriber.next({ - update: false, - state: HubConnectionState.Connecting, - }); - - const token = getHttpToken(); - const connection = new HubConnectionBuilder() - .withUrl("/api/hub/timeline", { - accessTokenFactory: token == null ? undefined : () => token, - }) - .withAutomaticReconnect() - .build(); - - const handler = (tn: string): void => { - if (timelineName === tn) { - subscriber.next({ update: true, state: connection.state }); - } - }; - - connection.onclose(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Disconnected, - }); - }); - - connection.onreconnecting(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Reconnecting, - }); - }); - - connection.onreconnected(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Connected, - }); - }); - - connection.on("OnTimelinePostChanged", handler); - - void connection.start().then(() => { - subscriber.next({ update: false, state: HubConnectionState.Connected }); - - return connection.invoke("SubscribeTimelinePostChange", timelineName); - }); - - return () => { - connection.off("OnTimelinePostChanged", handler); - - if (connection.state === HubConnectionState.Connected) { - void connection - .invoke("UnsubscribeTimelinePostChange", timelineName) - .then(() => connection.stop()); - } - }; - }); -} diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts deleted file mode 100644 index 9a8e5687..00000000 --- a/FrontEnd/src/app/services/user.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { useState, useEffect } from "react"; -import { BehaviorSubject, Observable } from "rxjs"; - -import { UiLogicError } from "@/common"; - -import { HttpNetworkError, setHttpToken } from "@/http/common"; -import { - getHttpTokenClient, - HttpCreateTokenBadCredentialError, -} from "@/http/token"; -import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user"; - -import { pushAlert } from "./alert"; - -interface IAuthUser extends HttpUser { - token: string; -} - -export class AuthUser implements IAuthUser { - constructor(user: HttpUser, public token: string) { - this.uniqueId = user.uniqueId; - this.username = user.username; - this.permissions = user.permissions; - this.nickname = user.nickname; - } - - uniqueId: string; - username: string; - permissions: UserPermission[]; - nickname: string; - - get hasAdministrationPermission(): boolean { - return this.permissions.length !== 0; - } - - get hasAllTimelineAdministrationPermission(): boolean { - return this.permissions.includes("AllTimelineManagement"); - } - - get hasHighlightTimelineAdministrationPermission(): boolean { - return this.permissions.includes("HighlightTimelineManagement"); - } -} - -export interface LoginCredentials { - username: string; - password: string; -} - -export class BadCredentialError { - message = "login.badCredential"; -} - -const USER_STORAGE_KEY = "currentuser"; - -export class UserService { - constructor() { - this.userSubject.subscribe((u) => { - setHttpToken(u?.token ?? null); - }); - } - - private userSubject = new BehaviorSubject( - undefined - ); - - get user$(): Observable { - return this.userSubject; - } - - get currentUser(): AuthUser | null | undefined { - return this.userSubject.value; - } - - async checkLoginState(): Promise { - if (this.currentUser !== undefined) { - console.warn("Already checked user. Can't check twice."); - } - - const savedUserString = localStorage.getItem(USER_STORAGE_KEY); - - const savedAuthUserData = - savedUserString == null - ? null - : (JSON.parse(savedUserString) as IAuthUser); - - const savedUser = - savedAuthUserData == null - ? null - : new AuthUser(savedAuthUserData, savedAuthUserData.token); - - if (savedUser == null) { - this.userSubject.next(null); - return null; - } - - this.userSubject.next(savedUser); - - const savedToken = savedUser.token; - try { - const res = await getHttpTokenClient().verify({ token: savedToken }); - const user = new AuthUser(res.user, savedToken); - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); - this.userSubject.next(user); - pushAlert({ - type: "success", - message: "user.welcomeBack", - }); - return user; - } catch (error) { - if (error instanceof HttpNetworkError) { - pushAlert({ - type: "danger", - message: "user.verifyTokenFailedNetwork", - }); - return savedUser; - } else { - localStorage.removeItem(USER_STORAGE_KEY); - this.userSubject.next(null); - pushAlert({ - type: "danger", - message: "user.verifyTokenFailed", - }); - return null; - } - } - } - - async login( - credentials: LoginCredentials, - rememberMe: boolean - ): Promise { - if (this.currentUser) { - throw new UiLogicError("Already login."); - } - try { - const res = await getHttpTokenClient().create({ - ...credentials, - expire: 30, - }); - const user = new AuthUser(res.user, res.token); - if (rememberMe) { - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); - } - this.userSubject.next(user); - } catch (e) { - if (e instanceof HttpCreateTokenBadCredentialError) { - throw new BadCredentialError(); - } else { - throw e; - } - } - } - - logout(): Promise { - if (this.currentUser === undefined) { - throw new UiLogicError("Please check user first."); - } - if (this.currentUser === null) { - throw new UiLogicError("No login."); - } - localStorage.removeItem(USER_STORAGE_KEY); - this.userSubject.next(null); - return Promise.resolve(); - } - - changePassword(oldPassword: string, newPassword: string): Promise { - if (this.currentUser == undefined) { - throw new UiLogicError("Not login or checked now, can't log out."); - } - - return getHttpUserClient() - .changePassword({ - oldPassword, - newPassword, - }) - .then(() => this.logout()); - } -} - -export const userService = new UserService(); - -export function useRawUser(): AuthUser | null | undefined { - const [user, setUser] = useState( - userService.currentUser - ); - useEffect(() => { - const subscription = userService.user$.subscribe((u) => setUser(u)); - return () => { - subscription.unsubscribe(); - }; - }); - return user; -} - -export function useUser(): AuthUser | null { - const [user, setUser] = useState(() => { - const initUser = userService.currentUser; - if (initUser === undefined) { - throw new UiLogicError( - "This is a logic error in user module. Current user can't be undefined in useUser." - ); - } - return initUser; - }); - useEffect(() => { - const sub = userService.user$.subscribe((u) => { - if (u === undefined) { - throw new UiLogicError( - "This is a logic error in user module. User emitted can't be undefined later." - ); - } - setUser(u); - }); - return () => { - sub.unsubscribe(); - }; - }); - return user; -} - -export function useUserLoggedIn(): AuthUser { - const user = useUser(); - if (user == null) { - throw new UiLogicError("You assert user has logged in but actually not."); - } - return user; -} diff --git a/FrontEnd/src/app/tsconfig.json b/FrontEnd/src/app/tsconfig.json deleted file mode 100644 index 17ee69cb..00000000 --- a/FrontEnd/src/app/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ] - }, - "include": [ - "." - ] -} diff --git a/FrontEnd/src/app/typings.d.ts b/FrontEnd/src/app/typings.d.ts deleted file mode 100644 index 34381682..00000000 --- a/FrontEnd/src/app/typings.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -declare module "*.png" { - const content: string; - export default content; -} - -declare module "*.jpeg" { - const content: string; - export default content; -} - -declare module "*.jpg" { - const content: string; - export default content; -} - -declare module "*.gif" { - const content: string; - export default content; -} - -declare module "*.svg" { - const content: string; - export default content; -} diff --git a/FrontEnd/src/app/utilities/mediaQuery.ts b/FrontEnd/src/app/utilities/mediaQuery.ts deleted file mode 100644 index ad55c3c0..00000000 --- a/FrontEnd/src/app/utilities/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/app/utilities/url.ts b/FrontEnd/src/app/utilities/url.ts deleted file mode 100644 index 4f2a6ecd..00000000 --- a/FrontEnd/src/app/utilities/url.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function applyQueryParameters(url: string, query: T): string { - if (query == null) return url; - - const params = new URLSearchParams(); - - for (const [key, value] of Object.entries(query)) { - if (value == null) void 0; - else if (typeof value === "string") params.set(key, value); - else if (typeof value === "number") params.set(key, String(value)); - else if (typeof value === "boolean") params.set(key, String(value)); - else if (value instanceof Date) params.set(key, value.toISOString()); - else { - console.error("Unknown query parameter type. Param: ", value); - } - } - return url + "?" + params.toString(); -} diff --git a/FrontEnd/src/app/utilities/useReverseScrollPositionRemember.ts b/FrontEnd/src/app/utilities/useReverseScrollPositionRemember.ts deleted file mode 100644 index a5812808..00000000 --- a/FrontEnd/src/app/utilities/useReverseScrollPositionRemember.ts +++ /dev/null @@ -1,77 +0,0 @@ -import React from "react"; - -let on = false; - -let reverseScrollPosition = getReverseScrollPosition(); -let reverseScrollToPosition: number | null = null; -let lastScrollPosition = window.scrollY; - -export function getReverseScrollPosition(): number { - if (document.documentElement.scrollHeight <= window.innerHeight) { - return 0; - } else { - return ( - document.documentElement.scrollHeight - - document.documentElement.scrollTop - - window.innerHeight - ); - } -} - -export function scrollToReverseScrollPosition(reversePosition: number): void { - if (document.documentElement.scrollHeight <= window.innerHeight) return; - - const old = document.documentElement.style.scrollBehavior; - document.documentElement.style.scrollBehavior = "auto"; - - const newPosition = - document.documentElement.scrollHeight - - window.innerHeight - - reversePosition; - - reverseScrollToPosition = newPosition; - - window.scrollTo(0, newPosition); - - document.documentElement.style.scrollBehavior = old; -} - -const scrollListener = (): void => { - if ( - reverseScrollToPosition != null && - Math.abs(window.scrollY - reverseScrollToPosition) > 50 - ) { - scrollToReverseScrollPosition(reverseScrollPosition); - return; - } - if ( - reverseScrollToPosition == null && - Math.abs(window.scrollY - lastScrollPosition) > 1000 - ) { - scrollToReverseScrollPosition(reverseScrollPosition); - return; - } - - reverseScrollToPosition = null; - lastScrollPosition = window.scrollY; - reverseScrollPosition = getReverseScrollPosition(); -}; - -const resizeObserver = new ResizeObserver(() => { - scrollToReverseScrollPosition(reverseScrollPosition); -}); - -export default function useReverseScrollPositionRemember(): void { - React.useEffect(() => { - if (on) return; - on = true; - window.addEventListener("scroll", scrollListener); - resizeObserver.observe(document.documentElement); - - return () => { - window.removeEventListener("scroll", scrollListener); - resizeObserver.disconnect(); - on = false; - }; - }, []); -} diff --git a/FrontEnd/src/app/utilities/useScrollToTop.ts b/FrontEnd/src/app/utilities/useScrollToTop.ts deleted file mode 100644 index 892e3545..00000000 --- a/FrontEnd/src/app/utilities/useScrollToTop.ts +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import { fromEvent } from "rxjs"; -import { filter, throttleTime } from "rxjs/operators"; - -function useScrollToTop( - handler: () => void, - enable = true, - option = { - maxOffset: 50, - throttle: 1000, - } -): void { - const handlerRef = React.useRef<(() => void) | null>(null); - - React.useEffect(() => { - handlerRef.current = handler; - - return () => { - handlerRef.current = null; - }; - }, [handler]); - - React.useEffect(() => { - const subscription = fromEvent(window, "scroll") - .pipe( - filter(() => { - return window.scrollY <= option.maxOffset; - }), - throttleTime(option.throttle) - ) - .subscribe(() => { - if (enable) { - handlerRef.current?.(); - } - }); - - return () => { - subscription.unsubscribe(); - }; - }, [enable, option.maxOffset, option.throttle]); -} - -export default useScrollToTop; diff --git a/FrontEnd/src/app/views/about/about.sass b/FrontEnd/src/app/views/about/about.sass deleted file mode 100644 index f4d00cae..00000000 --- a/FrontEnd/src/app/views/about/about.sass +++ /dev/null @@ -1,4 +0,0 @@ -.about-link-icon - @extend .mx-2 - width: 1.2em - height: 1.2em diff --git a/FrontEnd/src/app/views/about/author-avatar.png b/FrontEnd/src/app/views/about/author-avatar.png deleted file mode 100644 index d890d8d0..00000000 Binary files a/FrontEnd/src/app/views/about/author-avatar.png and /dev/null differ diff --git a/FrontEnd/src/app/views/about/github.png b/FrontEnd/src/app/views/about/github.png deleted file mode 100644 index ea6ff545..00000000 Binary files a/FrontEnd/src/app/views/about/github.png and /dev/null differ diff --git a/FrontEnd/src/app/views/about/index.tsx b/FrontEnd/src/app/views/about/index.tsx deleted file mode 100644 index a8a53a97..00000000 --- a/FrontEnd/src/app/views/about/index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from "react"; -import { useTranslation, Trans } from "react-i18next"; - -import authorAvatarUrl from "./author-avatar.png"; -import githubLogoUrl from "./github.png"; - -const frontendCredits: { - name: string; - url: string; -}[] = [ - { - name: "reactjs", - url: "https://reactjs.org", - }, - { - name: "typescript", - url: "https://www.typescriptlang.org", - }, - { - name: "bootstrap", - url: "https://getbootstrap.com", - }, - { - name: "react-bootstrap", - url: "https://react-bootstrap.github.io", - }, - { - name: "webpack", - url: "https://webpack.js.org", - }, - { - name: "sass", - url: "https://sass-lang.com", - }, - { - name: "eslint", - url: "https://eslint.org", - }, - { - name: "prettier", - url: "https://prettier.io", - }, - { - name: "pepjs", - url: "https://github.com/jquery/PEP", - }, -]; - -const backendCredits: { - name: string; - url: string; -}[] = [ - { - name: "ASP.NET Core", - url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", - }, - { name: "sqlite", url: "https://sqlite.org" }, - { - name: "ImageSharp", - url: "https://github.com/SixLabors/ImageSharp", - }, -]; - -const AboutPage: React.FC = () => { - const { t } = useTranslation(); - - return ( -
-
-

{t("about.author.title")}

-
-
- -
-

- {t("about.author.fullname")} - 杨宇千 -

-

- {t("about.author.nickname")} - crupest -

-

- {t("about.author.introduction")} - {t("about.author.introductionContent")} -

-
-
-

- {t("about.author.links")} - - - -

-
-
-
-

{t("about.site.title")}

-

- - 01234 - 56 - -

-

- - {t("about.site.repo")} - -

-
-
-

{t("about.credits.title")}

-

{t("about.credits.content")}

-

{t("about.credits.frontend")}

-
    - {frontendCredits.map((item, index) => { - return ( -
  • - - {item.name} - -
  • - ); - })} -
  • ...
  • -
-

{t("about.credits.backend")}

-
    - {backendCredits.map((item, index) => { - return ( -
  • - - {item.name} - -
  • - ); - })} -
  • ...
  • -
-
-
- ); -}; - -export default AboutPage; diff --git a/FrontEnd/src/app/views/admin/Admin.tsx b/FrontEnd/src/app/views/admin/Admin.tsx deleted file mode 100644 index 0b6d1f05..00000000 --- a/FrontEnd/src/app/views/admin/Admin.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { Fragment } from "react"; -import { Redirect, Route, Switch, useRouteMatch, match } from "react-router"; -import { Container } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -import { AuthUser } from "@/services/user"; - -import AdminNav from "./AdminNav"; -import UserAdmin from "./UserAdmin"; -import MoreAdmin from "./MoreAdmin"; - -interface AdminProps { - user: AuthUser; -} - -const Admin: React.FC = ({ user }) => { - useTranslation("admin"); - - const match = useRouteMatch(); - - return ( - - - - - {(p) => { - const match = p.match as match<{ name: string }>; - const name = match.params["name"]; - return ( - - - {(() => { - if (name === "users") { - return ; - } else if (name === "more") { - return ; - } - })()} - - ); - }} - - - - ); -}; - -export default Admin; diff --git a/FrontEnd/src/app/views/admin/AdminNav.tsx b/FrontEnd/src/app/views/admin/AdminNav.tsx deleted file mode 100644 index 47e2138f..00000000 --- a/FrontEnd/src/app/views/admin/AdminNav.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; -import { Nav } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; -import { useHistory, useRouteMatch } from "react-router"; - -const AdminNav: React.FC = () => { - const match = useRouteMatch<{ name: string }>(); - const history = useHistory(); - - const { t } = useTranslation(); - - const name = match.params.name; - - function toggle(newTab: string): void { - history.push(`/admin/${newTab}`); - } - - return ( - - ); -}; - -export default AdminNav; diff --git a/FrontEnd/src/app/views/admin/MoreAdmin.tsx b/FrontEnd/src/app/views/admin/MoreAdmin.tsx deleted file mode 100644 index 042789a0..00000000 --- a/FrontEnd/src/app/views/admin/MoreAdmin.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; - -import { AuthUser } from "@/services/user"; - -export interface MoreAdminProps { - user: AuthUser; -} - -const MoreAdmin: React.FC = () => { - return <>More...; -}; - -export default MoreAdmin; diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx deleted file mode 100644 index 558d3aee..00000000 --- a/FrontEnd/src/app/views/admin/UserAdmin.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import React, { useState, useEffect } from "react"; -import classnames from "classnames"; -import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap"; - -import OperationDialog, { - OperationDialogBoolInput, -} from "../common/OperationDialog"; - -import { AuthUser } from "@/services/user"; -import { - getHttpUserClient, - HttpUser, - kUserPermissionList, - UserPermission, -} from "@/http/user"; -import { Trans, useTranslation } from "react-i18next"; - -interface DialogProps { - open: boolean; - close: () => void; - data: TData; - onSuccess: (data: TReturn) => void; -} - -const CreateUserDialog: React.FC> = ({ - open, - close, - onSuccess, -}) => { - return ( - - getHttpUserClient().post({ - username, - password, - }) - } - close={close} - open={open} - onSuccessAndClose={onSuccess} - /> - ); -}; - -const UsernameLabel: React.FC = (props) => { - return {props.children}; -}; - -const UserDeleteDialog: React.FC> = - ({ open, close, data: { username }, onSuccess }) => { - return ( - ( - - 0{username}2 - - )} - onProcess={() => getHttpUserClient().delete(username)} - onSuccessAndClose={onSuccess} - /> - ); - }; - -const UserModifyDialog: React.FC< - DialogProps< - { - oldUser: HttpUser; - }, - HttpUser - > -> = ({ open, close, data: { oldUser }, onSuccess }) => { - return ( - ( - - 0{oldUser.username}2 - - )} - inputScheme={ - [ - { - type: "text", - label: "admin:user.username", - initValue: oldUser.username, - }, - { type: "text", label: "admin:user.password" }, - { - type: "text", - label: "admin:user.nickname", - initValue: oldUser.nickname, - }, - ] as const - } - onProcess={([username, password, nickname]) => - getHttpUserClient().patch(oldUser.username, { - username: username !== oldUser.username ? username : undefined, - password: password !== "" ? password : undefined, - nickname: nickname !== oldUser.nickname ? nickname : undefined, - }) - } - onSuccessAndClose={onSuccess} - /> - ); -}; - -const UserPermissionModifyDialog: React.FC< - DialogProps< - { - username: string; - permissions: UserPermission[]; - }, - UserPermission[] - > -> = ({ open, close, data: { username, permissions }, onSuccess }) => { - const oldPermissionBoolList: boolean[] = kUserPermissionList.map( - (permission) => permissions.includes(permission) - ); - - return ( - ( - - 0{username}2 - - )} - inputScheme={kUserPermissionList.map( - (permission, index) => ({ - type: "bool", - label: permission, - initValue: oldPermissionBoolList[index], - }) - )} - onProcess={async (newPermissionBoolList): Promise => { - for (let index = 0; index < kUserPermissionList.length; index++) { - const oldValue = oldPermissionBoolList[index]; - const newValue = newPermissionBoolList[index]; - const permission = kUserPermissionList[index]; - if (oldValue === newValue) continue; - if (newValue) { - await getHttpUserClient().putUserPermission(username, permission); - } else { - await getHttpUserClient().deleteUserPermission( - username, - permission - ); - } - } - return newPermissionBoolList; - }} - onSuccessAndClose={(newPermissionBoolList: boolean[]) => { - const permissions: UserPermission[] = []; - for (let index = 0; index < kUserPermissionList.length; index++) { - if (newPermissionBoolList[index]) { - permissions.push(kUserPermissionList[index]); - } - } - onSuccess(permissions); - }} - /> - ); -}; - -const kModify = "modify"; -const kModifyPermission = "permission"; -const kDelete = "delete"; - -type TModify = typeof kModify; -type TModifyPermission = typeof kModifyPermission; -type TDelete = typeof kDelete; - -type ContextMenuItem = TModify | TModifyPermission | TDelete; - -interface UserItemProps { - on: { [key in ContextMenuItem]: () => void }; - user: HttpUser; -} - -const UserItem: React.FC = ({ user, on }) => { - const { t } = useTranslation(); - - const [editMaskVisible, setEditMaskVisible] = React.useState(false); - - return ( - - setEditMaskVisible(true)} - /> -

{user.username}

-
- {t("admin:user.nickname")} - {user.nickname} -
-
- {t("admin:user.uniqueId")} - {user.uniqueId} -
-
- {t("admin:user.permissions")} - {user.permissions.map((permission) => { - return ( - - {permission}{" "} - - ); - })} -
-
setEditMaskVisible(false)} - > - - - -
-
- ); -}; - -interface UserAdminProps { - user: AuthUser; -} - -const UserAdmin: React.FC = () => { - const { t } = useTranslation(); - - type DialogInfo = - | null - | { - type: "create"; - } - | { - type: TModify; - user: HttpUser; - } - | { - type: TModifyPermission; - username: string; - permissions: UserPermission[]; - } - | { type: TDelete; username: string }; - - const [users, setUsers] = useState(null); - const [dialog, setDialog] = useState(null); - const [usersVersion, setUsersVersion] = useState(0); - const updateUsers = (): void => { - setUsersVersion(usersVersion + 1); - }; - - useEffect(() => { - let subscribe = true; - void getHttpUserClient() - .list() - .then((us) => { - if (subscribe) { - setUsers(us); - } - }); - return () => { - subscribe = false; - }; - }, [usersVersion]); - - let dialogNode: React.ReactNode; - if (dialog) { - switch (dialog.type) { - case "create": - dialogNode = ( - setDialog(null)} - data={undefined} - onSuccess={updateUsers} - /> - ); - break; - case kDelete: - dialogNode = ( - setDialog(null)} - data={{ username: dialog.username }} - onSuccess={updateUsers} - /> - ); - break; - case kModify: - dialogNode = ( - setDialog(null)} - data={{ oldUser: dialog.user }} - onSuccess={updateUsers} - /> - ); - break; - case kModifyPermission: - dialogNode = ( - setDialog(null)} - data={{ - username: dialog.username, - permissions: dialog.permissions, - }} - onSuccess={updateUsers} - /> - ); - break; - } - } - - if (users) { - const userComponents = users.map((user) => { - return ( - { - setDialog({ - type: "modify", - user, - }); - }, - permission: () => { - setDialog({ - type: kModifyPermission, - username: user.username, - permissions: user.permissions, - }); - }, - delete: () => { - setDialog({ - type: "delete", - username: user.username, - }); - }, - }} - /> - ); - }); - - return ( - <> - - - - - - {userComponents} - {dialogNode} - - ); - } else { - return ; - } -}; - -export default UserAdmin; diff --git a/FrontEnd/src/app/views/admin/admin.sass b/FrontEnd/src/app/views/admin/admin.sass deleted file mode 100644 index 1ce010f8..00000000 --- a/FrontEnd/src/app/views/admin/admin.sass +++ /dev/null @@ -1,22 +0,0 @@ -.admin-user-item - position: relative - - .edit-mask - position: absolute - top: 0 - left: 0 - bottom: 0 - right: 0 - - background: #ffffffc5 - position: absolute - - display: flex - justify-content: center - align-items: center - - @include media-breakpoint-down(xs) - flex-direction: column - - button - margin: 0.5em 2em diff --git a/FrontEnd/src/app/views/center/CenterBoards.tsx b/FrontEnd/src/app/views/center/CenterBoards.tsx deleted file mode 100644 index f5200415..00000000 --- a/FrontEnd/src/app/views/center/CenterBoards.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from "react"; -import { Row, Col } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -import { pushAlert } from "@/services/alert"; -import { useUserLoggedIn } from "@/services/user"; - -import { getHttpTimelineClient } from "@/http/timeline"; -import { getHttpBookmarkClient } from "@/http/bookmark"; -import { getHttpHighlightClient } from "@/http/highlight"; - -import TimelineBoard from "./TimelineBoard"; - -const CenterBoards: React.FC = () => { - const { t } = useTranslation(); - - const user = useUserLoggedIn(); - - return ( - <> - - - - - getHttpBookmarkClient().list()} - editHandler={{ - onDelete: (timeline) => { - return getHttpBookmarkClient() - .delete(timeline) - .catch((e) => { - pushAlert({ - message: "home.message.deleteBookmarkFail", - type: "danger", - }); - throw e; - }); - }, - onMove: (timeline, index, offset) => { - return getHttpBookmarkClient() - .move( - { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1 - ) - .catch((e) => { - pushAlert({ - message: "home.message.moveBookmarkFail", - type: "danger", - }); - throw e; - }); - }, - }} - /> - - - getHttpHighlightClient().list()} - editHandler={ - user.hasHighlightTimelineAdministrationPermission - ? { - onDelete: (timeline) => { - return getHttpHighlightClient() - .delete(timeline) - .catch((e) => { - pushAlert({ - message: "home.message.deleteHighlightFail", - type: "danger", - }); - throw e; - }); - }, - onMove: (timeline, index, offset) => { - return getHttpHighlightClient() - .move( - { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1 - ) - .catch((e) => { - pushAlert({ - message: "home.message.moveHighlightFail", - type: "danger", - }); - throw e; - }); - }, - } - : undefined - } - /> - - - - - - getHttpTimelineClient().listTimeline({ relate: user.username }) - } - /> - - - - ); -}; - -export default CenterBoards; diff --git a/FrontEnd/src/app/views/center/TimelineBoard.tsx b/FrontEnd/src/app/views/center/TimelineBoard.tsx deleted file mode 100644 index 35249f66..00000000 --- a/FrontEnd/src/app/views/center/TimelineBoard.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Spinner } from "react-bootstrap"; - -import { HttpTimelineInfo } from "@/http/timeline"; - -import TimelineLogo from "../common/TimelineLogo"; -import UserTimelineLogo from "../common/UserTimelineLogo"; -import LoadFailReload from "../common/LoadFailReload"; - -interface TimelineBoardItemProps { - timeline: HttpTimelineInfo; - // In height. - offset?: number; - // In px. - arbitraryOffset?: number; - // If not null, will disable navigation on click. - actions?: { - onDelete: () => void; - onMove: { - start: (e: React.PointerEvent) => void; - moving: (e: React.PointerEvent) => void; - end: (e: React.PointerEvent) => void; - }; - }; -} - -const TimelineBoardItem: React.FC = ({ - timeline, - arbitraryOffset, - offset, - actions, -}) => { - const { name, title } = timeline; - const isPersonal = name.startsWith("@"); - const url = isPersonal - ? `/users/${timeline.owner.username}` - : `/timelines/${name}`; - - const content = ( - <> - {isPersonal ? ( - - ) : ( - - )} - {title} - {name} - - {actions != null ? ( -
- - { - e.currentTarget.setPointerCapture(e.pointerId); - actions.onMove.start(e); - }} - onPointerUp={(e) => { - actions.onMove.end(e); - try { - e.currentTarget.releasePointerCapture(e.pointerId); - } catch (_) { - void null; - } - }} - onPointerMove={actions.onMove.moving} - /> -
- ) : null} - - ); - - const offsetStyle: React.CSSProperties = { - transform: - arbitraryOffset != null - ? `translate(0,${arbitraryOffset}px)` - : offset != null - ? `translate(0,${offset * 100}%)` - : undefined, - transition: offset != null ? "transform 0.5s" : undefined, - zIndex: arbitraryOffset != null ? 1 : undefined, - }; - - return actions == null ? ( - - {content} - - ) : ( -
- {content} -
- ); -}; - -interface TimelineBoardItemContainerProps { - timelines: HttpTimelineInfo[]; - editHandler?: { - // offset may exceed index range plusing index. - onMove: (timeline: string, index: number, offset: number) => void; - onDelete: (timeline: string) => void; - }; -} - -const TimelineBoardItemContainer: React.FC = ({ - timelines, - editHandler, -}) => { - const [moveState, setMoveState] = React.useState(null); - - return ( - <> - {timelines.map((timeline, index) => { - const height = 48; - - let offset: number | undefined = undefined; - let arbitraryOffset: number | undefined = undefined; - if (moveState != null) { - if (index === moveState.index) { - arbitraryOffset = moveState.offset; - } else { - if (moveState.offset >= 0) { - const offsetCount = Math.round(moveState.offset / height); - if ( - index > moveState.index && - index <= moveState.index + offsetCount - ) { - offset = -1; - } else { - offset = 0; - } - } else { - const offsetCount = Math.round(-moveState.offset / height); - if ( - index < moveState.index && - index >= moveState.index - offsetCount - ) { - offset = 1; - } else { - offset = 0; - } - } - } - } - - return ( - { - editHandler.onDelete(timeline.name); - }, - onMove: { - start: (e) => { - if (moveState != null) return; - setMoveState({ - index, - offset: 0, - startPointY: e.clientY, - }); - }, - moving: (e) => { - if (moveState == null) return; - setMoveState({ - index, - offset: e.clientY - moveState.startPointY, - startPointY: moveState.startPointY, - }); - }, - end: () => { - if (moveState != null) { - const offsetCount = Math.round( - moveState.offset / height - ); - editHandler.onMove( - timeline.name, - moveState.index, - offsetCount - ); - } - setMoveState(null); - }, - }, - } - : undefined - } - /> - ); - })} - - ); -}; - -interface TimelineBoardUIProps { - title?: string; - timelines: HttpTimelineInfo[] | "offline" | "loading"; - onReload: () => void; - className?: string; - editHandler?: { - onMove: (timeline: string, index: number, offset: number) => void; - onDelete: (timeline: string) => void; - }; -} - -const TimelineBoardUI: React.FC = (props) => { - const { title, timelines, className, editHandler } = props; - - const { t } = useTranslation(); - - const editable = editHandler != null; - - const [editing, setEditing] = React.useState(false); - - return ( -
-
- {title != null &&

{title}

} - {editable && - (editing ? ( -
{ - setEditing(false); - }} - > - {t("done")} -
- ) : ( -
{ - setEditing(true); - }} - > - {t("edit")} -
- ))} -
- {(() => { - if (timelines === "loading") { - return ( -
- -
- ); - } else if (timelines === "offline") { - return ( -
- -
- ); - } else { - return ( - { - if (index + offset >= timelines.length) { - offset = timelines.length - index - 1; - } else if (index + offset < 0) { - offset = -index; - } - editHandler.onMove(timeline, index, offset); - }, - } - : undefined - } - /> - ); - } - })()} -
- ); -}; - -export interface TimelineBoardProps { - title?: string; - className?: string; - load: () => Promise; - editHandler?: { - onMove: (timeline: string, index: number, offset: number) => Promise; - onDelete: (timeline: string) => Promise; - }; -} - -const TimelineBoard: React.FC = ({ - className, - title, - load, - editHandler, -}) => { - const [timelines, setTimelines] = React.useState< - HttpTimelineInfo[] | "offline" | "loading" - >("loading"); - - React.useEffect(() => { - let subscribe = true; - if (timelines === "loading") { - void load().then( - (timelines) => { - if (subscribe) { - setTimelines(timelines); - } - }, - () => { - setTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [load, timelines]); - - return ( - { - setTimelines("loading"); - }} - editHandler={ - typeof timelines === "object" && editHandler != null - ? { - onMove: (timeline, index, offset) => { - const newTimelines = timelines.slice(); - const [t] = newTimelines.splice(index, 1); - newTimelines.splice(index + offset, 0, t); - setTimelines(newTimelines); - editHandler.onMove(timeline, index, offset).then(null, () => { - setTimelines(timelines); - }); - }, - onDelete: (timeline) => { - const newTimelines = timelines.slice(); - newTimelines.splice( - timelines.findIndex((t) => t.name === timeline), - 1 - ); - setTimelines(newTimelines); - editHandler.onDelete(timeline).then(null, () => { - setTimelines(timelines); - }); - }, - } - : undefined - } - /> - ); -}; - -export default TimelineBoard; diff --git a/FrontEnd/src/app/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/center/TimelineCreateDialog.tsx deleted file mode 100644 index b4e25ba1..00000000 --- a/FrontEnd/src/app/views/center/TimelineCreateDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; - -import { validateTimelineName } from "@/services/timeline"; -import OperationDialog from "../common/OperationDialog"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; - -interface TimelineCreateDialogProps { - open: boolean; - close: () => void; -} - -const TimelineCreateDialog: React.FC = (props) => { - const history = useHistory(); - - return ( - { - if (name.length === 0) { - return { 0: "home.createDialog.noEmpty" }; - } else if (name.length > 26) { - return { 0: "home.createDialog.tooLong" }; - } else if (!validateTimelineName(name)) { - return { 0: "home.createDialog.badFormat" }; - } else { - return null; - } - }} - onProcess={([name]): Promise => - getHttpTimelineClient().postTimeline({ name }) - } - onSuccessAndClose={(timeline: HttpTimelineInfo) => { - history.push(`timelines/${timeline.name}`); - }} - failurePrompt={(e) => `${e as string}`} - /> - ); -}; - -export default TimelineCreateDialog; diff --git a/FrontEnd/src/app/views/center/center.sass b/FrontEnd/src/app/views/center/center.sass deleted file mode 100644 index c0dfb9c0..00000000 --- a/FrontEnd/src/app/views/center/center.sass +++ /dev/null @@ -1,36 +0,0 @@ -.timeline-board - @extend .cru-card - @extend .d-flex - @extend .flex-column - @extend .py-3 - min-height: 200px - height: 100% - position: relative - -.timeline-board-header - @extend .px-3 - display: flex - align-items: center - justify-content: space-between - -.timeline-board-item - font-size: 1.1em - @extend .px-3 - height: 48px - transition: background 0.3s - display: flex - align-items: center - .icon - height: 1.3em - color: black - @extend .me-2 - &:hover - background: $gray-300 - .right - display: flex - align-items: center - flex-shrink: 0 - .title - white-space: nowrap - overflow: hidden - text-overflow: ellipsis diff --git a/FrontEnd/src/app/views/center/index.tsx b/FrontEnd/src/app/views/center/index.tsx deleted file mode 100644 index 0a2abb2c..00000000 --- a/FrontEnd/src/app/views/center/index.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Row, Container, Button, Col } from "react-bootstrap"; - -import { useUserLoggedIn } from "@/services/user"; - -import SearchInput from "../common/SearchInput"; -import CenterBoards from "./CenterBoards"; -import TimelineCreateDialog from "./TimelineCreateDialog"; - -const HomePage: React.FC = () => { - const history = useHistory(); - - const { t } = useTranslation(); - - const user = useUserLoggedIn(); - - const [navText, setNavText] = React.useState(""); - - const [dialog, setDialog] = React.useState<"create" | null>(null); - - return ( - <> - - - - { - history.push(`search?q=${navText}`); - }} - additionalButton={ - user != null && ( - - ) - } - /> - - - - - {dialog === "create" && ( - { - setDialog(null); - }} - /> - )} - - ); -}; - -export default HomePage; diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx deleted file mode 100644 index 91dfbee9..00000000 --- a/FrontEnd/src/app/views/common/AppBar.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Link, NavLink } from "react-router-dom"; -import classnames from "classnames"; -import { useMediaQuery } from "react-responsive"; - -import { useUser } from "@/services/user"; - -import TimelineLogo from "./TimelineLogo"; -import UserAvatar from "./user/UserAvatar"; - -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(false); - const collapse = (): void => setExpand(false); - const toggleExpand = (): void => setExpand(!expand); - - const createLink = ( - link: string, - label: React.ReactNode, - className?: string - ): React.ReactNode => ( - - {label} - - ); - - return ( - - ); -}; - -export default AppBar; diff --git a/FrontEnd/src/app/views/common/BlobImage.tsx b/FrontEnd/src/app/views/common/BlobImage.tsx deleted file mode 100644 index 0dd25c52..00000000 --- a/FrontEnd/src/app/views/common/BlobImage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; - -const BlobImage: React.FC< - Omit, "src"> & { - blob?: Blob | unknown; - } -> = (props) => { - const { blob, ...otherProps } = props; - - const [url, setUrl] = React.useState(undefined); - - React.useEffect(() => { - if (blob instanceof Blob) { - const url = URL.createObjectURL(blob); - setUrl(url); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setUrl(undefined); - } - }, [blob]); - - return ; -}; - -export default BlobImage; diff --git a/FrontEnd/src/app/views/common/ConfirmDialog.tsx b/FrontEnd/src/app/views/common/ConfirmDialog.tsx deleted file mode 100644 index 72940c51..00000000 --- a/FrontEnd/src/app/views/common/ConfirmDialog.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { convertI18nText, I18nText } from "@/common"; -import React from "react"; -import { Modal, Button } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -const ConfirmDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; - title: I18nText; - body: I18nText; -}> = ({ onClose, onConfirm, title, body }) => { - const { t } = useTranslation(); - - return ( - - - - {convertI18nText(title, t)} - - - {convertI18nText(body, t)} - - - - - - ); -}; - -export default ConfirmDialog; diff --git a/FrontEnd/src/app/views/common/FlatButton.tsx b/FrontEnd/src/app/views/common/FlatButton.tsx deleted file mode 100644 index b1f7a051..00000000 --- a/FrontEnd/src/app/views/common/FlatButton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -import { BootstrapThemeColor } from "@/common"; - -export interface FlatButtonProps { - variant?: BootstrapThemeColor | string; - disabled?: boolean; - className?: string; - style?: React.CSSProperties; - onClick?: () => void; -} - -const FlatButton: React.FC = (props) => { - const { disabled, className, style } = props; - const variant = props.variant ?? "primary"; - - const onClick = disabled ? undefined : props.onClick; - - return ( -
- {props.children} -
- ); -}; - -export default FlatButton; diff --git a/FrontEnd/src/app/views/common/FullPage.tsx b/FrontEnd/src/app/views/common/FullPage.tsx deleted file mode 100644 index 1b59045a..00000000 --- a/FrontEnd/src/app/views/common/FullPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -export interface FullPageProps { - show: boolean; - onBack: () => void; - contentContainerClassName?: string; -} - -const FullPage: React.FC = ({ - show, - onBack, - children, - contentContainerClassName, -}) => { - return ( -
-
- -
-
- {children} -
-
- ); -}; - -export default FullPage; diff --git a/FrontEnd/src/app/views/common/ImageCropper.tsx b/FrontEnd/src/app/views/common/ImageCropper.tsx deleted file mode 100644 index 2ef5b7ed..00000000 --- a/FrontEnd/src/app/views/common/ImageCropper.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -import { UiLogicError } from "@/common"; - -export interface Clip { - left: number; - top: number; - width: number; -} - -interface NormailizedClip extends Clip { - height: number; -} - -interface ImageInfo { - width: number; - height: number; - landscape: boolean; - ratio: number; - maxClipWidth: number; - maxClipHeight: number; -} - -interface ImageCropperSavedState { - clip: NormailizedClip; - x: number; - y: number; - pointerId: number; -} - -export interface ImageCropperProps { - clip: Clip | null; - imageUrl: string; - onChange: (clip: Clip) => void; - imageElementCallback?: (element: HTMLImageElement | null) => void; - className?: string; -} - -const ImageCropper = (props: ImageCropperProps): React.ReactElement => { - const { clip, imageUrl, onChange, imageElementCallback, className } = props; - - const [oldState, setOldState] = React.useState( - null - ); - const [imageInfo, setImageInfo] = React.useState(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(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) => { - 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 ( -
- to crop -
-
-
-
-
- ); -}; - -export default ImageCropper; - -export function applyClipToImage( - image: HTMLImageElement, - clip: Clip, - mimeType: string -): Promise { - 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/app/views/common/LoadFailReload.tsx b/FrontEnd/src/app/views/common/LoadFailReload.tsx deleted file mode 100644 index a80e7b76..00000000 --- a/FrontEnd/src/app/views/common/LoadFailReload.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { Trans } from "react-i18next"; - -export interface LoadFailReloadProps { - className?: string; - style?: React.CSSProperties; - onReload: () => void; -} - -const LoadFailReload: React.FC = ({ - onReload, - className, - style, -}) => { - return ( - - 0 - { - onReload(); - e.preventDefault(); - }} - > - 1 - - 2 - - ); -}; - -export default LoadFailReload; diff --git a/FrontEnd/src/app/views/common/LoadingButton.tsx b/FrontEnd/src/app/views/common/LoadingButton.tsx deleted file mode 100644 index cd9f1adc..00000000 --- a/FrontEnd/src/app/views/common/LoadingButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { Button, ButtonProps, Spinner } from "react-bootstrap"; - -const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({ - loading, - variant, - disabled, - ...otherProps -}) => { - return ( - - ); -}; - -export default LoadingButton; diff --git a/FrontEnd/src/app/views/common/LoadingPage.tsx b/FrontEnd/src/app/views/common/LoadingPage.tsx deleted file mode 100644 index 590fafa0..00000000 --- a/FrontEnd/src/app/views/common/LoadingPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import { Spinner } from "react-bootstrap"; - -const LoadingPage: React.FC = () => { - return ( -
- -
- ); -}; - -export default LoadingPage; diff --git a/FrontEnd/src/app/views/common/Menu.tsx b/FrontEnd/src/app/views/common/Menu.tsx deleted file mode 100644 index ae73a331..00000000 --- a/FrontEnd/src/app/views/common/Menu.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common"; - -export type MenuItem = - | { - type: "divider"; - } - | { - type: "button"; - text: I18nText; - iconClassName?: string; - color?: BootstrapThemeColor; - onClick: () => void; - }; - -export type MenuItems = MenuItem[]; - -export interface MenuProps { - items: MenuItems; - className?: string; - onItemClicked?: () => void; -} - -const Menu: React.FC = ({ items, className, onItemClicked }) => { - const { t } = useTranslation(); - - return ( -
- {items.map((item, index) => { - if (item.type === "divider") { - return
; - } else { - return ( -
{ - item.onClick(); - onItemClicked?.(); - }} - > - {item.iconClassName != null ? ( - - ) : null} - {convertI18nText(item.text, t)} -
- ); - } - })} -
- ); -}; - -export default Menu; - -export interface PopupMenuProps { - items: MenuItems; - children: OverlayTriggerProps["children"]; -} - -export const PopupMenu: React.FC = ({ items, children }) => { - const [show, setShow] = React.useState(false); - const toggle = (): void => setShow(!show); - - return ( - - setShow(false)} /> - - } - show={show} - onToggle={toggle} - > - {children} - - ); -}; diff --git a/FrontEnd/src/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx deleted file mode 100644 index ac4c51b9..00000000 --- a/FrontEnd/src/app/views/common/OperationDialog.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Form, Button, Modal } from "react-bootstrap"; -import { TwitterPicker } from "react-color"; -import moment from "moment"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -import LoadingButton from "./LoadingButton"; - -interface DefaultErrorPromptProps { - error?: string; -} - -const DefaultErrorPrompt: React.FC = (props) => { - const { t } = useTranslation(); - - let result =

{t("operationDialog.error")}

; - - if (props.error != null) { - result = ( - <> - {result} -

{props.error}

- - ); - } - - return result; -}; - -export interface OperationDialogTextInput { - type: "text"; - label?: I18nText; - password?: boolean; - initValue?: string; - textFieldProps?: Omit< - React.InputHTMLAttributes, - "type" | "value" | "onChange" | "aria-relevant" - >; - helperText?: string; -} - -export interface OperationDialogBoolInput { - type: "bool"; - label: I18nText; - initValue?: boolean; -} - -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; -} - -export type OperationDialogInput = - | OperationDialogTextInput - | OperationDialogBoolInput - | OperationDialogSelectInput - | OperationDialogColorInput - | OperationDialogDateTimeInput; - -interface OperationInputTypeStringToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string | null; - datetime: string; -} - -type MapOperationInputTypeStringToValueType = - Type extends keyof OperationInputTypeStringToValueTypeMap - ? OperationInputTypeStringToValueTypeMap[Type] - : never; - -type MapOperationInputInfoValueType = T extends OperationDialogInput - ? MapOperationInputTypeStringToValueType - : T; - -const initValueMapperMap: { - [T in OperationDialogInput as T["type"]]: ( - item: T - ) => MapOperationInputInfoValueType; -} = { - 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; -} & { 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; - close: () => void; - title: I18nText | (() => React.ReactNode); - themeColor?: "danger" | "success" | string; - onProcess: ( - inputs: MapOperationInputInfoValueTypeList - ) => Promise; - inputScheme?: OperationInputInfoList; - inputValidator?: ( - inputs: MapOperationInputInfoValueTypeList - ) => 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 -): 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({ type: "input" }); - - type ValueType = boolean | string | null | undefined; - - const [values, setValues] = useState( - 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(() => - inputScheme.map(() => false) - ); - const [inputError, setInputError] = useState(); - - const close = (): void => { - if (step.type !== "process") { - props.close(); - 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 - ) - .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 =
{inputPrompt}
; - - const validate = (values: ValueType[]): boolean => { - const { inputValidator } = props; - if (inputValidator != null) { - const result = inputValidator( - values as unknown as MapOperationInputInfoValueTypeList - ); - 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 = ( - <> - - {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 ( - - {item.label && ( - {convertI18nText(item.label, t)} - )} - { - const v = e.target.value; - updateValue(index, v); - }} - isInvalid={error != null} - disabled={process} - /> - {error != null && ( - - {error} - - )} - {item.helperText && ( - {t(item.helperText)} - )} - - ); - } else if (item.type === "bool") { - return ( - - - type="checkbox" - checked={value as boolean} - onChange={(event) => { - updateValue(index, event.currentTarget.checked); - }} - label={convertI18nText(item.label, t)} - disabled={process} - /> - - ); - } else if (item.type === "select") { - return ( - - {convertI18nText(item.label, t)} - { - updateValue(index, event.target.value); - }} - disabled={process} - > - {item.options.map((option, i) => { - return ( - - ); - })} - - - ); - } else if (item.type === "color") { - return ( - - {item.canBeNull ? ( - - type="checkbox" - checked={value !== null} - onChange={(event) => { - if (event.currentTarget.checked) { - updateValue(index, "#007bff"); - } else { - updateValue(index, null); - } - }} - label={convertI18nText(item.label, t)} - disabled={process} - /> - ) : ( - {convertI18nText(item.label, t)} - )} - {value !== null && ( - updateValue(index, result.hex)} - /> - )} - - ); - } else if (item.type === "datetime") { - return ( - - {item.label && ( - {convertI18nText(item.label, t)} - )} - { - const v = e.target.value; - updateValue(index, v); - }} - isInvalid={error != null} - disabled={process} - /> - {error != null && ( - - {error} - - )} - - ); - } - })} - - - - { - setDirtyList(inputScheme.map(() => true)); - if (validate(values)) { - onConfirm(); - } - }} - > - {t("operationDialog.confirm")} - - - - ); - } 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 =

{content}

; - } else { - content = props.failurePrompt?.(result.data) ?? ; - if (typeof content === "string") - content = ; - } - body = ( - <> - {content} - - - - - ); - } - - const title = - typeof props.title === "function" - ? props.title() - : convertI18nText(props.title, t); - - return ( - - - {title} - - {body} - - ); -}; - -export default OperationDialog; diff --git a/FrontEnd/src/app/views/common/SearchInput.tsx b/FrontEnd/src/app/views/common/SearchInput.tsx deleted file mode 100644 index ccb6dad6..00000000 --- a/FrontEnd/src/app/views/common/SearchInput.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useCallback } from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; -import { Spinner, Form, Button } from "react-bootstrap"; - -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 = (props) => { - const { onChange, onButtonClick, alwaysOneline } = props; - - const { t } = useTranslation(); - - const onInputChange = useCallback( - (event: React.ChangeEvent): void => { - onChange(event.currentTarget.value); - }, - [onChange] - ); - - const onInputKeyPress = useCallback( - (event: React.KeyboardEvent): void => { - if (event.key === "Enter") { - onButtonClick(); - event.preventDefault(); - } - }, - [onButtonClick] - ); - - return ( -
- - {props.additionalButton ? ( -
- {props.additionalButton} -
- ) : null} -
- {props.loading ? ( - - ) : ( - - )} -
- - ); -}; - -export default SearchInput; diff --git a/FrontEnd/src/app/views/common/Skeleton.tsx b/FrontEnd/src/app/views/common/Skeleton.tsx deleted file mode 100644 index 14886c71..00000000 --- a/FrontEnd/src/app/views/common/Skeleton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { range } from "lodash"; - -export interface SkeletonProps { - lineNumber?: number; - className?: string; - style?: React.CSSProperties; -} - -const Skeleton: React.FC = (props) => { - const { lineNumber: lineNumberProps, className, style } = props; - const lineNumber = lineNumberProps ?? 3; - - return ( -
- {range(lineNumber).map((i) => ( -
- ))} -
- ); -}; - -export default Skeleton; diff --git a/FrontEnd/src/app/views/common/TabPages.tsx b/FrontEnd/src/app/views/common/TabPages.tsx deleted file mode 100644 index 2b1d91cb..00000000 --- a/FrontEnd/src/app/views/common/TabPages.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import { Nav } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -export interface TabPage { - id: string; - tabText: I18nText; - page: React.ReactNode; -} - -export interface TabPagesProps { - pages: TabPage[]; - actions?: React.ReactNode; - className?: string; - style?: React.CSSProperties; - navClassName?: string; - navStyle?: React.CSSProperties; - pageContainerClassName?: string; - pageContainerStyle?: React.CSSProperties; -} - -const TabPages: React.FC = ({ - pages, - actions, - className, - style, - navClassName, - navStyle, - pageContainerClassName, - pageContainerStyle, -}) => { - if (pages.length === 0) { - throw new UiLogicError("Page list can't be empty."); - } - - const { t } = useTranslation(); - - const [tab, setTab] = React.useState(pages[0].id); - - const currentPage = pages.find((p) => p.id === tab); - - if (currentPage == null) { - throw new UiLogicError("Current tab value is bad."); - } - - return ( -
- -
- {currentPage.page} -
-
- ); -}; - -export default TabPages; diff --git a/FrontEnd/src/app/views/common/TimelineLogo.tsx b/FrontEnd/src/app/views/common/TimelineLogo.tsx deleted file mode 100644 index 27d188fc..00000000 --- a/FrontEnd/src/app/views/common/TimelineLogo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { SVGAttributes } from "react"; - -export interface TimelineLogoProps extends SVGAttributes { - color?: string; -} - -const TimelineLogo: React.FC = (props) => { - const { color, ...forwardProps } = props; - const coercedColor = color ?? "currentcolor"; - return ( - - - - - - ); -}; - -export default TimelineLogo; diff --git a/FrontEnd/src/app/views/common/ToggleIconButton.tsx b/FrontEnd/src/app/views/common/ToggleIconButton.tsx deleted file mode 100644 index c4d2d132..00000000 --- a/FrontEnd/src/app/views/common/ToggleIconButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -export interface ToggleIconButtonProps - extends React.HTMLAttributes { - state: boolean; - trueIconClassName: string; - falseIconClassName: string; -} - -const ToggleIconButton: React.FC = ({ - state, - className, - trueIconClassName, - falseIconClassName, - ...otherProps -}) => { - return ( - - ); -}; - -export default ToggleIconButton; diff --git a/FrontEnd/src/app/views/common/UserTimelineLogo.tsx b/FrontEnd/src/app/views/common/UserTimelineLogo.tsx deleted file mode 100644 index 19b9fee5..00000000 --- a/FrontEnd/src/app/views/common/UserTimelineLogo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { SVGAttributes } from "react"; - -export interface UserTimelineLogoProps extends SVGAttributes { - color?: string; -} - -const UserTimelineLogo: React.FC = (props) => { - const { color, ...forwardProps } = props; - const coercedColor = color ?? "currentcolor"; - - return ( - - - - - - - - - - - - ); -}; - -export default UserTimelineLogo; diff --git a/FrontEnd/src/app/views/common/alert/AlertHost.tsx b/FrontEnd/src/app/views/common/alert/AlertHost.tsx deleted file mode 100644 index 949be7ed..00000000 --- a/FrontEnd/src/app/views/common/alert/AlertHost.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import without from "lodash/without"; -import { useTranslation } from "react-i18next"; -import { Alert } from "react-bootstrap"; - -import { - alertService, - AlertInfoEx, - kAlertHostId, - AlertInfo, -} from "@/services/alert"; -import { convertI18nText } from "@/common"; - -interface AutoCloseAlertProps { - alert: AlertInfo; - close: () => void; -} - -export const AutoCloseAlert: React.FC = (props) => { - const { alert, close } = props; - const { dismissTime } = alert; - - const { t } = useTranslation(); - - const timerTag = React.useRef(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 ( - - {(() => { - const { message } = alert; - if (typeof message === "function") { - const Message = message; - return ; - } else return convertI18nText(message, t); - })()} - - ); -}; - -const AlertHost: React.FC = () => { - const [alerts, setAlerts] = React.useState([]); - - // react guarantee that state setters are stable, so we don't need to add it to dependency list - - React.useEffect(() => { - const consume = (alert: AlertInfoEx): void => { - setAlerts((old) => [...old, alert]); - }; - - alertService.registerConsumer(consume); - return () => { - alertService.unregisterConsumer(consume); - }; - }, []); - - return ( -
- {alerts.map((alert) => { - return ( - { - setAlerts((old) => without(old, alert)); - }} - /> - ); - })} -
- ); -}; - -export default AlertHost; diff --git a/FrontEnd/src/app/views/common/alert/alert.sass b/FrontEnd/src/app/views/common/alert/alert.sass deleted file mode 100644 index c3560b87..00000000 --- a/FrontEnd/src/app/views/common/alert/alert.sass +++ /dev/null @@ -1,15 +0,0 @@ -.alert-container - position: fixed - z-index: $zindex-popover - -@include media-breakpoint-up(sm) - .alert-container - bottom: 0 - right: 0 - -@include media-breakpoint-down(sm) - .alert-container - bottom: 0 - right: 0 - left: 0 - text-align: center diff --git a/FrontEnd/src/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass deleted file mode 100644 index cbf7292e..00000000 --- a/FrontEnd/src/app/views/common/common.sass +++ /dev/null @@ -1,191 +0,0 @@ -.image-cropper-container - position: relative - box-sizing: border-box - user-select: none - -.image-cropper-container img - position: absolute - left: 0 - top: 0 - width: 100% - height: 100% - -.image-cropper-mask-container - position: absolute - left: 0 - top: 0 - right: 0 - bottom: 0 - overflow: hidden - -.image-cropper-mask - position: absolute - box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%) - touch-action: none - -.image-cropper-handler - position: absolute - width: 26px - height: 26px - border: black solid 2px - border-radius: 50% - background: white - touch-action: none - -.app-bar - display: flex - align-items: center - height: 56px - - position: fixed - z-index: 1030 - top: 0 - left: 0 - right: 0 - - background-color: var(--tl-primary-color) - - transition: background-color 1s - - a - color: var(--tl-text-on-primary-inactive-color) - text-decoration: none - margin: 0 1em - - &:hover - color: var(--tl-text-on-primary-color) - - &.active - color: var(--tl-text-on-primary-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(--tl-primary-color) - - flex-direction: column - - &.app-bar-collapse - transform: scale(1,0) - - a - text-align: left - padding: 0.5em 0.5em - - .app-bar-link-area - flex-direction: column - align-items: stretch - - .app-bar-user-area - flex-direction: column - align-items: stretch - margin-left: unset - - .app-bar-avatar - align-self: flex-end - -.app-bar-toggler - margin-left: auto - font-size: 2em - margin-right: 1em - color: var(--tl-text-on-primary-color) - cursor: pointer - user-select: none - -.cru-skeleton - padding: 0 1em - -.cru-skeleton-line - height: 1em - background-color: #e6e6e6 - margin: 0.7em 0 - border-radius: 0.2em - - &.last - width: 50% - -.cru-full-page - position: fixed - z-index: 1031 - 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(--tl-primary-color) - - display: flex - align-items: center - -.cru-full-page-content-container - overflow: scroll - -.cru-menu - min-width: 200px - -.cru-menu-item - font-size: 1.2em - padding: 0.5em 1.5em - cursor: pointer - - @each $color, $value in $theme-colors - &.color-#{$color} - color: $value - - &:hover - color: white - background-color: $value - -.cru-menu-item-icon - margin-right: 1em - -.cru-menu-divider - border-top: 1px solid $gray-200 - -.cru-tab-pages-action-area - display: flex - align-items: center - -.cru-search-input - display: flex - flex-wrap: wrap diff --git a/FrontEnd/src/app/views/common/user/UserAvatar.tsx b/FrontEnd/src/app/views/common/user/UserAvatar.tsx deleted file mode 100644 index 9e822528..00000000 --- a/FrontEnd/src/app/views/common/user/UserAvatar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; - -import { getHttpUserClient } from "@/http/user"; - -export interface UserAvatarProps - extends React.ImgHTMLAttributes { - username: string; -} - -const UserAvatar: React.FC = ({ username, ...otherProps }) => { - return ( - - ); -}; - -export default UserAvatar; diff --git a/FrontEnd/src/app/views/home/TimelineListView.tsx b/FrontEnd/src/app/views/home/TimelineListView.tsx deleted file mode 100644 index 95c3c367..00000000 --- a/FrontEnd/src/app/views/home/TimelineListView.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from "react"; - -import { convertI18nText, I18nText } from "@/common"; - -import { HttpTimelineInfo } from "@/http/timeline"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -interface TimelineListItemProps { - timeline: HttpTimelineInfo; -} - -const TimelineListItem: React.FC = ({ timeline }) => { - const url = React.useMemo( - () => - timeline.name.startsWith("@") - ? `/users/${timeline.owner.username}` - : `/timelines/${timeline.name}`, - [timeline] - ); - - return ( -
- - - -
-
{timeline.title}
-
- {timeline.description} -
-
- - - -
- ); -}; - -const TimelineListArrow: React.FC = () => { - return ( -
-
- - - -
-
- - - -
-
- ); -}; - -interface TimelineListViewProps { - headerText?: I18nText; - timelines?: HttpTimelineInfo[]; -} - -const TimelineListView: React.FC = ({ - headerText, - timelines, -}) => { - const { t } = useTranslation(); - - return ( -
-
- - - -

{convertI18nText(headerText, t)}

-
- {timelines != null - ? timelines.map((t) => ) - : null} - -
- ); -}; - -export default TimelineListView; diff --git a/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx deleted file mode 100644 index aea7b4b2..00000000 --- a/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import 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 ( -
-

- 欢迎来到时间线!🎉🎉🎉 -

-

- 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。 -

-

- 如果你拥有一个账号,登陆 - 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。 -

-

- 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。 -

-

- 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在 - 关于页面找到一些信息。 -

-

- - 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅 - -

-
- ); - } else { - return ( -
-

- Welcome to Timeline!🎉🎉🎉 -

-

- This website consists of many individual timelines. Each timeline is a - list of messages just like a chat app. -

-

- If you do have an account, you can login 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. -

-

- If you don't have an account, you can view some public timelines - like highlight timelines below set by website manager. -

-

- 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 about{" "} - page. -

-

- - 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!😅 - -

-
- ); - } -}; - -export default WebsiteIntroduction; diff --git a/FrontEnd/src/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass deleted file mode 100644 index b4cda586..00000000 --- a/FrontEnd/src/app/views/home/home.sass +++ /dev/null @@ -1,29 +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 - &:hover - background: $gray-200 - -@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 diff --git a/FrontEnd/src/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx deleted file mode 100644 index 0eca23ee..00000000 --- a/FrontEnd/src/app/views/home/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; - -import { HttpTimelineInfo } from "@/http/timeline"; -import { getHttpHighlightClient } from "@/http/highlight"; - -import SearchInput from "../common/SearchInput"; -import TimelineListView from "./TimelineListView"; -import WebsiteIntroduction from "./WebsiteIntroduction"; - -const highlightTimelineMessageMap = { - loading: "home.loadingHighlightTimelines", - done: "home.loadedHighlightTimelines", - error: "home.errorHighlightTimelines", -} as const; - -const HomeV2: React.FC = () => { - const history = useHistory(); - - const [navText, setNavText] = React.useState(""); - - const [highlightTimelineState, setHighlightTimelineState] = React.useState< - "loading" | "done" | "error" - >("loading"); - const [highlightTimelines, setHighlightTimelines] = React.useState< - HttpTimelineInfo[] | undefined - >(); - - React.useEffect(() => { - if (highlightTimelineState === "loading") { - let subscribe = true; - void getHttpHighlightClient() - .list() - .then( - (data) => { - if (subscribe) { - setHighlightTimelineState("done"); - setHighlightTimelines(data); - } - }, - () => { - if (subscribe) { - setHighlightTimelineState("error"); - setHighlightTimelines(undefined); - } - } - ); - return () => { - subscribe = false; - }; - } - }, [highlightTimelineState]); - - return ( - <> - { - history.push(`search?q=${navText}`); - }} - alwaysOneline - /> - - - - ); -}; - -export default HomeV2; diff --git a/FrontEnd/src/app/views/login/index.tsx b/FrontEnd/src/app/views/login/index.tsx deleted file mode 100644 index 6adcef39..00000000 --- a/FrontEnd/src/app/views/login/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Container, Form } from "react-bootstrap"; - -import { useUser, userService } from "@/services/user"; - -import AppBar from "../common/AppBar"; -import LoadingButton from "../common/LoadingButton"; - -const LoginPage: React.FC = (_) => { - const { t } = useTranslation(); - const history = useHistory(); - const [username, setUsername] = React.useState(""); - const [usernameDirty, setUsernameDirty] = React.useState(false); - const [password, setPassword] = React.useState(""); - const [passwordDirty, setPasswordDirty] = React.useState(false); - const [rememberMe, setRememberMe] = React.useState(true); - const [process, setProcess] = React.useState(false); - const [error, setError] = React.useState(null); - - const user = useUser(); - - React.useEffect(() => { - if (user != null) { - const id = setTimeout(() => history.push("/"), 3000); - return () => { - clearTimeout(id); - }; - } - }, [history, user]); - - if (user != null) { - return ( - <> - -

{t("login.alreadyLogin")}

- - ); - } - - const submit = (): void => { - if (username === "" || password === "") { - setUsernameDirty(true); - setPasswordDirty(true); - return; - } - - setProcess(true); - userService - .login( - { - username: username, - password: password, - }, - rememberMe - ) - .then( - () => { - if (history.length === 0) { - history.push("/"); - } else { - history.goBack(); - } - }, - (e: Error) => { - setProcess(false); - setError(e.message); - } - ); - }; - - const onEnterPressInPassword: React.KeyboardEventHandler = (e) => { - if (e.key === "Enter") { - submit(); - } - }; - - return ( - -

{t("welcome")}

-
- - {t("user.username")} - { - setUsername(e.target.value); - setUsernameDirty(true); - }} - value={username} - isInvalid={usernameDirty && username === ""} - /> - {usernameDirty && username === "" && ( - - {t("login.emptyUsername")} - - )} - - - {t("user.password")} - { - setPassword(e.target.value); - setPasswordDirty(true); - }} - value={password} - onKeyDown={onEnterPressInPassword} - isInvalid={passwordDirty && password === ""} - /> - {passwordDirty && password === "" && ( - - {t("login.emptyPassword")} - - )} - - - - id="remember-me" - type="checkbox" - checked={rememberMe} - onChange={(e) => { - setRememberMe(e.currentTarget.checked); - }} - label={t("user.rememberMe")} - /> - - {error ?

{t(error)}

: null} -
- { - submit(); - e.preventDefault(); - }} - disabled={username === "" || password === "" ? true : undefined} - > - {t("user.login")} - -
-
-
- ); -}; - -export default LoginPage; diff --git a/FrontEnd/src/app/views/login/login.sass b/FrontEnd/src/app/views/login/login.sass deleted file mode 100644 index 0bf385f5..00000000 --- a/FrontEnd/src/app/views/login/login.sass +++ /dev/null @@ -1,2 +0,0 @@ -.login-container - max-width: 600px diff --git a/FrontEnd/src/app/views/search/index.tsx b/FrontEnd/src/app/views/search/index.tsx deleted file mode 100644 index 966ca666..00000000 --- a/FrontEnd/src/app/views/search/index.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Container, Row } from "react-bootstrap"; -import { useHistory, useLocation } from "react-router"; -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"; - -const TimelineSearchResultItemView: React.FC<{ - timeline: HttpTimelineInfo; -}> = ({ timeline }) => { - const link = timeline.name.startsWith("@") - ? `users/${timeline.owner.username}` - : `timelines/${timeline.name}`; - - return ( -
-

- - {timeline.title} - {timeline.name} - -

-
- - {timeline.owner.nickname} - - @{timeline.owner.username} - -
-
- ); -}; - -const SearchPage: React.FC = () => { - const { t } = useTranslation(); - - const history = useHistory(); - const location = useLocation(); - const searchParams = new URLSearchParams(location.search); - const queryParam = searchParams.get("q"); - - const [searchText, setSearchText] = React.useState(""); - const [state, setState] = React.useState< - HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error" - >("init"); - - const [forceResearchKey, setForceResearchKey] = React.useState(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 ( - - - { - if (queryParam === searchText) { - setForceResearchKey((old) => old + 1); - } else { - history.push(`/search?q=${searchText}`); - } - }} - /> - - {(() => { - switch (state) { - case "init": { - if (queryParam == null || queryParam.length === 0) { - return
{t("searchPage.input")}
; - } - break; - } - case "loading": { - return
{t("searchPage.loading")}
; - } - case "network-error": { - return
{t("error.network")}
; - } - case "error": { - return
{t("error.unknown")}
; - } - default: { - if (state.length === 0) { - return
{t("searchPage.noResult")}
; - } - return state.map((t) => ( - - )); - } - } - })()} -
- ); -}; - -export default SearchPage; diff --git a/FrontEnd/src/app/views/search/search.sass b/FrontEnd/src/app/views/search/search.sass deleted file mode 100644 index 83f297fe..00000000 --- a/FrontEnd/src/app/views/search/search.sass +++ /dev/null @@ -1,13 +0,0 @@ -.timeline-search-result-item - @extend .rounded - border: 1px solid - border-color: $gray-200 - background: $gray-100 - transition: all 0.3s - &:hover - border-color: $primary - -.timeline-search-result-item-avatar - width: 2em - height: 2em - border-radius: 50% diff --git a/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx deleted file mode 100644 index c4f6f492..00000000 --- a/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { AxiosError } from "axios"; -import { Modal, Row, Button } from "react-bootstrap"; - -import { UiLogicError } from "@/common"; - -import { useUserLoggedIn } from "@/services/user"; - -import { getHttpUserClient } from "@/http/user"; - -import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; - -export interface ChangeAvatarDialogProps { - open: boolean; - close: () => void; -} - -const ChangeAvatarDialog: React.FC = (props) => { - const { t } = useTranslation(); - - const user = useUserLoggedIn(); - - const [file, setFile] = React.useState(null); - const [fileUrl, setFileUrl] = React.useState(null); - const [clip, setClip] = React.useState(null); - const [cropImgElement, setCropImgElement] = - React.useState(null); - const [resultBlob, setResultBlob] = React.useState(null); - const [resultUrl, setResultUrl] = React.useState(null); - - const [state, setState] = React.useState< - | "select" - | "crop" - | "processcrop" - | "preview" - | "uploading" - | "success" - | "error" - >("select"); - - const [message, setMessage] = useState< - string | { type: "custom"; text: string } | null - >("settings.dialogChangeAvatar.prompt.select"); - - const trueMessage = - message == null - ? null - : typeof message === "string" - ? t(message) - : message.text; - - 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): 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(); - } - - setState("uploading"); - getHttpUserClient() - .putAvatar(user.username, resultBlob) - .then( - () => { - setState("success"); - }, - (e: unknown) => { - setState("error"); - setMessage({ type: "custom", text: (e as AxiosError).message }); - } - ); - }, [user.username, resultBlob]); - - const createPreviewRow = (): React.ReactElement => { - if (resultUrl == null) { - throw new UiLogicError(); - } - return ( - - {t("settings.dialogChangeAvatar.previewImgAlt")} - - ); - }; - - return ( - - - {t("settings.dialogChangeAvatar.title")} - - {(() => { - if (state === "select") { - return ( - <> - - {t("settings.dialogChangeAvatar.prompt.select")} - - - - - - - - - ); - } else if (state === "crop") { - if (fileUrl == null) { - throw new UiLogicError(); - } - return ( - <> - - - - - {t("settings.dialogChangeAvatar.prompt.crop")} - - - - - - - - ); - } else if (state === "processcrop") { - return ( - <> - - - {t("settings.dialogChangeAvatar.prompt.processingCrop")} - - - - - - - - ); - } else if (state === "preview") { - return ( - <> - - {createPreviewRow()} - {t("settings.dialogChangeAvatar.prompt.preview")} - - - - - - - - ); - } else if (state === "uploading") { - return ( - <> - - {createPreviewRow()} - {t("settings.dialogChangeAvatar.prompt.uploading")} - - - - ); - } else if (state === "success") { - return ( - <> - - - {t("operationDialog.success")} - - - - - - - ); - } else { - return ( - <> - - {createPreviewRow()} - {trueMessage} - - - - - - - ); - } - })()} - - ); -}; - -export default ChangeAvatarDialog; diff --git a/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx deleted file mode 100644 index 4b44cdd6..00000000 --- a/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getHttpUserClient } from "@/http/user"; -import { useUserLoggedIn } from "@/services/user"; -import React from "react"; - -import OperationDialog from "../common/OperationDialog"; - -export interface ChangeNicknameDialogProps { - open: boolean; - close: () => void; -} - -const ChangeNicknameDialog: React.FC = (props) => { - const user = useUserLoggedIn(); - - return ( - { - return getHttpUserClient().patch(user.username, { - nickname: newNickname, - }); - }} - close={props.close} - /> - ); -}; - -export default ChangeNicknameDialog; diff --git a/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx deleted file mode 100644 index 21eeeb09..00000000 --- a/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useState } from "react"; -import { useHistory } from "react-router"; - -import { userService } from "@/services/user"; - -import OperationDialog from "../common/OperationDialog"; - -export interface ChangePasswordDialogProps { - open: boolean; - close: () => void; -} - -const ChangePasswordDialog: React.FC = (props) => { - const history = useHistory(); - - const [redirect, setRedirect] = useState(false); - - return ( - { - const result: Record = {}; - 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); - }} - close={() => { - props.close(); - if (redirect) { - history.push("/login"); - } - }} - /> - ); -}; - -export default ChangePasswordDialog; diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx deleted file mode 100644 index 04a2777a..00000000 --- a/FrontEnd/src/app/views/settings/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useState } from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Container, Form, Row, Col, Button, Modal } from "react-bootstrap"; - -import { useUser, userService } from "@/services/user"; - -import ChangePasswordDialog from "./ChangePasswordDialog"; -import ChangeAvatarDialog from "./ChangeAvatarDialog"; -import ChangeNicknameDialog from "./ChangeNicknameDialog"; - -const ConfirmLogoutDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; -}> = ({ onClose, onConfirm }) => { - const { t } = useTranslation(); - - return ( - - - - {t("settings.dialogConfirmLogout.title")} - - - {t("settings.dialogConfirmLogout.prompt")} - - - - - - ); -}; - -const SettingsPage: React.FC = (_) => { - const { i18n, t } = useTranslation(); - const user = useUser(); - const history = useHistory(); - - const [dialog, setDialog] = useState< - null | "changepassword" | "changeavatar" | "changenickname" | "logout" - >(null); - - const language = i18n.language.slice(0, 2); - - return ( - <> - - {user ? ( -
-

- {t("settings.subheaders.account")} -

-
setDialog("changeavatar")} - > - {t("settings.changeAvatar")} -
-
setDialog("changenickname")} - > - {t("settings.changeNickname")} -
-
setDialog("changepassword")} - > - {t("settings.changePassword")} -
-
{ - setDialog("logout"); - }} - > - {t("settings.logout")} -
-
- ) : null} -
-

- {t("settings.subheaders.customization")} -

- - -
{t("settings.languagePrimary")}
- - {t("settings.languageSecondary")} - - - - { - void i18n.changeLanguage(e.target.value); - }} - > - - - - -
-
-
- {(() => { - switch (dialog) { - case "changepassword": - return setDialog(null)} />; - case "logout": - return ( - setDialog(null)} - onConfirm={() => { - void userService.logout().then(() => { - history.push("/"); - }); - }} - /> - ); - case "changeavatar": - return setDialog(null)} />; - case "changenickname": - return setDialog(null)} />; - default: - return null; - } - })()} - - ); -}; - -export default SettingsPage; diff --git a/FrontEnd/src/app/views/settings/settings.sass b/FrontEnd/src/app/views/settings/settings.sass deleted file mode 100644 index 8c6d24b8..00000000 --- a/FrontEnd/src/app/views/settings/settings.sass +++ /dev/null @@ -1,14 +0,0 @@ -.settings-item - padding: 0.5em 1em - transition: background 0.3s - border-bottom: 1px solid $gray-200 - - &.first - border-top: 1px solid $gray-200 - - &.clickable - cursor: pointer - - &:hover - background: $gray-300 - diff --git a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx deleted file mode 100644 index 12a3b710..00000000 --- a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -const CollapseButton: React.FC<{ - collapse: boolean; - onClick: () => void; - className?: string; - style?: React.CSSProperties; -}> = ({ collapse, onClick, className, style }) => { - return ( - - ); -}; - -export default CollapseButton; diff --git a/FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx deleted file mode 100644 index df43d8d2..00000000 --- a/FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { HubConnectionState } from "@microsoft/signalr"; -import { useTranslation } from "react-i18next"; - -export interface ConnectionStatusBadgeProps { - status: HubConnectionState; - className?: string; - style?: React.CSSProperties; -} - -const classNameMap: Record = { - Connected: "success", - Connecting: "warning", - Disconnected: "danger", - Disconnecting: "warning", - Reconnecting: "warning", -}; - -const ConnectionStatusBadge: React.FC = (props) => { - const { status, className, style } = props; - - const { t } = useTranslation(); - - return ( -
- {t(`connectionState.${status}`)} -
- ); -}; - -export default ConnectionStatusBadge; diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx deleted file mode 100644 index 685e17be..00000000 --- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { Form, Spinner } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; -import { Prompt } from "react-router"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import FlatButton from "../common/FlatButton"; -import TabPages from "../common/TabPages"; -import TimelinePostBuilder from "@/services/TimelinePostBuilder"; -import ConfirmDialog from "../common/ConfirmDialog"; - -export interface MarkdownPostEditProps { - timeline: string; - onPosted: (post: HttpTimelinePostInfo) => void; - onPostError: () => void; - onClose: () => void; - className?: string; - style?: React.CSSProperties; -} - -const MarkdownPostEdit: React.FC = ({ - timeline: timelineName, - onPosted, - onClose, - onPostError, - className, - style, -}) => { - const { t } = useTranslation(); - - const [canLeave, setCanLeave] = React.useState(true); - - const [process, setProcess] = React.useState(false); - - const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] = - React.useState(false); - - const [text, _setText] = React.useState(""); - const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [] - ); - const [previewHtml, _setPreviewHtml] = React.useState(""); - - const _builder = React.useRef(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 => { - setProcess(true); - try { - const dataList = await getBuilder().build(); - const post = await getHttpTimelineClient().postPost(timelineName, { - dataList, - }); - onPosted(post); - onClose(); - } catch (e) { - setProcess(false); - onPostError(); - } - }; - - return ( - <> - - - ) : ( - <> - { - if (canLeave) { - onClose(); - } else { - setShowLeaveConfirmDialog(true); - } - }} - > - {t("operationDialog.cancel")} - - - {t("timeline.send")} - - - ) - } - pages={[ - { - id: "text", - tabText: "edit", - page: ( - { - getBuilder().setMarkdownText(event.currentTarget.value); - }} - /> - ), - }, - { - id: "images", - tabText: "image", - page: ( -
- {images.map((image, index) => ( -
- - { - getBuilder().deleteImage(index); - }} - /> -
- ))} - ) => { - const { files } = event.currentTarget; - if (files != null && files.length !== 0) { - getBuilder().appendImage(files[0]); - } - }} - disabled={process} - /> -
- ), - }, - { - id: "preview", - tabText: "preview", - page: ( -
- ), - }, - ]} - /> - {showLeaveConfirmDialog && ( - setShowLeaveConfirmDialog(false)} - onConfirm={onClose} - title="timeline.dropDraft" - body="timeline.confirmLeave" - /> - )} - - ); -}; - -export default MarkdownPostEdit; diff --git a/FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx deleted file mode 100644 index 001e52d7..00000000 --- a/FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import OperationDialog from "../common/OperationDialog"; - -function PostPropertyChangeDialog(props: { - onClose: () => void; - post: HttpTimelinePostInfo; - onSuccess: (post: HttpTimelinePostInfo) => void; -}): React.ReactElement | null { - const { onClose, post, onSuccess } = props; - - return ( - { - return getHttpTimelineClient().patchPost(post.timelineName, post.id, { - time: time === "" ? undefined : new Date(time).toISOString(), - }); - }} - onSuccessAndClose={onSuccess} - /> - ); -} - -export default PostPropertyChangeDialog; diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx deleted file mode 100644 index 589382b0..00000000 --- a/FrontEnd/src/app/views/timeline-common/Timeline.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from "react"; -import { HubConnectionState } from "@microsoft/signalr"; - -import { - HttpForbiddenError, - HttpNetworkError, - HttpNotFoundError, -} from "@/http/common"; -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import { getTimelinePostUpdate$ } from "@/services/timeline"; - -import TimelinePagedPostListView from "./TimelinePagedPostListView"; -import TimelineTop from "./TimelineTop"; -import TimelineLoading from "./TimelineLoading"; - -export interface TimelineProps { - className?: string; - style?: React.CSSProperties; - timelineName?: string; - reloadKey: number; - onReload: () => void; - onConnectionStateChanged?: (state: HubConnectionState) => void; -} - -const Timeline: React.FC = (props) => { - const { timelineName, className, style, reloadKey } = props; - - const [state, setState] = React.useState< - "loading" | "loaded" | "offline" | "notexist" | "forbid" | "error" - >("loading"); - const [posts, setPosts] = React.useState([]); - - React.useEffect(() => { - setState("loading"); - setPosts([]); - }, [timelineName]); - - const onReload = React.useRef<() => void>(props.onReload); - - React.useEffect(() => { - onReload.current = props.onReload; - }, [props.onReload]); - - const onConnectionStateChanged = React.useRef< - ((state: HubConnectionState) => void) | null - >(null); - - React.useEffect(() => { - onConnectionStateChanged.current = props.onConnectionStateChanged ?? null; - }, [props.onConnectionStateChanged]); - - React.useEffect(() => { - if (timelineName != null && state === "loaded") { - const timelinePostUpdate$ = getTimelinePostUpdate$(timelineName); - const subscription = timelinePostUpdate$.subscribe( - ({ update, state }) => { - if (update) { - onReload.current(); - } - onConnectionStateChanged.current?.(state); - } - ); - return () => { - subscription.unsubscribe(); - }; - } - }, [timelineName, state]); - - React.useEffect(() => { - if (timelineName != null) { - let subscribe = true; - - void getHttpTimelineClient() - .listPost(timelineName) - .then( - (data) => { - if (subscribe) { - setState("loaded"); - setPosts(data); - } - }, - (error) => { - if (error instanceof HttpNetworkError) { - setState("offline"); - } else if (error instanceof HttpForbiddenError) { - setState("forbid"); - } else if (error instanceof HttpNotFoundError) { - setState("notexist"); - } else { - console.error(error); - setState("error"); - } - } - ); - - return () => { - subscribe = false; - }; - } - }, [timelineName, reloadKey]); - - switch (state) { - case "loading": - return ; - case "offline": - return ( -
- Offline. -
- ); - case "notexist": - return ( -
- Not exist. -
- ); - case "forbid": - return ( -
- Forbid. -
- ); - case "error": - return ( -
- Error. -
- ); - default: - return ( - <> - - - - ); - } -}; - -export default Timeline; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx b/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx deleted file mode 100644 index 80968ee2..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; -import TimelineLine from "./TimelineLine"; - -export interface TimelineDateItemProps { - date: Date; -} - -const TimelineDateLabel: React.FC = ({ date }) => { - return ( -
- -
- {date.toLocaleDateString()} -
-
- ); -}; - -export default TimelineDateLabel; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx b/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx deleted file mode 100644 index 0a828b32..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import 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 = ({ - startSegmentLength, - center, - current, - className, - style, -}) => { - return ( -
-
- {center !== "none" ? ( -
-
- {center === "loading" ? ( - - - - ) : null} -
- ) : null} - {center !== "loading" ?
: null} - {current &&
} -
- ); -}; - -export default TimelineLine; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx b/FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx deleted file mode 100644 index fc42f4b4..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; - -import TimelineTop from "./TimelineTop"; - -const TimelineLoading: React.FC = () => { - return ( - - ); -}; - -export default TimelineLoading; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx deleted file mode 100644 index 299d6a53..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap"; - -import { convertI18nText, I18nText } from "@/common"; - -import { HttpUser } from "@/http/user"; -import { getHttpSearchClient } from "@/http/search"; - -import SearchInput from "../common/SearchInput"; -import UserAvatar from "../common/user/UserAvatar"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; - -const TimelineMemberItem: React.FC<{ - user: HttpUser; - add?: boolean; - onAction?: (username: string) => void; -}> = ({ user, add, onAction }) => { - const { t } = useTranslation(); - - return ( - - - - - - - {user.nickname} - - {"@" + user.username} - - - {onAction ? ( - - - - ) : null} - - - ); -}; - -const TimelineMemberUserSearch: React.FC<{ - timeline: HttpTimelineInfo; - onChange: () => void; -}> = ({ timeline, onChange }) => { - const { t } = useTranslation(); - - const [userSearchText, setUserSearchText] = useState(""); - const [userSearchState, setUserSearchState] = useState< - | { - type: "users"; - data: HttpUser[]; - } - | { type: "error"; data: I18nText } - | { type: "loading" } - | { type: "init" } - >({ type: "init" }); - - return ( - <> - { - setUserSearchText(v); - }} - loading={userSearchState.type === "loading"} - onButtonClick={() => { - if (userSearchText === "") { - setUserSearchState({ - type: "error", - data: "login.emptyUsername", - }); - return; - } - setUserSearchState({ type: "loading" }); - getHttpSearchClient() - .searchUsers(userSearchText) - .then( - (users) => { - users = users.filter( - (user) => - timeline.members.findIndex( - (m) => m.username === user.username - ) === -1 && timeline.owner.username !== user.username - ); - setUserSearchState({ type: "users", data: users }); - }, - (e) => { - setUserSearchState({ - type: "error", - data: { type: "custom", value: String(e) }, - }); - } - ); - }} - /> - {(() => { - if (userSearchState.type === "users") { - const users = userSearchState.data; - if (users.length === 0) { - return
{t("timeline.member.noUserAvailableToAdd")}
; - } else { - return ( - - {users.map((user) => ( - { - void getHttpTimelineClient() - .memberPut(timeline.name, user.username) - .then(() => { - setUserSearchText(""); - setUserSearchState({ type: "init" }); - onChange(); - }); - }} - /> - ))} - - ); - } - } else if (userSearchState.type === "error") { - return ( -
- {convertI18nText(userSearchState.data, t)} -
- ); - } - })()} - - ); -}; - -export interface TimelineMemberProps { - timeline: HttpTimelineInfo; - onChange: () => void; -} - -const TimelineMember: React.FC = (props) => { - const { timeline, onChange } = props; - const members = [timeline.owner, ...timeline.members]; - - return ( - - - {members.map((member, index) => ( - { - void getHttpTimelineClient() - .memberDelete(timeline.name, member.username) - .then(onChange); - } - : undefined - } - /> - ))} - - {timeline.manageable ? ( - - ) : null} - - ); -}; - -export default TimelineMember; - -export interface TimelineMemberDialogProps extends TimelineMemberProps { - open: boolean; - onClose: () => void; -} - -export const TimelineMemberDialog: React.FC = ( - props -) => { - return ( - - - - ); -}; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx deleted file mode 100644 index 623d643f..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { getHttpHighlightClient } from "@/http/highlight"; -import { getHttpBookmarkClient } from "@/http/bookmark"; - -import { useUser } from "@/services/user"; -import { pushAlert } from "@/services/alert"; -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; - -import { useIsSmallScreen } from "@/utilities/mediaQuery"; - -import { TimelinePageCardProps } from "./TimelinePageTemplate"; - -import CollapseButton from "./CollapseButton"; -import { TimelineMemberDialog } from "./TimelineMember"; -import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; -import ConnectionStatusBadge from "./ConnectionStatusBadge"; -import { MenuItems, PopupMenu } from "../common/Menu"; -import FullPage from "../common/FullPage"; - -export interface TimelineCardTemplateProps extends TimelinePageCardProps { - infoArea: React.ReactElement; - manageItems?: MenuItems; - dialog: string | "property" | "member" | null; - setDialog: (dialog: "property" | "member" | null) => void; -} - -const TimelinePageCardTemplate: React.FC = ({ - timeline, - collapse, - toggleCollapse, - infoArea, - manageItems, - connectionStatus, - onReload, - className, - dialog, - setDialog, -}) => { - const { t } = useTranslation(); - - const isSmallScreen = useIsSmallScreen(); - - const user = useUser(); - - const content = ( - <> - {infoArea} -

{timeline.description}

- - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - -
- { - getHttpHighlightClient() - [timeline.isHighlight ? "delete" : "put"](timeline.name) - .then(onReload, () => { - pushAlert({ - message: timeline.isHighlight - ? "timeline.removeHighlightFail" - : "timeline.addHighlightFail", - type: "danger", - }); - }); - } - : undefined - } - /> - {user != null ? ( - { - getHttpBookmarkClient() - [timeline.isBookmark ? "delete" : "put"](timeline.name) - .then(onReload, () => { - pushAlert({ - message: timeline.isBookmark - ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", - type: "danger", - }); - }); - }} - /> - ) : null} - setDialog("member")} - /> - {manageItems != null ? ( - - - - ) : null} -
- - ); - - return ( - <> -
-
- - -
- {isSmallScreen ? ( - - {content} - - ) : ( -
{content}
- )} -
- {(() => { - if (dialog === "member") { - return ( - setDialog(null)} - open - onChange={onReload} - /> - ); - } else if (dialog === "property") { - return ( - setDialog(null)} - open - onChange={onReload} - /> - ); - } - })()} - - ); -}; - -export default TimelinePageCardTemplate; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx deleted file mode 100644 index 658ce502..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Container } from "react-bootstrap"; -import { HubConnectionState } from "@microsoft/signalr"; - -import { HttpNetworkError, HttpNotFoundError } from "@/http/common"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; - -import { getAlertHost } from "@/services/alert"; - -import Timeline from "./Timeline"; -import TimelinePostEdit from "./TimelinePostEdit"; - -import useReverseScrollPositionRemember from "@/utilities/useReverseScrollPositionRemember"; -import { generatePalette, setPalette } from "@/palette"; - -export interface TimelinePageCardProps { - timeline: HttpTimelineInfo; - collapse: boolean; - toggleCollapse: () => void; - connectionStatus: HubConnectionState; - className?: string; - onReload: () => void; -} - -export interface TimelinePageTemplateProps { - timelineName: string; - notFoundI18nKey: string; - reloadKey: number; - onReload: () => void; - CardComponent: React.ComponentType; -} - -const TimelinePageTemplate: React.FC = (props) => { - const { timelineName, reloadKey, onReload, CardComponent } = props; - - const { t } = useTranslation(); - - const [state, setState] = React.useState< - "loading" | "done" | "offline" | "notexist" | "error" - >("loading"); - const [timeline, setTimeline] = React.useState(null); - - const [connectionStatus, setConnectionStatus] = - React.useState(HubConnectionState.Connecting); - - useReverseScrollPositionRemember(); - - React.useEffect(() => { - setState("loading"); - setTimeline(null); - }, [timelineName]); - - React.useEffect(() => { - let subscribe = true; - void getHttpTimelineClient() - .getTimeline(timelineName) - .then( - (data) => { - if (subscribe) { - setState("done"); - setTimeline(data); - } - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setState("offline"); - } else if (error instanceof HttpNotFoundError) { - setState("notexist"); - } else { - console.error(error); - setState("error"); - } - setTimeline(null); - } - } - ); - return () => { - subscribe = false; - }; - }, [timelineName, reloadKey]); - - React.useEffect(() => { - if (timeline != null && timeline.color != null) { - return setPalette(generatePalette({ primary: timeline.color })); - } - }, [timeline]); - - const [bottomSpaceHeight, setBottomSpaceHeight] = React.useState(0); - - const [timelineReloadKey, setTimelineReloadKey] = React.useState(0); - - const reloadTimeline = (): void => { - setTimelineReloadKey((old) => old + 1); - }; - - const onPostEditHeightChange = React.useCallback((height: number): void => { - setBottomSpaceHeight(height); - if (height === 0) { - const alertHost = getAlertHost(); - if (alertHost != null) { - alertHost.style.removeProperty("margin-bottom"); - } - } else { - const alertHost = getAlertHost(); - if (alertHost != null) { - alertHost.style.marginBottom = `${height}px`; - } - } - }, []); - - const cardCollapseLocalStorageKey = `timeline.${timelineName}.cardCollapse`; - - const [cardCollapse, setCardCollapse] = React.useState(true); - - React.useEffect(() => { - const savedCollapse = window.localStorage.getItem( - cardCollapseLocalStorageKey - ); - setCardCollapse(savedCollapse == null ? true : savedCollapse === "true"); - }, [cardCollapseLocalStorageKey]); - - const toggleCardCollapse = (): void => { - const newState = !cardCollapse; - setCardCollapse(newState); - window.localStorage.setItem( - cardCollapseLocalStorageKey, - newState.toString() - ); - }; - - return ( - <> - {timeline != null ? ( - - ) : null} - - {(() => { - if (state === "offline") { - // TODO: i18n - return

Offline!

; - } else if (state === "notexist") { - return

{t(props.notFoundI18nKey)}

; - } else if (state === "error") { - // TODO: i18n - return

Error!

; - } else { - return ( - - ); - } - })()} -
- {timeline != null && timeline.postable ? ( - <> -
- - - ) : null} - - ); -}; - -export default TimelinePageTemplate; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx deleted file mode 100644 index 37f02a82..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; - -import { HttpTimelinePostInfo } from "@/http/timeline"; - -import useScrollToTop from "@/utilities/useScrollToTop"; - -import TimelinePostListView from "./TimelinePostListView"; - -export interface TimelinePagedPostListViewProps { - className?: string; - style?: React.CSSProperties; - posts: HttpTimelinePostInfo[]; - onReload: () => void; -} - -const TimelinePagedPostListView: React.FC = ( - props -) => { - const { className, style, posts, onReload } = props; - - const [lastViewCount, setLastViewCount] = React.useState(10); - - const viewingPosts = React.useMemo(() => { - return lastViewCount >= posts.length - ? posts.slice() - : posts.slice(-lastViewCount); - }, [posts, lastViewCount]); - - useScrollToTop(() => { - setLastViewCount(lastViewCount + 10); - }, lastViewCount < posts.length); - - return ( - - ); -}; - -export default TimelinePagedPostListView; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx deleted file mode 100644 index 607b72c9..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { Remarkable } from "remarkable"; - -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 = (props) => { - const { post, className, style } = props; - - const [text, setText] = React.useState(null); - const [error, setError] = React.useState<"offline" | "error" | null>(null); - - const [reloadKey, setReloadKey] = React.useState(0); - - React.useEffect(() => { - let subscribe = true; - - setText(null); - setError(null); - - void getHttpTimelineClient() - .getPostDataAsString(post.timelineName, post.id) - .then( - (data) => { - if (subscribe) setText(data); - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else { - setError("error"); - } - } - } - ); - - return () => { - subscribe = false; - }; - }, [post.timelineName, post.id, reloadKey]); - - if (error != null) { - return ( - setReloadKey(reloadKey + 1)} - /> - ); - } else if (text == null) { - return ; - } else { - return ( -
- {text} -
- ); - } -}; - -const ImageView: React.FC = (props) => { - const { post, className, style } = props; - - useUser(); - - return ( - - ); -}; - -const MarkdownView: React.FC = (props) => { - const { post, className, style } = props; - - const _remarkable = React.useRef(); - - const getRemarkable = (): Remarkable => { - if (_remarkable.current) { - return _remarkable.current; - } else { - _remarkable.current = new Remarkable(); - return _remarkable.current; - } - }; - - const [markdown, setMarkdown] = React.useState(null); - const [error, setError] = React.useState<"offline" | "error" | null>(null); - - const [reloadKey, setReloadKey] = React.useState(0); - - React.useEffect(() => { - let subscribe = true; - - setMarkdown(null); - setError(null); - - void getHttpTimelineClient() - .getPostDataAsString(post.timelineName, post.id) - .then( - (data) => { - if (subscribe) setMarkdown(data); - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else { - setError("error"); - } - } - } - ); - - return () => { - subscribe = false; - }; - }, [post.timelineName, post.id, reloadKey]); - - const markdownHtml = React.useMemo(() => { - if (markdown == null) return null; - return getRemarkable().render(markdown); - }, [markdown]); - - if (error != null) { - return ( - setReloadKey(reloadKey + 1)} - /> - ); - } else if (markdown == null) { - return ; - } else { - if (markdownHtml == null) { - throw new UiLogicError("Markdown is not null but markdown html is."); - } - return ( -
- ); - } -}; - -export interface TimelinePostContentViewProps { - post: HttpTimelinePostInfo; - className?: string; - style?: React.CSSProperties; -} - -const viewMap: Record> = { - "text/plain": TextView, - "text/markdown": MarkdownView, - "image/png": ImageView, - "image/jpeg": ImageView, - "image/gif": ImageView, - "image/webp": ImageView, -}; - -const TimelinePostContentView: React.FC = ( - props -) => { - const { post, className, style } = props; - - const type = post.dataList[0].kind; - - if (type in viewMap) { - const View = viewMap[type]; - return ; - } else { - // TODO: i18n - console.error("Unknown post type", post); - return
Error, unknown post type!
; - } -}; - -export default TimelinePostContentView; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx deleted file mode 100644 index b2c7a470..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { Modal, Button } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -const TimelinePostDeleteConfirmDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; -}> = ({ onClose, onConfirm }) => { - const { t } = useTranslation(); - - return ( - - - - {t("timeline.post.deleteDialog.title")} - - - {t("timeline.post.deleteDialog.prompt")} - - - - - - ); -}; - -export default TimelinePostDeleteConfirmDialog; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx deleted file mode 100644 index 5f3f0345..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; -import { Row, Col, Form } from "react-bootstrap"; - -import { UiLogicError } from "@/common"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePostInfo, - HttpTimelinePostPostRequestData, -} from "@/http/timeline"; - -import { pushAlert } from "@/services/alert"; -import { base64 } from "@/http/common"; - -import BlobImage from "../common/BlobImage"; -import LoadingButton from "../common/LoadingButton"; -import { PopupMenu } from "../common/Menu"; -import MarkdownPostEdit from "./MarkdownPostEdit"; - -interface TimelinePostEditTextProps { - text: string; - disabled: boolean; - onChange: (text: string) => void; - className?: string; - style?: React.CSSProperties; -} - -const TimelinePostEditText: React.FC = (props) => { - const { text, disabled, onChange, className, style } = props; - - return ( - { - onChange(event.target.value); - }} - className={className} - style={style} - /> - ); -}; - -interface TimelinePostEditImageProps { - onSelect: (file: File | null) => void; - disabled: boolean; -} - -const TimelinePostEditImage: React.FC = (props) => { - const { onSelect, disabled } = props; - - const { t } = useTranslation(); - - const [file, setFile] = React.useState(null); - const [error, setError] = React.useState(false); - - const onInputChange: React.ChangeEventHandler = (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 ( - <> - - {file != null && !error && ( - onSelect(file)} - onError={() => { - onSelect(null); - setError(true); - }} - /> - )} - {error ?
{t("loadImageError")}
: null} - - ); -}; - -type PostKind = "text" | "markdown" | "image"; - -const postKindIconClassNameMap: Record = { - text: "bi-fonts", - markdown: "bi-markdown", - image: "bi-image", -}; - -export interface TimelinePostEditProps { - className?: string; - timeline: HttpTimelineInfo; - onPosted: (newPost: HttpTimelinePostInfo) => void; - onHeightChange?: (height: number) => void; -} - -const TimelinePostEdit: React.FC = (props) => { - const { timeline, onHeightChange, className, onPosted } = props; - - const { t } = useTranslation(); - - const [process, setProcess] = React.useState(false); - - const [kind, setKind] = React.useState>("text"); - const [showMarkdown, setShowMarkdown] = React.useState(false); - - const [text, setText] = React.useState(""); - const [image, setImage] = React.useState(null); - - const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`; - - React.useEffect(() => { - setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); - }, [draftTextLocalStorageKey]); - - const canSend = - (kind === "text" && text.length !== 0) || - (kind === "image" && image != null); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const containerRef = React.useRef(null!); - - const notifyHeightChange = (): void => { - if (onHeightChange) { - onHeightChange(containerRef.current.clientHeight); - } - }; - - React.useEffect(() => { - notifyHeightChange(); - return () => { - if (onHeightChange) { - onHeightChange(0); - } - }; - }); - - const onPostError = (): void => { - pushAlert({ - type: "danger", - message: "timeline.sendPostFailed", - }); - }; - - const onSend = async (): Promise => { - 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.name, { - dataList: [requestData], - }) - .then( - (data) => { - if (kind === "text") { - setText(""); - window.localStorage.removeItem(draftTextLocalStorageKey); - } - setProcess(false); - setKind("text"); - onPosted(data); - }, - (_) => { - setProcess(false); - onPostError(); - } - ); - }; - - return ( -
- {showMarkdown ? ( - setShowMarkdown(false)} - timeline={timeline.name} - onPosted={onPosted} - onPostError={onPostError} - /> - ) : ( - - - {(() => { - if (kind === "text") { - return ( - { - setText(t); - window.localStorage.setItem(draftTextLocalStorageKey, t); - }} - /> - ); - } else if (kind === "image") { - return ( - - ); - } - })()} - - -
- ({ - type: "button", - text: `timeline.post.type.${kind}`, - iconClassName: postKindIconClassNameMap[kind], - onClick: () => { - if (kind === "markdown") { - setShowMarkdown(true); - } else { - setKind(kind); - } - }, - }))} - > - - -
- - {t("timeline.send")} - - -
- )} -
- ); -}; - -export default TimelinePostEdit; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx deleted file mode 100644 index ba204b72..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { Fragment } from "react"; -import classnames from "classnames"; - -import { HttpTimelinePostInfo } from "@/http/timeline"; - -import TimelinePostView from "./TimelinePostView"; -import TimelineDateLabel from "./TimelineDateLabel"; - -function dateEqual(left: Date, right: Date): boolean { - return ( - left.getDate() == right.getDate() && - left.getMonth() == right.getMonth() && - left.getFullYear() == right.getFullYear() - ); -} - -export interface TimelinePostListViewProps { - className?: string; - style?: React.CSSProperties; - posts: HttpTimelinePostInfo[]; - onReload: () => void; -} - -const TimelinePostListView: React.FC = (props) => { - const { className, style, posts, onReload } = props; - - const groupedPosts = React.useMemo< - { - date: Date; - posts: (HttpTimelinePostInfo & { index: number })[]; - }[] - >(() => { - const result: { - date: Date; - posts: (HttpTimelinePostInfo & { index: number })[]; - }[] = []; - let index = 0; - for (const post of posts) { - const time = new Date(post.time); - if (result.length === 0) { - result.push({ date: time, posts: [{ ...post, index }] }); - } else { - const lastGroup = result[result.length - 1]; - if (dateEqual(lastGroup.date, time)) { - lastGroup.posts.push({ ...post, index }); - } else { - result.push({ date: time, posts: [{ ...post, index }] }); - } - } - index++; - } - return result; - }, [posts]); - - return ( -
- {groupedPosts.map((group) => { - return ( - - - {group.posts.map((post) => { - return ( - - ); - })} - - ); - })} -
- ); -}; - -export default TimelinePostListView; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx deleted file mode 100644 index f7b81478..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from "react"; -import classnames from "classnames"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import { pushAlert } from "@/services/alert"; - -import UserAvatar from "../common/user/UserAvatar"; -import TimelineLine from "./TimelineLine"; -import TimelinePostContentView from "./TimelinePostContentView"; -import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog"; -import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; - -export interface TimelinePostViewProps { - post: HttpTimelinePostInfo; - current?: boolean; - className?: string; - style?: React.CSSProperties; - cardStyle?: React.CSSProperties; - onChanged: (post: HttpTimelinePostInfo) => void; - onDeleted: () => void; -} - -const TimelinePostView: React.FC = (props) => { - const { post, className, style, cardStyle, onChanged, onDeleted } = props; - const current = props.current === true; - - const { t } = useTranslation(); - - const [operationMaskVisible, setOperationMaskVisible] = - React.useState(false); - const [dialog, setDialog] = React.useState< - "delete" | "changeproperty" | null - >(null); - - const cardRef = React.useRef(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 ( -
- -
- {post.editable ? ( - { - setOperationMaskVisible(true); - e.stopPropagation(); - }} - /> - ) : null} -
- - - - - - {post.author.nickname} - - {new Date(post.time).toLocaleTimeString()} - - - -
-
- -
- {operationMaskVisible ? ( -
{ - setOperationMaskVisible(false); - }} - > - { - setDialog("changeproperty"); - e.stopPropagation(); - }} - > - {t("changeProperty")} - - { - setDialog("delete"); - e.stopPropagation(); - }} - > - {t("delete")} - -
- ) : null} -
- {dialog === "delete" ? ( - { - setDialog(null); - setOperationMaskVisible(false); - }} - onConfirm={() => { - void getHttpTimelineClient() - .deletePost(post.timelineName, post.id) - .then(onDeleted, () => { - pushAlert({ - type: "danger", - message: "timeline.deletePostFailed", - }); - }); - }} - /> - ) : dialog === "changeproperty" ? ( - { - setDialog(null); - setOperationMaskVisible(false); - }} - post={post} - onSuccess={onChanged} - /> - ) : null} -
- ); -}; - -export default TimelinePostView; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index 70f72025..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React from "react"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePatchRequest, - kTimelineVisibilities, - TimelineVisibility, -} from "@/http/timeline"; - -import OperationDialog from "../common/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 = - (props) => { - const { timeline, onChange } = props; - - return ( - ({ - 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} - close={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.name, req) - .then(onChange); - }} - /> - ); - }; - -export default TimelinePropertyChangeDialog; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx deleted file mode 100644 index dabbdf1e..00000000 --- a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -import TimelineLine, { TimelineLineProps } from "./TimelineLine"; - -export interface TimelineTopProps { - height?: number | string; - lineProps?: TimelineLineProps; - className?: string; - style?: React.CSSProperties; -} - -const TimelineTop: React.FC = (props) => { - const { height, style, className } = props; - const lineProps = props.lineProps ?? { center: "none" }; - - return ( -
- -
- ); -}; - -export default TimelineTop; diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass deleted file mode 100644 index 4400fead..00000000 --- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass +++ /dev/null @@ -1,259 +0,0 @@ -@use 'sass:color' - -.timeline - z-index: 0 - position: relative - width: 100% - overflow-wrap: break-word - animation: 1s timeline-enter - -$timeline-line-width: 7px -$timeline-line-node-radius: 18px -$timeline-line-color: var(--tl-primary-color) -$timeline-line-color-current: var(--tl-primary-enhance-color) - -@keyframes timeline-line-node-noncurrent - to - box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color) - -@keyframes timeline-line-node-current - to - box-shadow: 0 0 20px 3px var(--tl-primary-enhance-lighter-color) - -@keyframes timeline-line-node-loading - to - box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color) - -@keyframes timeline-line-node-loading-edge - from - transform: rotate(0turn) - to - transform: rotate(1turn) - -@keyframes timeline-enter - from - transform: translate(0, -100vh) - -@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 - - @include media-breakpoint-down(sm) - left: 1em - - .segment - width: $timeline-line-width - background: $timeline-line-color - - &.start - height: 1.8em - flex: 0 0 auto - - &.end - flex: 1 1 auto - - &.current-end - height: 2em - flex: 0 0 auto - background: linear-gradient($timeline-line-color-current, white) - - .node-container - flex: 0 0 auto - position: relative - width: $timeline-line-node-radius - height: $timeline-line-node-radius - - .node - width: $timeline-line-node-radius + 2 - height: $timeline-line-node-radius + 2 - position: absolute - background: $timeline-line-color - left: -1px - top: -1px - border-radius: 50% - box-sizing: border-box - z-index: 1 - animation: 1s infinite alternate - animation-name: timeline-line-node-noncurrent - - .node-loading-edge - color: $timeline-line-color - width: $timeline-line-node-radius + 20 - height: $timeline-line-node-radius + 20 - position: absolute - left: -10px - top: -10px - box-sizing: border-box - z-index: 2 - animation: 1.5s linear infinite timeline-line-node-loading-edge - - &.current - .segment - &.start - background: linear-gradient($timeline-line-color, $timeline-line-color-current) - &.end - background: $timeline-line-color-current - .node - background: $timeline-line-color-current - animation-name: timeline-line-node-current - - &.loading - .node - background: $timeline-line-color - animation-name: timeline-line-node-loading - -.timeline-item.current - padding-bottom: 2.5em - -.timeline-top - position: relative - text-align: right - -.timeline-item - position: relative - padding: 0.5em - -.timeline-item-card - @extend .cru-card - position: relative - padding: 0.3em 0.5em 1em 4em - transition: background 0.5s, padding-left 0.5s - animation: 0.6s forwards - opacity: 0 - - @include media-breakpoint-down(sm) - padding-left: 3em - -.timeline-item-header - display: flex - align-items: center - @extend .my-2 - -.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-edit-image - max-width: 100px - max-height: 100px - -.mask - background: change-color($color: white, $alpha: 0.8) - z-index: 100 - -.timeline-sync-state-badge - font-size: 0.8em - padding: 3px 8px - border-radius: 5px - background: #e8fbff - -.timeline-sync-state-badge-pin - display: inline-block - width: 0.4em - height: 0.4em - border-radius: 50% - vertical-align: middle - margin-right: 0.6em - -.timeline-template-card - position: fixed - top: 56px - right: 0 - margin: 0.5em - -.timeline-markdown-post-edit-page - overflow: scroll - 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 - -.connection-status-badge - font-size: 0.8em - border-radius: 5px - padding: 0.1em 1em - background-color: rgb(234 242 255) - - &::before - width: 10px - height: 10px - border-radius: 50% - display: inline-block - content: '' - margin-right: 0.6em - - &.success - color: #006100 - &::before - background-color: #006100 - - &.warning - color: #e4a700 - &::before - background-color: #e4a700 - - &.danger - color: #fd1616 - &::before - background-color: #fd1616 diff --git a/FrontEnd/src/app/views/timeline/TimelineCard.tsx b/FrontEnd/src/app/views/timeline/TimelineCard.tsx deleted file mode 100644 index e031b565..00000000 --- a/FrontEnd/src/app/views/timeline/TimelineCard.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; - -import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate"; -import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate"; - -import UserAvatar from "../common/user/UserAvatar"; -import TimelineDeleteDialog from "./TimelineDeleteDialog"; - -const TimelineCard: React.FC = (props) => { - const { timeline } = props; - - const [dialog, setDialog] = React.useState< - "member" | "property" | "delete" | null - >(null); - - return ( - <> - -

- {timeline.title} - {timeline.name} -

-
- - {timeline.owner.nickname} - - @{timeline.owner.username} - -
- - } - manageItems={ - timeline.manageable - ? [ - { - type: "button", - text: "timeline.manageItem.property", - onClick: () => setDialog("property"), - }, - { type: "divider" }, - { - type: "button", - onClick: () => setDialog("delete"), - color: "danger", - text: "timeline.manageItem.delete", - }, - ] - : undefined - } - dialog={dialog} - setDialog={setDialog} - {...props} - /> - {(() => { - if (dialog === "delete") { - return ( - setDialog(null)} - /> - ); - } - })()} - - ); -}; - -export default TimelineCard; diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index dbca62ca..00000000 --- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { Trans } from "react-i18next"; - -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; - -import OperationDialog from "../common/OperationDialog"; - -interface TimelineDeleteDialog { - timeline: HttpTimelineInfo; - open: boolean; - close: () => void; -} - -const TimelineDeleteDialog: React.FC = (props) => { - const history = useHistory(); - - const { timeline } = props; - - return ( - { - return ( - - 0{{ name }}2 - - ); - }} - inputScheme={[ - { - type: "text", - }, - ]} - inputValidator={([value]) => { - if (value !== timeline.name) { - return { 0: "timeline.deleteDialog.notMatch" }; - } else { - return null; - } - }} - onProcess={() => { - return getHttpTimelineClient().deleteTimeline(timeline.name); - }} - onSuccessAndClose={() => { - history.replace("/"); - }} - /> - ); -}; - -export default TimelineDeleteDialog; diff --git a/FrontEnd/src/app/views/timeline/index.tsx b/FrontEnd/src/app/views/timeline/index.tsx deleted file mode 100644 index c5bfd7ab..00000000 --- a/FrontEnd/src/app/views/timeline/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { useParams } from "react-router"; - -import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; -import TimelineCard from "./TimelineCard"; - -const TimelinePage: React.FC = () => { - const { name } = useParams<{ name: string }>(); - - const [reloadKey, setReloadKey] = React.useState(0); - - return ( - setReloadKey(reloadKey + 1)} - /> - ); -}; - -export default TimelinePage; diff --git a/FrontEnd/src/app/views/timeline/timeline.sass b/FrontEnd/src/app/views/timeline/timeline.sass deleted file mode 100644 index e69de29b..00000000 diff --git a/FrontEnd/src/app/views/user/UserCard.tsx b/FrontEnd/src/app/views/user/UserCard.tsx deleted file mode 100644 index e7e4252e..00000000 --- a/FrontEnd/src/app/views/user/UserCard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; - -import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate"; -import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate"; -import UserAvatar from "../common/user/UserAvatar"; - -const UserCard: React.FC = (props) => { - const { timeline } = props; - - const [dialog, setDialog] = React.useState<"member" | "property" | null>( - null - ); - - return ( - <> - -

- {timeline.title} - {timeline.name} -

-
- - {timeline.owner.nickname} -
- - } - manageItems={ - timeline.manageable - ? [ - { - type: "button", - text: "timeline.manageItem.property", - onClick: () => setDialog("property"), - }, - ] - : undefined - } - dialog={dialog} - setDialog={setDialog} - {...props} - /> - - ); -}; - -export default UserCard; diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx deleted file mode 100644 index 57454d0d..00000000 --- a/FrontEnd/src/app/views/user/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; -import { useParams } from "react-router"; - -import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; -import UserCard from "./UserCard"; - -const UserPage: React.FC = () => { - const { username } = useParams<{ username: string }>(); - - const [reloadKey, setReloadKey] = React.useState(0); - - let dialogElement: React.ReactElement | undefined; - - return ( - <> - setReloadKey(reloadKey + 1)} - CardComponent={UserCard} - /> - {dialogElement} - - ); -}; - -export default UserPage; diff --git a/FrontEnd/src/app/views/user/user.sass b/FrontEnd/src/app/views/user/user.sass deleted file mode 100644 index 63a28e05..00000000 --- a/FrontEnd/src/app/views/user/user.sass +++ /dev/null @@ -1,7 +0,0 @@ -.change-avatar-cropper-row - max-height: 400px - -.change-avatar-img - min-width: 50% - max-width: 100% - max-height: 400px diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts new file mode 100644 index 00000000..1a4f6dda --- /dev/null +++ b/FrontEnd/src/common.ts @@ -0,0 +1,39 @@ +import { TFunction } from "i18next"; + +export type BootstrapThemeColor = + | "primary" + | "secondary" + | "success" + | "danger" + | "warning" + | "info"; + +// This error is thrown when ui goes wrong with bad logic. +// Such as a variable should not be null, but it does. +// This error should never occur. If it does, it indicates there is some logic bug in codes. +export class UiLogicError extends Error {} + +export type I18nText = + | string + | { type: "custom"; value: string } + | { type: "i18n"; value: string }; + +export function convertI18nText(text: I18nText, t: TFunction): string; +export function convertI18nText( + text: I18nText | null | undefined, + t: TFunction +): string | null; +export function convertI18nText( + text: I18nText | null | undefined, + t: TFunction +): string | null { + if (text == null) { + return null; + } else if (typeof text === "string") { + return t(text); + } else if (text.type === "i18n") { + return t(text.value); + } else { + return text.value; + } +} diff --git a/FrontEnd/src/http/bookmark.ts b/FrontEnd/src/http/bookmark.ts new file mode 100644 index 00000000..3e5be229 --- /dev/null +++ b/FrontEnd/src/http/bookmark.ts @@ -0,0 +1,49 @@ +import { axios, apiBaseUrl, extractResponseData } from "./common"; + +import { HttpTimelineInfo } from "./timeline"; + +export interface HttpHighlightMoveRequest { + timeline: string; + newPosition: number; +} + +export interface IHttpBookmarkClient { + list(): Promise; + put(timeline: string): Promise; + delete(timeline: string): Promise; + move(req: HttpHighlightMoveRequest): Promise; +} + +export class HttpHighlightClient implements IHttpBookmarkClient { + list(): Promise { + return axios + .get(`${apiBaseUrl}/bookmarks`) + .then(extractResponseData); + } + + put(timeline: string): Promise { + return axios.put(`${apiBaseUrl}/bookmarks/${timeline}`).then(); + } + + delete(timeline: string): Promise { + return axios.delete(`${apiBaseUrl}/bookmarks/${timeline}`).then(); + } + + move(req: HttpHighlightMoveRequest): Promise { + return axios.post(`${apiBaseUrl}/bookmarkop/move`, req).then(); + } +} + +let client: IHttpBookmarkClient = new HttpHighlightClient(); + +export function getHttpBookmarkClient(): IHttpBookmarkClient { + return client; +} + +export function setHttpBookmarkClient( + newClient: IHttpBookmarkClient +): IHttpBookmarkClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/http/common.ts b/FrontEnd/src/http/common.ts new file mode 100644 index 00000000..e1672985 --- /dev/null +++ b/FrontEnd/src/http/common.ts @@ -0,0 +1,214 @@ +import rawAxios, { AxiosError, AxiosResponse } from "axios"; +import { Base64 } from "js-base64"; +import { BehaviorSubject, Observable } from "rxjs"; + +export const apiBaseUrl = "/api"; + +export const axios = rawAxios.create(); + +function convertToNetworkError(error: AxiosError): never { + if (error.isAxiosError && error.response == null) { + throw new HttpNetworkError(error); + } else { + throw error; + } +} + +function convertToForbiddenError(error: AxiosError): never { + if ( + error.isAxiosError && + error.response != null && + (error.response.status == 401 || error.response.status == 403) + ) { + throw new HttpForbiddenError(error); + } else { + throw error; + } +} + +function convertToNotFoundError(error: AxiosError): never { + if ( + error.isAxiosError && + error.response != null && + error.response.status == 404 + ) { + throw new HttpNotFoundError(error); + } else { + throw error; + } +} + +rawAxios.interceptors.response.use(undefined, convertToNetworkError); +rawAxios.interceptors.response.use(undefined, convertToForbiddenError); +rawAxios.interceptors.response.use(undefined, convertToNotFoundError); +axios.interceptors.response.use(undefined, convertToNetworkError); +axios.interceptors.response.use(undefined, convertToForbiddenError); +axios.interceptors.response.use(undefined, convertToNotFoundError); + +const tokenSubject = new BehaviorSubject(null); + +export function getHttpToken(): string | null { + return tokenSubject.value; +} + +export function setHttpToken(token: string | null): void { + tokenSubject.next(token); + + if (token == null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + delete axios.defaults.headers.common["Authorization"]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; + } +} + +export const token$: Observable = tokenSubject.asObservable(); + +export function base64(blob: Blob | string): Promise { + if (typeof blob === "string") { + return Promise.resolve(Base64.encode(blob)); + } + + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = function () { + resolve((reader.result as string).replace(/^data:.*;base64,/, "")); + }; + reader.readAsDataURL(blob); + }); +} + +export function extractStatusCode(error: AxiosError): number | null { + if (error.isAxiosError) { + const code = error?.response?.status; + if (typeof code === "number") { + return code; + } + } + return null; +} + +export interface CommonErrorResponse { + code: number; + message: string; +} + +export function extractErrorCode( + error: AxiosError +): number | null { + if (error.isAxiosError) { + const code = error.response?.data?.code; + if (typeof code === "number") { + return code; + } + } + return null; +} + +export class HttpNetworkError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class HttpForbiddenError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class HttpNotFoundError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class NotModified {} + +export interface BlobWithEtag { + data: Blob; + etag: string; +} + +export function extractResponseData(res: AxiosResponse): T { + return res.data; +} + +export function catchIfStatusCodeIs< + TResult, + TErrorHandlerResult extends TResult | PromiseLike | null | undefined +>( + statusCode: number, + errorHandler: (error: AxiosError) => TErrorHandlerResult +): (error: AxiosError) => TErrorHandlerResult { + return (error: AxiosError) => { + if (extractStatusCode(error) == statusCode) { + return errorHandler(error); + } else { + throw error; + } + }; +} + +export function convertToIfStatusCodeIs( + statusCode: number, + newErrorType: { + new (innerError: AxiosError): NewError; + } +): (error: AxiosError) => never { + return catchIfStatusCodeIs(statusCode, (error) => { + throw new newErrorType(error); + }); +} + +export function catchIfErrorCodeIs< + TResult, + TErrorHandlerResult extends TResult | PromiseLike | null | undefined +>( + errorCode: number, + errorHandler: (error: AxiosError) => TErrorHandlerResult +): (error: AxiosError) => TErrorHandlerResult { + return (error: AxiosError) => { + if (extractErrorCode(error) == errorCode) { + return errorHandler(error); + } else { + throw error; + } + }; +} +export function convertToIfErrorCodeIs( + errorCode: number, + newErrorType: { + new (innerError: AxiosError): NewError; + } +): (error: AxiosError) => never { + return catchIfErrorCodeIs(errorCode, (error) => { + throw new newErrorType(error); + }); +} + +export function convertToNotModified( + error: AxiosError +): NotModified { + if ( + error.isAxiosError && + error.response != null && + error.response.status == 304 + ) { + return new NotModified(); + } else { + throw error; + } +} + +export function convertToBlobWithEtag(res: AxiosResponse): BlobWithEtag { + return { + data: res.data, + etag: (res.headers as Record<"etag", string>)["etag"], + }; +} + +export function extractEtag(res: AxiosResponse): string { + return (res.headers as Record<"etag", string>)["etag"]; +} diff --git a/FrontEnd/src/http/highlight.ts b/FrontEnd/src/http/highlight.ts new file mode 100644 index 00000000..fddf0729 --- /dev/null +++ b/FrontEnd/src/http/highlight.ts @@ -0,0 +1,49 @@ +import { axios, apiBaseUrl, extractResponseData } from "./common"; + +import { HttpTimelineInfo } from "./timeline"; + +export interface HttpHighlightMoveRequest { + timeline: string; + newPosition: number; +} + +export interface IHttpHighlightClient { + list(): Promise; + put(timeline: string): Promise; + delete(timeline: string): Promise; + move(req: HttpHighlightMoveRequest): Promise; +} + +export class HttpHighlightClient implements IHttpHighlightClient { + list(): Promise { + return axios + .get(`${apiBaseUrl}/highlights`) + .then(extractResponseData); + } + + put(timeline: string): Promise { + return axios.put(`${apiBaseUrl}/highlights/${timeline}`).then(); + } + + delete(timeline: string): Promise { + return axios.delete(`${apiBaseUrl}/highlights/${timeline}`).then(); + } + + move(req: HttpHighlightMoveRequest): Promise { + return axios.post(`${apiBaseUrl}/highlightop/move`, req).then(); + } +} + +let client: IHttpHighlightClient = new HttpHighlightClient(); + +export function getHttpHighlightClient(): IHttpHighlightClient { + return client; +} + +export function setHttpHighlightClient( + newClient: IHttpHighlightClient +): IHttpHighlightClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/http/search.ts b/FrontEnd/src/http/search.ts new file mode 100644 index 00000000..8ca48fe9 --- /dev/null +++ b/FrontEnd/src/http/search.ts @@ -0,0 +1,36 @@ +import { apiBaseUrl, axios, extractResponseData } from "./common"; +import { HttpTimelineInfo } from "./timeline"; +import { HttpUser } from "./user"; + +export interface IHttpSearchClient { + searchTimelines(query: string): Promise; + searchUsers(query: string): Promise; +} + +export class HttpSearchClient implements IHttpSearchClient { + searchTimelines(query: string): Promise { + return axios + .get(`${apiBaseUrl}/search/timelines?q=${query}`) + .then(extractResponseData); + } + + searchUsers(query: string): Promise { + return axios + .get(`${apiBaseUrl}/search/users?q=${query}`) + .then(extractResponseData); + } +} + +let client: IHttpSearchClient = new HttpSearchClient(); + +export function getHttpSearchClient(): IHttpSearchClient { + return client; +} + +export function setHttpSearchClient( + newClient: IHttpSearchClient +): IHttpSearchClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/http/timeline.ts b/FrontEnd/src/http/timeline.ts new file mode 100644 index 00000000..9697c1a0 --- /dev/null +++ b/FrontEnd/src/http/timeline.ts @@ -0,0 +1,234 @@ +import { AxiosError } from "axios"; + +import { applyQueryParameters } from "../utilities/url"; + +import { + axios, + apiBaseUrl, + extractResponseData, + convertToIfErrorCodeIs, + getHttpToken, +} from "./common"; +import { HttpUser } from "./user"; + +export const kTimelineVisibilities = ["Public", "Register", "Private"] as const; + +export type TimelineVisibility = typeof kTimelineVisibilities[number]; + +export interface HttpTimelineInfo { + uniqueId: string; + title: string; + name: string; + description: string; + owner: HttpUser; + visibility: TimelineVisibility; + color: string; + lastModified: string; + members: HttpUser[]; + isHighlight: boolean; + isBookmark: boolean; + manageable: boolean; + postable: boolean; +} + +export interface HttpTimelineListQuery { + visibility?: TimelineVisibility; + relate?: string; + relateType?: "own" | "join"; +} + +export interface HttpTimelinePostRequest { + name: string; +} + +export interface HttpTimelinePostDataDigest { + kind: string; + eTag: string; + lastUpdated: string; +} + +export interface HttpTimelinePostInfo { + id: number; + time: string; + author: HttpUser; + dataList: HttpTimelinePostDataDigest[]; + color: string; + lastUpdated: string; + timelineName: string; + editable: boolean; +} + +export interface HttpTimelinePostPostRequestData { + contentType: string; + data: string; +} + +export interface HttpTimelinePostPostRequest { + time?: string; + color?: string; + dataList: HttpTimelinePostPostRequestData[]; +} + +export interface HttpTimelinePatchRequest { + name?: string; + title?: string; + color?: string; + visibility?: TimelineVisibility; + description?: string; +} + +export interface HttpTimelinePostPatchRequest { + time?: string; + color?: string; +} + +export class HttpTimelineNameConflictError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export interface IHttpTimelineClient { + listTimeline(query: HttpTimelineListQuery): Promise; + getTimeline(timelineName: string): Promise; + postTimeline(req: HttpTimelinePostRequest): Promise; + patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest + ): Promise; + deleteTimeline(timelineName: string): Promise; + memberPut(timelineName: string, username: string): Promise; + memberDelete(timelineName: string, username: string): Promise; + listPost(timelineName: string): Promise; + generatePostDataUrl(timelineName: string, postId: number): string; + getPostDataAsString(timelineName: string, postId: number): Promise; + postPost( + timelineName: string, + req: HttpTimelinePostPostRequest + ): Promise; + patchPost( + timelineName: string, + postId: number, + req: HttpTimelinePostPatchRequest + ): Promise; + deletePost(timelineName: string, postId: number): Promise; +} + +export class HttpTimelineClient implements IHttpTimelineClient { + listTimeline(query: HttpTimelineListQuery): Promise { + return axios + .get( + applyQueryParameters(`${apiBaseUrl}/timelines`, query) + ) + .then(extractResponseData); + } + + getTimeline(timelineName: string): Promise { + return axios + .get(`${apiBaseUrl}/timelines/${timelineName}`) + .then(extractResponseData); + } + + postTimeline(req: HttpTimelinePostRequest): Promise { + return axios + .post(`${apiBaseUrl}/timelines`, req) + .then(extractResponseData) + .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)); + } + + patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest + ): Promise { + return axios + .patch(`${apiBaseUrl}/timelines/${timelineName}`, req) + .then(extractResponseData); + } + + deleteTimeline(timelineName: string): Promise { + return axios.delete(`${apiBaseUrl}/timelines/${timelineName}`).then(); + } + + memberPut(timelineName: string, username: string): Promise { + return axios + .put(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`) + .then(); + } + + memberDelete(timelineName: string, username: string): Promise { + return axios + .delete(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`) + .then(); + } + + listPost(timelineName: string): Promise { + return axios + .get( + `${apiBaseUrl}/timelines/${timelineName}/posts` + ) + .then(extractResponseData); + } + + generatePostDataUrl(timelineName: string, postId: number): string { + return applyQueryParameters( + `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`, + { token: getHttpToken() } + ); + } + + getPostDataAsString(timelineName: string, postId: number): Promise { + return axios + .get( + `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`, + { + responseType: "text", + } + ) + .then(extractResponseData); + } + + postPost( + timelineName: string, + req: HttpTimelinePostPostRequest + ): Promise { + return axios + .post( + `${apiBaseUrl}/timelines/${timelineName}/posts`, + req + ) + .then(extractResponseData); + } + + patchPost( + timelineName: string, + postId: number, + req: HttpTimelinePostPatchRequest + ): Promise { + return axios + .patch( + `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}`, + req + ) + .then(extractResponseData); + } + + deletePost(timelineName: string, postId: number): Promise { + return axios + .delete(`${apiBaseUrl}/timelines/${timelineName}/posts/${postId}`) + .then(); + } +} + +let client: IHttpTimelineClient = new HttpTimelineClient(); + +export function getHttpTimelineClient(): IHttpTimelineClient { + return client; +} + +export function setHttpTimelineClient( + newClient: IHttpTimelineClient +): IHttpTimelineClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/http/token.ts b/FrontEnd/src/http/token.ts new file mode 100644 index 00000000..f8b09d63 --- /dev/null +++ b/FrontEnd/src/http/token.ts @@ -0,0 +1,71 @@ +// Don't use axios in common because it will contains +// authorization header, which shouldn't be used in token apis. +import axios, { AxiosError } from "axios"; + +import { + apiBaseUrl, + convertToIfErrorCodeIs, + extractResponseData, +} from "./common"; +import { HttpUser } from "./user"; + +export interface HttpCreateTokenRequest { + username: string; + password: string; + expire: number; +} + +export interface HttpCreateTokenResponse { + token: string; + user: HttpUser; +} + +export interface HttpVerifyTokenRequest { + token: string; +} + +export interface HttpVerifyTokenResponse { + user: HttpUser; +} + +export class HttpCreateTokenBadCredentialError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export interface IHttpTokenClient { + create(req: HttpCreateTokenRequest): Promise; + verify(req: HttpVerifyTokenRequest): Promise; +} + +export class HttpTokenClient implements IHttpTokenClient { + create(req: HttpCreateTokenRequest): Promise { + return axios + .post(`${apiBaseUrl}/token/create`, req) + .then(extractResponseData) + .catch( + convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError) + ); + } + + verify(req: HttpVerifyTokenRequest): Promise { + return axios + .post(`${apiBaseUrl}/token/verify`, req) + .then(extractResponseData); + } +} + +let client: IHttpTokenClient = new HttpTokenClient(); + +export function getHttpTokenClient(): IHttpTokenClient { + return client; +} + +export function setHttpTokenClient( + newClient: IHttpTokenClient +): IHttpTokenClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/http/user.ts b/FrontEnd/src/http/user.ts new file mode 100644 index 00000000..dcf24cba --- /dev/null +++ b/FrontEnd/src/http/user.ts @@ -0,0 +1,161 @@ +import { AxiosError } from "axios"; + +import { + axios, + apiBaseUrl, + extractResponseData, + convertToIfStatusCodeIs, + convertToIfErrorCodeIs, + extractEtag, +} from "./common"; + +export const kUserManagement = "UserManagement"; +export const kAllTimelineManagement = "AllTimelineManagement"; +export const kHighlightTimelineManagement = "HighlightTimelineManagement"; + +export const kUserPermissionList = [ + kUserManagement, + kAllTimelineManagement, + kHighlightTimelineManagement, +] as const; + +export type UserPermission = typeof kUserPermissionList[number]; + +export interface HttpUser { + uniqueId: string; + username: string; + permissions: UserPermission[]; + nickname: string; +} + +export interface HttpUserPatchRequest { + username?: string; + password?: string; + nickname?: string; +} + +export interface HttpChangePasswordRequest { + oldPassword: string; + newPassword: string; +} + +export interface HttpCreateUserRequest { + username: string; + password: string; +} + +export class HttpUserNotExistError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export class HttpChangePasswordBadCredentialError extends Error { + constructor(public innerError?: AxiosError) { + super(); + } +} + +export interface IHttpUserClient { + list(): Promise; + get(username: string): Promise; + post(req: HttpCreateUserRequest): Promise; + patch(username: string, req: HttpUserPatchRequest): Promise; + delete(username: string): Promise; + generateAvatarUrl(username: string): string; + putAvatar(username: string, data: Blob): Promise; + changePassword(req: HttpChangePasswordRequest): Promise; + putUserPermission( + username: string, + permission: UserPermission + ): Promise; + deleteUserPermission( + username: string, + permission: UserPermission + ): Promise; +} + +export class HttpUserClient implements IHttpUserClient { + list(): Promise { + return axios + .get(`${apiBaseUrl}/users`) + .then(extractResponseData); + } + + get(username: string): Promise { + return axios + .get(`${apiBaseUrl}/users/${username}`) + .then(extractResponseData) + .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)); + } + + post(req: HttpCreateUserRequest): Promise { + return axios + .post(`${apiBaseUrl}/users`, req) + .then(extractResponseData) + .then(); + } + + patch(username: string, req: HttpUserPatchRequest): Promise { + return axios + .patch(`${apiBaseUrl}/users/${username}`, req) + .then(extractResponseData); + } + + delete(username: string): Promise { + return axios.delete(`${apiBaseUrl}/users/${username}`).then(); + } + + generateAvatarUrl(username: string): string { + return `${apiBaseUrl}/users/${username}/avatar`; + } + + putAvatar(username: string, data: Blob): Promise { + return axios + .put(`${apiBaseUrl}/users/${username}/avatar`, data, { + headers: { + "Content-Type": data.type, + }, + }) + .then(extractEtag); + } + + changePassword(req: HttpChangePasswordRequest): Promise { + return axios + .post(`${apiBaseUrl}/userop/changepassword`, req) + .catch( + convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError) + ) + .then(); + } + + putUserPermission( + username: string, + permission: UserPermission + ): Promise { + return axios + .put(`${apiBaseUrl}/users/${username}/permissions/${permission}`) + .then(); + } + + deleteUserPermission( + username: string, + permission: UserPermission + ): Promise { + return axios + .delete(`${apiBaseUrl}/users/${username}/permissions/${permission}`) + .then(); + } +} + +let client: IHttpUserClient = new HttpUserClient(); + +export function getHttpUserClient(): IHttpUserClient { + return client; +} + +export function setHttpUserClient(newClient: IHttpUserClient): IHttpUserClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/i18n.ts b/FrontEnd/src/i18n.ts new file mode 100644 index 00000000..8caf51ec --- /dev/null +++ b/FrontEnd/src/i18n.ts @@ -0,0 +1,88 @@ +import i18n, { BackendModule, ResourceKey } from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; + +const backend: BackendModule = { + type: "backend", + async read(language, namespace, callback) { + function error(message: string): void { + callback(new Error(message), false); + } + + function success(result: ResourceKey): void { + callback(null, result); + } + + const promise = (() => { + if (namespace === "translation") { + if (language === "en") { + return import("./locales/en/translation.json"); + } else if (language === "zh") { + return import("./locales/zh/translation.json"); + } else { + error(`Language ${language} is not supported.`); + } + } else if (namespace === "admin") { + if (language === "en") { + return import("./locales/en/admin.json"); + } else if (language === "zh") { + return import("./locales/zh/admin.json"); + } else { + error(`Language ${language} is not supported.`); + } + } else { + error(`Namespace ${namespace} is not supported.`); + } + })(); + + if (promise) { + success((await promise).default); + } + }, + init() {}, // eslint-disable-line @typescript-eslint/no-empty-function + create() {}, // eslint-disable-line @typescript-eslint/no-empty-function +}; + +export const i18nPromise = i18n + .use(LanguageDetector) + .use(backend) + .use(initReactI18next) // bind react-i18next to the instance + .init({ + fallbackLng: false, + lowerCaseLng: true, + + debug: process.env.NODE_ENV === "development", + + interpolation: { + escapeValue: false, // not needed for react!! + }, + + // react i18next special options (optional) + // override if needed - omit if ok with defaults + /* + react: { + bindI18n: 'languageChanged', + bindI18nStore: '', + transEmptyNodeValue: '', + transSupportBasicHtmlNodes: true, + transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'], + useSuspense: true, + } + */ + }); + +if (import.meta.hot) { + import.meta.hot.accept( + [ + "./locales/en/translation.json", + "./locales/zh/translation.json", + "./locales/en/admin.json", + "./locales/zh/admin.json", + ], + () => { + void i18n.reloadResources(); + } + ); +} + +export default i18n; diff --git a/FrontEnd/src/index.ejs b/FrontEnd/src/index.ejs new file mode 100644 index 00000000..c2ff4182 --- /dev/null +++ b/FrontEnd/src/index.ejs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + <%= htmlWebpackPlugin.options.title %> + + + +
+ + + diff --git a/FrontEnd/src/index.sass b/FrontEnd/src/index.sass new file mode 100644 index 00000000..4cee155f --- /dev/null +++ b/FrontEnd/src/index.sass @@ -0,0 +1,120 @@ +@import 'bootstrap/scss/bootstrap' +@import 'bootstrap-icons/font/bootstrap-icons.css' + +@import './views/common/common' +@import './views/common/alert/alert' +@import './views/center/center' +@import './views/home/home' +@import './views/about/about' +@import './views/login/login' +@import './views/settings/settings' +@import './views/timeline-common/timeline-common' +@import './views/timeline/timeline' +@import './views/user/user' +@import './views/search/search' + +@import './views/admin/admin' + +.tl-color-primary + color: var(--tl-primary-color) + +.tl-color-danger + color: var(--tl-danger-color) + +small + line-height: 1.2 + +.flex-fix-length + flex-grow: 0 + flex-shrink: 0 + +.position-lt + left: 0 + top: 0 + +.avatar + width: 60px + height: 60px + &.large + width: 100px + height: 100px + &.small + width: 40px + height: 40px + +.icon-button + font-size: 1.4rem + cursor: pointer + &.large + font-size: 1.6rem + +.flat-button + cursor: pointer + padding: 0.2em 0.5em + border-radius: 0.2em + &:hover:not(.disabled) + background-color: $gray-200 + &.disabled + cursor: default + @each $color, $value in $theme-colors + &.#{$color} + color: $value + &.disabled + color: adjust-color($value, $lightness: +15%) + +.cursor-pointer + cursor: pointer + +textarea + resize: none + +.white-space-no-wrap + white-space: nowrap + +.cru-card + @extend .shadow + @extend .rounded + border: 1px solid + border-color: $gray-200 + background: $gray-100 + transition: all 0.3s + &:hover + border-color: var(--tl-primary-color) + +.full-viewport-center-child + position: fixed + width: 100vw + height: 100vh + display: flex + justify-content: center + align-items: center + +.text-orange + color: $orange + +.text-yellow + color: $yellow + +.text-button + background: transparent + border: none + @each $color, $value in $theme-colors + &.#{$color} + color: $value + &:hover + color: adjust-color($value, $lightness: +15%) + +.touch-action-none + touch-action: none + +i + line-height: 1 + +.markdown-container + white-space: initial + img + max-height: 200px + max-width: 100% + +a + text-decoration: none diff --git a/FrontEnd/src/index.tsx b/FrontEnd/src/index.tsx new file mode 100644 index 00000000..fb0c8899 --- /dev/null +++ b/FrontEnd/src/index.tsx @@ -0,0 +1,21 @@ +import "regenerator-runtime"; +import "core-js/modules/es.promise"; +import "core-js/modules/es.array.iterator"; +import "pepjs"; + +import React from "react"; +import ReactDOM from "react-dom"; + +import "./index.sass"; + +import "./i18n"; + +import App from "./App"; + +import "./palette"; + +import { userService } from "./services/user"; + +void userService.checkLoginState(); + +ReactDOM.render(, document.getElementById("app")); diff --git a/FrontEnd/src/locales/en/admin.json b/FrontEnd/src/locales/en/admin.json new file mode 100644 index 00000000..ddb3ffad --- /dev/null +++ b/FrontEnd/src/locales/en/admin.json @@ -0,0 +1,35 @@ +{ + "nav": { + "users": "Users", + "more": "More" + }, + "create": "Create", + "user": { + "username": "Username: ", + "password": "Password: ", + "nickname": "Nickname: ", + "uniqueId": "Unique ID: ", + "permissions": "Permissions: ", + "modify": "Modify", + "modifyPermissions": "Modify Permissions", + "delete": "Delete", + "dialog": { + "create": { + "title": "Create User", + "prompt": "You are creating a new user." + }, + "delete": { + "title": "Delete user", + "prompt": "You are deleting <1>username . Caution: This can't be undo." + }, + "modify": { + "title": "Modify User", + "prompt": "You are modifying user <1>username ." + }, + "modifyPermissions": { + "title": "Modify User Permissions", + "prompt": "You are modifying permissions of user <1>username ." + } + } + } +} diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json new file mode 100644 index 00000000..a2766b4e --- /dev/null +++ b/FrontEnd/src/locales/en/translation.json @@ -0,0 +1,224 @@ +{ + "welcome": "Welcome!", + "search": "Search", + "edit": "Edit", + "image": "Image", + "done": "Done", + "preview": "Preview", + "delete": "Delete", + "changeProperty": "Change Property", + "loadFailReload": "Load failed, <1>click here to reload.", + "error": { + "network": "Network error.", + "unknown": "Unknown error." + }, + "connectionState": { + "Connected": "Connected", + "Connecting": "Connecting", + "Disconnected": "Disconnected", + "Disconnecting": "Disconnecting", + "Reconnecting": "Reconnecting" + }, + "serviceWorker": { + "availableOffline": "Timeline is now cached in your computer and you can use it offline. 🎉🎉🎉", + "upgradePrompt": "App is getting a new version!", + "upgradeNow": "Update Now", + "upgradeSuccess": "Congratulations! App update succeeded! Still you can use it offline. 🎉🎉🎉", + "externalActivatedPrompt": "A new version of app is activated. Please refresh the page. Or it may be broken.", + "reloadNow": "Refresh Now" + }, + "nav": { + "settings": "Settings", + "login": "Login", + "about": "About", + "administration": "Administration" + }, + "chooseImage": "Choose a image", + "loadImageError": "Failed to load image.", + "home": { + "loadingHighlightTimelines": "Loading highlight timelines...", + "loadedHighlightTimelines": "Here are some highlight timelines💡", + "errorHighlightTimelines": "Failed to load highlight timelines, please try reloading!", + "bookmarkTimeline": "Bookmark Timelines", + "highlightTimeline": "Highlight Timelines", + "relatedTimeline": "Timelines You Participate", + "message": { + "moveHighlightFail": "Failed to move highlight timeline.", + "deleteHighlightFail": "Failed to delete highlight timeline.", + "moveBookmarkFail": "Failed to move bookmark timeline.", + "deleteBookmarkFail": "Failed to delete bookmark timeline." + }, + "createButton": "Create Timeline", + "createDialog": { + "title": "Create Timeline!", + "name": "Name", + "nameFormat": "Name must consist of only letter including non-English letter, digit, hyphen(-) and underline(_) and be no longer than 26.", + "badFormat": "Bad format.", + "noEmpty": "Empty is not allowed.", + "tooLong": "Too long." + } + }, + "operationDialog": { + "retry": "Retry", + "nextStep": "Next", + "previousStep": "Previous", + "confirm": "Confirm", + "cancel": "Cancel", + "ok": "OK!", + "processing": "Processing...", + "success": "Success!", + "error": "An error occured." + }, + "timeline": { + "messageCantSee": "Sorry, you are not allowed to see this timeline.😅", + "userNotExist": "The user does not exist!", + "timelineNotExist": "The timeline does not exist!", + "manage": "Manage", + "memberButton": "Member", + "send": "Send", + "deletePostFailed": "Failed to delete post.", + "sendPostFailed": "Failed to send post.", + "dropDraft": "Drop Draft", + "confirmLeave": "Are you sure to leave? All content you typed would be lost.", + "visibility": { + "public": "public to everyone", + "register": "only registed people can see", + "private": "only members can see" + }, + "visibilityTooltip": { + "public": "Everyone including those without accounts can see content of the timeline.", + "register": "Only those who have an account and logined can see content of the timeline.", + "private": "Only members of this timeline can see content of the timeline." + }, + "dialogChangeProperty": { + "title": "Change Timeline Properties", + "titleField": "Title", + "visibility": "Visibility", + "description": "Description", + "color": "Color" + }, + "changePostPropertyDialog": { + "title": "Change Post Properties", + "time": "Date and time", + "timeEmpty": "You must select a time." + }, + "member": { + "noUserAvailableToAdd": "Sorry, no user available to be a member in search result.", + "add": "Add", + "remove": "Remove" + }, + "manageItem": { + "nickname": "Nickname", + "avatar": "Avatar", + "property": "Timeline Property", + "member": "Timeline Member", + "delete": "Delete Timeline" + }, + "deleteDialog": { + "title": "Delete Timeline", + "inputPrompt": "This is a dangerous action. If you are sure to delete timeline<1>{{name}}, please input its name below and click confirm button.", + "notMatch": "Name does not match." + }, + "post": { + "type": { + "text": "Plain Text", + "markdown": "Markdown", + "image": "Image" + }, + "deleteDialog": { + "title": "Confirm Delete", + "prompt": "Are you sure to delete the post? This operation is not recoverable." + } + }, + "addHighlightFail": "Failed to add highlight.", + "removeHighlightFail": "Failed to remove highlight.", + "addBookmarkFail": "Failed to add bookmark.", + "removeBookmarkFail": "Failed to remove bookmark." + }, + "searchPage": { + "loading": "Loading search result...", + "input": "Input something and search!", + "noResult": "Sorry, there is no satisfied results." + }, + "user": { + "username": "username", + "password": "password", + "login": "login", + "rememberMe": "Remember Me", + "welcomeBack": "Welcome back!", + "verifyTokenFailed": "User login info is expired. Please login again!", + "verifyTokenFailedNetwork": "Verifying user login info failed. Please check your network and refresh page!" + }, + "login": { + "emptyUsername": "Username can't be empty.", + "emptyPassword": "Password can't be empty.", + "badCredential": "Username or password is invalid.", + "alreadyLogin": "Already login! Redirect to home page in 3s!" + }, + "settings": { + "subheaders": { + "account": "Account", + "customization": "Customization" + }, + "languagePrimary": "Choose display language.", + "languageSecondary": "You language preference will be saved locally. Next time you visit this page, last language option will be used.", + "changePassword": "Change account's password.", + "logout": "Log out this account.", + "changeAvatar": "Change avatar.", + "changeNickname": "Change nickname.", + "dialogChangePassword": { + "title": "Change Password", + "prompt": "You are changing your password. You need to input the correct old password. After change, you need to login again and all old login will be invalid.", + "inputOldPassword": "Old password", + "inputNewPassword": "New password", + "inputRetypeNewPassword": "Retype new password", + "errorEmptyOldPassword": "Old password can't be empty.", + "errorEmptyNewPassword": "New password can't be empty.", + "errorRetypeNotMatch": "Password retyped does not match." + }, + "dialogConfirmLogout": { + "title": "Confirm Logout", + "prompt": "Are you sure to log out? All cached data in the browser will be deleted." + }, + "dialogChangeNickname": { + "title": "Change Nickname", + "inputLabel": "New nickname" + }, + "dialogChangeAvatar": { + "title": "Change Avatar", + "previewImgAlt": "preview", + "prompt": { + "select": "Please select a picture.", + "crop": "Please crop the picture.", + "processingCrop": "Cropping picture...", + "uploading": "Uploading...", + "preview": "Please preview avatar" + }, + "upload": "upload" + } + }, + "about": { + "author": { + "title": "Site Developer", + "fullname": "Fullname: ", + "nickname": "Nickname: ", + "introduction": "Introduction: ", + "introductionContent": "A programmer coding based on coincidence", + "links": "Links: " + }, + "site": { + "title": "Site Information", + "content": "The name of this site is <1>Timeline, which is a Web App with <3>timeline as its core concept. Its frontend and backend are both developed by <5>me, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.", + "repo": "GitHub Repo" + }, + "credits": { + "title": "Credits", + "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", + "frontend": "Frontend: ", + "backend": "Backend: " + } + }, + "admin": { + "title": "admin" + } +} diff --git a/FrontEnd/src/locales/zh/admin.json b/FrontEnd/src/locales/zh/admin.json new file mode 100644 index 00000000..edd1cabd --- /dev/null +++ b/FrontEnd/src/locales/zh/admin.json @@ -0,0 +1,35 @@ +{ + "nav": { + "users": "用户", + "more": "更多" + }, + "create": "创建", + "user": { + "username": "用户名:", + "password": "密码:", + "nickname": "昵称:", + "uniqueId": "唯一ID:", + "permissions": "权限:", + "modify": "修改", + "modifyPermissions": "修改权限", + "delete": "删除", + "dialog": { + "create": { + "title": "创建用户", + "prompt": "您正在创建一个新用户。" + }, + "delete": { + "title": "删除用户", + "prompt": "您正在删除用户 <1>username 。注意:此操作不可撤销。" + }, + "modify": { + "title": "修改用户", + "prompt": "您正在修改用户 <1>username 。" + }, + "modifyPermissions": { + "title": "修改用户权限", + "prompt": "您正在修改用户 <1>username 的权限。" + } + } + } +} diff --git a/FrontEnd/src/locales/zh/translation.json b/FrontEnd/src/locales/zh/translation.json new file mode 100644 index 00000000..5a5a6843 --- /dev/null +++ b/FrontEnd/src/locales/zh/translation.json @@ -0,0 +1,224 @@ +{ + "welcome": "欢迎!", + "search": "搜索", + "edit": "编辑", + "image": "图片", + "done": "完成", + "preview": "预览", + "loadFailReload": "加载失败,<1>点击重试。", + "delete": "删除", + "changeProperty": "修改属性", + "error": { + "network": "网络错误。", + "unknown": "未知错误。" + }, + "connectionState": { + "Connected": "已连接", + "Connecting": "正在连接", + "Disconnected": "已断开连接", + "Disconnecting": "正在断开连接", + "Reconnecting": "正在重新连接" + }, + "serviceWorker": { + "availableOffline": "Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉", + "upgradePrompt": "App 有新版本!", + "upgradeNow": "现在升级", + "upgradeSuccess": "App 升级成功,当然,你仍可以离线使用它。 🎉🎉🎉", + "externalActivatedPrompt": "一个新的 App 版本已经激活,请刷新页面使用,否则页面可能会出现故障。", + "reloadNow": "立刻刷新" + }, + "nav": { + "settings": "设置", + "login": "登陆", + "about": "关于", + "administration": "管理" + }, + "chooseImage": "选择一个图片", + "loadImageError": "加载图片失败", + "home": { + "loadingHighlightTimelines": "正在加载高光时间线...", + "loadedHighlightTimelines": "康康以下这些高光时间线💡", + "errorHighlightTimelines": "加载高光时间线失败,刷新试试!", + "bookmarkTimeline": "书签时间线", + "highlightTimeline": "高光时间线", + "relatedTimeline": "参与的时间线", + "message": { + "moveHighlightFail": "移动高光时间线失败。", + "deleteHighlightFail": "删除高光时间线失败。", + "moveBookmarkFail": "移动书签时间线失败。", + "deleteBookmarkFail": "删除书签时间线失败。" + }, + "createButton": "创建时间线", + "createDialog": { + "title": "创建时间线!", + "name": "名字", + "nameFormat": "名字只能由字母、汉字、数字、下划线(_)和连字符(-)构成,且长度不能超过26.", + "badFormat": "格式错误", + "noEmpty": "不能为空", + "tooLong": "太长了" + } + }, + "operationDialog": { + "retry": "重试", + "nextStep": "下一步", + "previousStep": "上一步", + "confirm": "确定", + "cancel": "取消", + "ok": "好的!", + "processing": "处理中...", + "success": "成功!", + "error": "出错啦!" + }, + "timeline": { + "messageCantSee": "不好意思,你没有权限查看这个时间线。😅", + "userNotExist": "该用户不存在!", + "timelineNotExist": "该时间线不存在!", + "manage": "管理", + "memberButton": "成员", + "send": "发送", + "deletePostFailed": "删除消息失败。", + "sendPostFailed": "发送消息失败。", + "dropDraft": "放弃草稿", + "confirmLeave": "确定要离开吗?所有输入的内容将会丢失。", + "visibility": { + "public": "对所有人公开", + "register": "仅注册可见", + "private": "仅成员可见" + }, + "visibilityTooltip": { + "public": "所有人都可以看到这个时间线的内容,包括没有注册的人。", + "register": "只有拥有本网站的账号且登陆了的人才能看到这个时间线的内容。", + "private": "只有这个时间线的成员可以看到这个时间线的内容。" + }, + "dialogChangeProperty": { + "title": "修改时间线属性", + "titleField": "标题", + "visibility": "可见性", + "description": "描述", + "color": "颜色" + }, + "changePostPropertyDialog": { + "title": "修改消息属性", + "time": "时间", + "timeEmpty": "你必须选择一个时间。" + }, + "member": { + "noUserAvailableToAdd": "搜索结果显示没有可以添加为成员的用户。", + "add": "添加", + "remove": "移除" + }, + "manageItem": { + "nickname": "昵称", + "avatar": "头像", + "property": "时间线属性", + "member": "时间线成员", + "delete": "删除时间线" + }, + "deleteDialog": { + "title": "删除时间线", + "inputPrompt": "这是一个危险的操作。如果您确认要删除时间线<1>{{name}},请在下面输入它的名字并点击确认。", + "notMatch": "名字不匹配" + }, + "post": { + "type": { + "text": "纯文本", + "markdown": "Markdown", + "image": "图片" + }, + "deleteDialog": { + "title": "确认删除", + "prompt": "确定删除这个消息?这个操作不可撤销。" + } + }, + "addHighlightFail": "添加高光失败。", + "removeHighlightFail": "删除高光失败。", + "addBookmarkFail": "添加书签失败。", + "removeBookmarkFail": "删除书签失败。" + }, + "searchPage": { + "loading": "加载搜索结果中...", + "input": "输入一些东西来搜索!", + "noResult": "对不起,没有符合条件的结果。" + }, + "user": { + "username": "用户名", + "password": "密码", + "login": "登录", + "rememberMe": "记住我", + "welcomeBack": "欢迎回来!", + "verifyTokenFailed": "用户登录信息已过期,请重新登陆!", + "verifyTokenFailedNetwork": "验证用户登录信息失败,请检查网络连接并刷新页面!" + }, + "login": { + "emptyUsername": "用户名不能为空。", + "emptyPassword": "密码不能为空。", + "badCredential": "用户名或密码错误。", + "alreadyLogin": "已经登陆,三秒后导航到首页!" + }, + "settings": { + "subheaders": { + "account": "账户", + "customization": "个性化" + }, + "languagePrimary": "选择显示的语言。", + "languageSecondary": "您的语言偏好将会存储在本地,下次浏览时将自动使用上次保存的语言选项。", + "changePassword": "更改账号的密码。", + "logout": "注销此账号。", + "changeAvatar": "更改头像。", + "changeNickname": "更改昵称。", + "dialogChangePassword": { + "title": "修改密码", + "prompt": "您正在修改密码,您需要输入正确的旧密码。成功修改后您需要重新登陆,而且以前所有的登录都会失效。", + "inputOldPassword": "旧密码", + "inputNewPassword": "新密码", + "inputRetypeNewPassword": "再次输入新密码", + "errorEmptyOldPassword": "旧密码不能为空。", + "errorEmptyNewPassword": "新密码不能为空", + "errorRetypeNotMatch": "两次输入的密码不一致" + }, + "dialogConfirmLogout": { + "title": "确定注销", + "prompt": "您确定注销此账号?这将删除所有已经缓存在浏览器的数据。" + }, + "dialogChangeNickname": { + "title": "更改昵称", + "inputLabel": "新昵称" + }, + "dialogChangeAvatar": { + "title": "修改头像", + "previewImgAlt": "预览", + "prompt": { + "select": "请选择一个图片", + "crop": "请裁剪图片", + "processingCrop": "正在裁剪图片", + "uploading": "正在上传", + "preview": "请预览图片" + }, + "upload": "上传" + } + }, + "about": { + "author": { + "title": "网站作者", + "fullname": "姓名:", + "nickname": "昵称:", + "introduction": "简介:", + "introductionContent": "一个基于巧合编程的代码爱好者。", + "links": "链接:" + }, + "site": { + "title": "网站信息", + "content": "这个网站的名字叫 <1>Timeline,是一个以<3>时间线为核心概念的 Web App . 它的前端和后端都是由<5>我开发,并且在 GitHub 上开源。大家可以相对轻松的把它们部署在自己的服务器上,这也是我的目标之一。欢迎大家前往 GitHub 仓库提出任何意见。", + "repo": "GitHub 仓库" + }, + "credits": { + "title": "鸣谢", + "content": "Timeline 是站在巨人肩膀上的作品,感谢以下列出的和其他未列出的许多开源项目,相关 License 请在 GitHub 仓库中查看。", + "frontend": "前端:", + "backend": "后端:" + } + }, + "admin": { + "title": "管理" + } +} diff --git a/FrontEnd/src/palette.ts b/FrontEnd/src/palette.ts new file mode 100644 index 00000000..c4f4f4f9 --- /dev/null +++ b/FrontEnd/src/palette.ts @@ -0,0 +1,116 @@ +import Color from "color"; +import { BehaviorSubject, Observable } from "rxjs"; + +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; + inactive: string; + lighter: string; + darker: string; + [key: string]: string; +} + +export interface Palette { + primary: PaletteColor; + primaryEnhance: PaletteColor; + secondary: PaletteColor; + textPrimary: PaletteColor; + textOnPrimary: PaletteColor; + danger: PaletteColor; + success: PaletteColor; + [key: string]: PaletteColor; +} + +export function generatePaletteColor(color: string): PaletteColor { + const c = Color(color); + return { + color: c.toString(), + inactive: (c.lightness() > 60 + ? darkenBy(c, 0.1) + : lightenBy(c, 0.2) + ).toString(), + lighter: lightenBy(c, 0.1).fade(0.1).toString(), + darker: darkenBy(c, 0.1).toString(), + }; +} + +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 ? p.rotate(90) : Color(secondary); + + return { + primary: generatePaletteColor(p.toString()), + primaryEnhance: generatePaletteColor(pe.toString()), + secondary: generatePaletteColor(s.toString()), + textPrimary: generatePaletteColor("#111111"), + textOnPrimary: generatePaletteColor(p.lightness() > 60 ? "black" : "white"), + danger: generatePaletteColor("red"), + success: generatePaletteColor("green"), + }; +} + +export function generatePaletteCSS(palette: Palette): string { + function toSnakeCase(s: string): string { + return s.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + } + + const colors: [string, string][] = []; + for (const paletteColorName in palette) { + const paletteColor = palette[paletteColorName]; + for (const variant in paletteColor) { + let key = `--tl-${toSnakeCase(paletteColorName)}`; + if (variant !== "color") key += `-${toSnakeCase(variant)}`; + key += "-color"; + colors.push([key, paletteColor[variant]]); + } + } + + return `:root {${colors + .map(([key, color]) => `${key} : ${color};`) + .join("")}}`; +} + +const paletteSubject: BehaviorSubject = new BehaviorSubject( + generatePalette({ primary: "#007bff" }) +); + +export const palette$: Observable = paletteSubject.asObservable(); + +palette$.subscribe((palette) => { + const styleTagId = "timeline-palette-css"; + let styleTag = document.getElementById(styleTagId); + if (styleTag == null) { + styleTag = document.createElement("style"); + styleTag.id = styleTagId; + document.head.append(styleTag); + } + styleTag.innerHTML = generatePaletteCSS(palette); +}); + +export function setPalette(palette: Palette): () => void { + const old = paletteSubject.value; + + paletteSubject.next(palette); + + return () => { + paletteSubject.next(old); + }; +} diff --git a/FrontEnd/src/service-worker.txt b/FrontEnd/src/service-worker.txt new file mode 100644 index 00000000..ea8dfc32 --- /dev/null +++ b/FrontEnd/src/service-worker.txt @@ -0,0 +1,104 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "react-bootstrap"; + +import { pushAlert } from "./services/alert"; + +if ("serviceWorker" in navigator) { + let isThisTriggerUpgrade = false; + + const upgradeSuccessLocalStorageKey = "TIMELINE_UPGRADE_SUCCESS"; + + if (window.localStorage.getItem(upgradeSuccessLocalStorageKey)) { + pushAlert({ + message: "serviceWorker.upgradeSuccess", + type: "success", + }); + window.localStorage.removeItem(upgradeSuccessLocalStorageKey); + } + + void import("workbox-window").then(({ Workbox, messageSW }) => { + const wb = new Workbox("/sw.js"); + let registration: ServiceWorkerRegistration | undefined; + + // externalactivated is not usable but I still use its name. + wb.addEventListener("controlling", () => { + const upgradeReload = (): void => { + window.localStorage.setItem(upgradeSuccessLocalStorageKey, "true"); + window.location.reload(); + }; + + if (isThisTriggerUpgrade) { + upgradeReload(); + } else { + const Message: React.FC = () => { + const { t } = useTranslation(); + return ( + <> + {t("serviceWorker.externalActivatedPrompt")} + + + ); + }; + + pushAlert({ + message: Message, + dismissTime: "never", + type: "warning", + }); + } + }); + + wb.addEventListener("activated", (event) => { + if (!event.isUpdate) { + pushAlert({ + message: "serviceWorker.availableOffline", + type: "success", + }); + } + }); + + // Add an event listener to detect when the registered + // service worker has installed but is waiting to activate. + wb.addEventListener("waiting", (): void => { + const upgrade = (): void => { + isThisTriggerUpgrade = true; + if (registration && registration.waiting) { + // Send a message to the waiting service worker, + // instructing it to activate. + // Note: for this to work, you have to add a message + // listener in your service worker. See below. + void messageSW(registration.waiting, { type: "SKIP_WAITING" }); + } + }; + + const UpgradeMessage: React.FC = () => { + const { t } = useTranslation(); + return ( + <> + {t("serviceWorker.upgradePrompt")} + + + ); + }; + + pushAlert({ + message: UpgradeMessage, + dismissTime: "never", + type: "success", + }); + }); + + void wb.register().then((reg) => { + registration = reg; + }); + }); +} diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts new file mode 100644 index 00000000..fe4c7a9a --- /dev/null +++ b/FrontEnd/src/services/TimelinePostBuilder.ts @@ -0,0 +1,116 @@ +import { Remarkable } from "remarkable"; + +import { UiLogicError } from "@/common"; + +import { base64 } from "http/common"; +import { HttpTimelinePostPostRequest } from "http/timeline"; + +export default class TimelinePostBuilder { + private _onChange: () => void; + private _text = ""; + private _images: { file: File; url: string }[] = []; + private _md: Remarkable = new Remarkable(); + + constructor(onChange: () => void) { + this._onChange = onChange; + const oldImageRenderer = this._md.renderer.rules.image; + this._md.renderer.rules.image = (( + _t: TimelinePostBuilder + ): Remarkable.Rule => + function (tokens, idx, options /*, env */) { + const i = parseInt(tokens[idx].src); + if (!isNaN(i) && i > 0 && i <= _t._images.length) { + tokens[idx].src = _t._images[i - 1].url; + } + return oldImageRenderer(tokens, idx, options); + })(this); + } + + 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 this._md.render(this._text); + } + + dispose(): void { + for (const image of this._images) { + URL.revokeObjectURL(image.url); + } + this._images = []; + } + + async build(): Promise { + 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 new file mode 100644 index 00000000..48d482ea --- /dev/null +++ b/FrontEnd/src/services/alert.ts @@ -0,0 +1,63 @@ +import React from "react"; +import pull from "lodash/pull"; + +import { BootstrapThemeColor, I18nText } from "@/common"; + +export interface AlertInfo { + type?: BootstrapThemeColor; + message: React.FC | I18nText; + dismissTime?: number | "never"; +} + +export interface AlertInfoEx extends AlertInfo { + id: number; +} + +export type AlertConsumer = (alerts: AlertInfoEx) => void; + +export class AlertService { + private consumers: AlertConsumer[] = []; + private savedAlerts: AlertInfoEx[] = []; + private currentId = 1; + + private produce(alert: AlertInfoEx): void { + for (const consumer of this.consumers) { + consumer(alert); + } + } + + registerConsumer(consumer: AlertConsumer): void { + this.consumers.push(consumer); + if (this.savedAlerts.length !== 0) { + for (const alert of this.savedAlerts) { + this.produce(alert); + } + this.savedAlerts = []; + } + } + + unregisterConsumer(consumer: AlertConsumer): void { + pull(this.consumers, consumer); + } + + push(alert: AlertInfo): void { + const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ }; + if (this.consumers.length === 0) { + this.savedAlerts.push(newAlert); + } else { + this.produce(newAlert); + } + } +} + +export const alertService = new AlertService(); + +export function pushAlert(alert: AlertInfo): void { + alertService.push(alert); +} + +export const kAlertHostId = "alert-host"; + +export function getAlertHost(): HTMLElement | null { + return document.getElementById(kAlertHostId); +} diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts new file mode 100644 index 00000000..4ebb705d --- /dev/null +++ b/FrontEnd/src/services/timeline.ts @@ -0,0 +1,85 @@ +import { TimelineVisibility } from "http/timeline"; +import XRegExp from "xregexp"; +import { Observable } from "rxjs"; +import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; + +import { getHttpToken } from "http/common"; + +const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); + +export function validateTimelineName(name: string): boolean { + return timelineNameReg.test(name); +} + +export const timelineVisibilityTooltipTranslationMap: Record< + TimelineVisibility, + string +> = { + Public: "timeline.visibilityTooltip.public", + Register: "timeline.visibilityTooltip.register", + Private: "timeline.visibilityTooltip.private", +}; + +export function getTimelinePostUpdate$( + timelineName: string +): Observable<{ update: boolean; state: HubConnectionState }> { + return new Observable((subscriber) => { + subscriber.next({ + update: false, + state: HubConnectionState.Connecting, + }); + + const token = getHttpToken(); + const connection = new HubConnectionBuilder() + .withUrl("/api/hub/timeline", { + accessTokenFactory: token == null ? undefined : () => token, + }) + .withAutomaticReconnect() + .build(); + + const handler = (tn: string): void => { + if (timelineName === tn) { + subscriber.next({ update: true, state: connection.state }); + } + }; + + connection.onclose(() => { + subscriber.next({ + update: false, + state: HubConnectionState.Disconnected, + }); + }); + + connection.onreconnecting(() => { + subscriber.next({ + update: false, + state: HubConnectionState.Reconnecting, + }); + }); + + connection.onreconnected(() => { + subscriber.next({ + update: false, + state: HubConnectionState.Connected, + }); + }); + + connection.on("OnTimelinePostChanged", handler); + + void connection.start().then(() => { + subscriber.next({ update: false, state: HubConnectionState.Connected }); + + return connection.invoke("SubscribeTimelinePostChange", timelineName); + }); + + return () => { + connection.off("OnTimelinePostChanged", handler); + + if (connection.state === HubConnectionState.Connected) { + void connection + .invoke("UnsubscribeTimelinePostChange", timelineName) + .then(() => connection.stop()); + } + }; + }); +} diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts new file mode 100644 index 00000000..3375c88a --- /dev/null +++ b/FrontEnd/src/services/user.ts @@ -0,0 +1,228 @@ +import { useState, useEffect } from "react"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { UiLogicError } from "@/common"; + +import { HttpNetworkError, setHttpToken } from "http/common"; +import { + getHttpTokenClient, + HttpCreateTokenBadCredentialError, +} from "http/token"; +import { getHttpUserClient, HttpUser, UserPermission } from "http/user"; + +import { pushAlert } from "./alert"; + +interface IAuthUser extends HttpUser { + token: string; +} + +export class AuthUser implements IAuthUser { + constructor(user: HttpUser, public token: string) { + this.uniqueId = user.uniqueId; + this.username = user.username; + this.permissions = user.permissions; + this.nickname = user.nickname; + } + + uniqueId: string; + username: string; + permissions: UserPermission[]; + nickname: string; + + get hasAdministrationPermission(): boolean { + return this.permissions.length !== 0; + } + + get hasAllTimelineAdministrationPermission(): boolean { + return this.permissions.includes("AllTimelineManagement"); + } + + get hasHighlightTimelineAdministrationPermission(): boolean { + return this.permissions.includes("HighlightTimelineManagement"); + } +} + +export interface LoginCredentials { + username: string; + password: string; +} + +export class BadCredentialError { + message = "login.badCredential"; +} + +const USER_STORAGE_KEY = "currentuser"; + +export class UserService { + constructor() { + this.userSubject.subscribe((u) => { + setHttpToken(u?.token ?? null); + }); + } + + private userSubject = new BehaviorSubject( + undefined + ); + + get user$(): Observable { + return this.userSubject; + } + + get currentUser(): AuthUser | null | undefined { + return this.userSubject.value; + } + + async checkLoginState(): Promise { + if (this.currentUser !== undefined) { + console.warn("Already checked user. Can't check twice."); + } + + const savedUserString = localStorage.getItem(USER_STORAGE_KEY); + + const savedAuthUserData = + savedUserString == null + ? null + : (JSON.parse(savedUserString) as IAuthUser); + + const savedUser = + savedAuthUserData == null + ? null + : new AuthUser(savedAuthUserData, savedAuthUserData.token); + + if (savedUser == null) { + this.userSubject.next(null); + return null; + } + + this.userSubject.next(savedUser); + + const savedToken = savedUser.token; + try { + const res = await getHttpTokenClient().verify({ token: savedToken }); + const user = new AuthUser(res.user, savedToken); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + this.userSubject.next(user); + pushAlert({ + type: "success", + message: "user.welcomeBack", + }); + return user; + } catch (error) { + if (error instanceof HttpNetworkError) { + pushAlert({ + type: "danger", + message: "user.verifyTokenFailedNetwork", + }); + return savedUser; + } else { + localStorage.removeItem(USER_STORAGE_KEY); + this.userSubject.next(null); + pushAlert({ + type: "danger", + message: "user.verifyTokenFailed", + }); + return null; + } + } + } + + async login( + credentials: LoginCredentials, + rememberMe: boolean + ): Promise { + if (this.currentUser) { + throw new UiLogicError("Already login."); + } + try { + const res = await getHttpTokenClient().create({ + ...credentials, + expire: 30, + }); + const user = new AuthUser(res.user, res.token); + if (rememberMe) { + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + } + this.userSubject.next(user); + } catch (e) { + if (e instanceof HttpCreateTokenBadCredentialError) { + throw new BadCredentialError(); + } else { + throw e; + } + } + } + + logout(): Promise { + if (this.currentUser === undefined) { + throw new UiLogicError("Please check user first."); + } + if (this.currentUser === null) { + throw new UiLogicError("No login."); + } + localStorage.removeItem(USER_STORAGE_KEY); + this.userSubject.next(null); + return Promise.resolve(); + } + + changePassword(oldPassword: string, newPassword: string): Promise { + if (this.currentUser == undefined) { + throw new UiLogicError("Not login or checked now, can't log out."); + } + + return getHttpUserClient() + .changePassword({ + oldPassword, + newPassword, + }) + .then(() => this.logout()); + } +} + +export const userService = new UserService(); + +export function useRawUser(): AuthUser | null | undefined { + const [user, setUser] = useState( + userService.currentUser + ); + useEffect(() => { + const subscription = userService.user$.subscribe((u) => setUser(u)); + return () => { + subscription.unsubscribe(); + }; + }); + return user; +} + +export function useUser(): AuthUser | null { + const [user, setUser] = useState(() => { + const initUser = userService.currentUser; + if (initUser === undefined) { + throw new UiLogicError( + "This is a logic error in user module. Current user can't be undefined in useUser." + ); + } + return initUser; + }); + useEffect(() => { + const sub = userService.user$.subscribe((u) => { + if (u === undefined) { + throw new UiLogicError( + "This is a logic error in user module. User emitted can't be undefined later." + ); + } + setUser(u); + }); + return () => { + sub.unsubscribe(); + }; + }); + return user; +} + +export function useUserLoggedIn(): AuthUser { + const user = useUser(); + if (user == null) { + throw new UiLogicError("You assert user has logged in but actually not."); + } + return user; +} diff --git a/FrontEnd/src/sw/sw.ts b/FrontEnd/src/sw/sw.ts deleted file mode 100644 index 724804dd..00000000 --- a/FrontEnd/src/sw/sw.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { precacheAndRoute, matchPrecache } from "workbox-precaching"; -import { registerRoute, setDefaultHandler } from "workbox-routing"; -import { - NetworkFirst, - NetworkOnly, - StaleWhileRevalidate, -} from "workbox-strategies"; -import { CacheableResponsePlugin } from "workbox-cacheable-response"; -import { ExpirationPlugin } from "workbox-expiration"; - -declare let self: ServiceWorkerGlobalScope; - -self.addEventListener("message", (event) => { - if (event.data && (event.data as { type: string }).type === "SKIP_WAITING") { - void self.skipWaiting(); - } -}); - -precacheAndRoute(self.__WB_MANIFEST); - -const networkOnly = new NetworkOnly(); - -registerRoute(new RegExp("/swagger/?.*"), new NetworkOnly()); - -registerRoute(new RegExp("/api/token/?.*"), new NetworkOnly()); -registerRoute(new RegExp("/api/search/?.*"), new NetworkOnly()); - -registerRoute( - new RegExp("/api/users/.+/avatar"), - new StaleWhileRevalidate({ - cacheName: "avatars", - plugins: [ - new CacheableResponsePlugin({ - statuses: [200], - }), - new ExpirationPlugin({ - maxAgeSeconds: 60 * 60 * 24 * 30 * 3, // 3 months - }), - ], - }) -); - -registerRoute( - new RegExp("/api/?.*"), - new NetworkFirst({ - plugins: [ - new CacheableResponsePlugin({ - statuses: [200], - }), - ], - }) -); - -setDefaultHandler((options) => { - const { request } = options; - - if (request instanceof Request && request.destination === "document") - return matchPrecache("/index.html").then((r) => - r == null ? Response.error() : r - ); - else return networkOnly.handle(options); -}); diff --git a/FrontEnd/src/sw/tsconfig.json b/FrontEnd/src/sw/tsconfig.json deleted file mode 100644 index 71fc0bcd..00000000 --- a/FrontEnd/src/sw/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "lib": [ - "esnext", - "webworker" - ] - }, - "include": [ - "." - ] -} diff --git a/FrontEnd/src/tsconfig.json b/FrontEnd/src/tsconfig.json index 21989043..817c50bd 100644 --- a/FrontEnd/src/tsconfig.json +++ b/FrontEnd/src/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "target": "ES6", "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -12,10 +11,13 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react", + "types": ["vite/client"], "sourceMap": true, "baseUrl": "./", "paths": { - "@/*": ["app/*"] - } - } + "@/*": ["*"] + }, + "lib": ["dom", "dom.iterable", "esnext"] + }, + "include": ["."] } diff --git a/FrontEnd/src/utilities/mediaQuery.ts b/FrontEnd/src/utilities/mediaQuery.ts new file mode 100644 index 00000000..ad55c3c0 --- /dev/null +++ b/FrontEnd/src/utilities/mediaQuery.ts @@ -0,0 +1,5 @@ +import { useMediaQuery } from "react-responsive"; + +export function useIsSmallScreen(): boolean { + return useMediaQuery({ maxWidth: 576 }); +} diff --git a/FrontEnd/src/utilities/url.ts b/FrontEnd/src/utilities/url.ts new file mode 100644 index 00000000..4f2a6ecd --- /dev/null +++ b/FrontEnd/src/utilities/url.ts @@ -0,0 +1,17 @@ +export function applyQueryParameters(url: string, query: T): string { + if (query == null) return url; + + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value == null) void 0; + else if (typeof value === "string") params.set(key, value); + else if (typeof value === "number") params.set(key, String(value)); + else if (typeof value === "boolean") params.set(key, String(value)); + else if (value instanceof Date) params.set(key, value.toISOString()); + else { + console.error("Unknown query parameter type. Param: ", value); + } + } + return url + "?" + params.toString(); +} diff --git a/FrontEnd/src/utilities/useReverseScrollPositionRemember.ts b/FrontEnd/src/utilities/useReverseScrollPositionRemember.ts new file mode 100644 index 00000000..a5812808 --- /dev/null +++ b/FrontEnd/src/utilities/useReverseScrollPositionRemember.ts @@ -0,0 +1,77 @@ +import React from "react"; + +let on = false; + +let reverseScrollPosition = getReverseScrollPosition(); +let reverseScrollToPosition: number | null = null; +let lastScrollPosition = window.scrollY; + +export function getReverseScrollPosition(): number { + if (document.documentElement.scrollHeight <= window.innerHeight) { + return 0; + } else { + return ( + document.documentElement.scrollHeight - + document.documentElement.scrollTop - + window.innerHeight + ); + } +} + +export function scrollToReverseScrollPosition(reversePosition: number): void { + if (document.documentElement.scrollHeight <= window.innerHeight) return; + + const old = document.documentElement.style.scrollBehavior; + document.documentElement.style.scrollBehavior = "auto"; + + const newPosition = + document.documentElement.scrollHeight - + window.innerHeight - + reversePosition; + + reverseScrollToPosition = newPosition; + + window.scrollTo(0, newPosition); + + document.documentElement.style.scrollBehavior = old; +} + +const scrollListener = (): void => { + if ( + reverseScrollToPosition != null && + Math.abs(window.scrollY - reverseScrollToPosition) > 50 + ) { + scrollToReverseScrollPosition(reverseScrollPosition); + return; + } + if ( + reverseScrollToPosition == null && + Math.abs(window.scrollY - lastScrollPosition) > 1000 + ) { + scrollToReverseScrollPosition(reverseScrollPosition); + return; + } + + reverseScrollToPosition = null; + lastScrollPosition = window.scrollY; + reverseScrollPosition = getReverseScrollPosition(); +}; + +const resizeObserver = new ResizeObserver(() => { + scrollToReverseScrollPosition(reverseScrollPosition); +}); + +export default function useReverseScrollPositionRemember(): void { + React.useEffect(() => { + if (on) return; + on = true; + window.addEventListener("scroll", scrollListener); + resizeObserver.observe(document.documentElement); + + return () => { + window.removeEventListener("scroll", scrollListener); + resizeObserver.disconnect(); + on = false; + }; + }, []); +} diff --git a/FrontEnd/src/utilities/useScrollToTop.ts b/FrontEnd/src/utilities/useScrollToTop.ts new file mode 100644 index 00000000..892e3545 --- /dev/null +++ b/FrontEnd/src/utilities/useScrollToTop.ts @@ -0,0 +1,43 @@ +import React from "react"; +import { fromEvent } from "rxjs"; +import { filter, throttleTime } from "rxjs/operators"; + +function useScrollToTop( + handler: () => void, + enable = true, + option = { + maxOffset: 50, + throttle: 1000, + } +): void { + const handlerRef = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + handlerRef.current = handler; + + return () => { + handlerRef.current = null; + }; + }, [handler]); + + React.useEffect(() => { + const subscription = fromEvent(window, "scroll") + .pipe( + filter(() => { + return window.scrollY <= option.maxOffset; + }), + throttleTime(option.throttle) + ) + .subscribe(() => { + if (enable) { + handlerRef.current?.(); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [enable, option.maxOffset, option.throttle]); +} + +export default useScrollToTop; diff --git a/FrontEnd/src/views/about/about.sass b/FrontEnd/src/views/about/about.sass new file mode 100644 index 00000000..f4d00cae --- /dev/null +++ b/FrontEnd/src/views/about/about.sass @@ -0,0 +1,4 @@ +.about-link-icon + @extend .mx-2 + width: 1.2em + height: 1.2em diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png new file mode 100644 index 00000000..d890d8d0 Binary files /dev/null and b/FrontEnd/src/views/about/author-avatar.png differ diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png new file mode 100644 index 00000000..ea6ff545 Binary files /dev/null and b/FrontEnd/src/views/about/github.png differ diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx new file mode 100644 index 00000000..a8a53a97 --- /dev/null +++ b/FrontEnd/src/views/about/index.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { useTranslation, Trans } from "react-i18next"; + +import authorAvatarUrl from "./author-avatar.png"; +import githubLogoUrl from "./github.png"; + +const frontendCredits: { + name: string; + url: string; +}[] = [ + { + name: "reactjs", + url: "https://reactjs.org", + }, + { + name: "typescript", + url: "https://www.typescriptlang.org", + }, + { + name: "bootstrap", + url: "https://getbootstrap.com", + }, + { + name: "react-bootstrap", + url: "https://react-bootstrap.github.io", + }, + { + name: "webpack", + url: "https://webpack.js.org", + }, + { + name: "sass", + url: "https://sass-lang.com", + }, + { + name: "eslint", + url: "https://eslint.org", + }, + { + name: "prettier", + url: "https://prettier.io", + }, + { + name: "pepjs", + url: "https://github.com/jquery/PEP", + }, +]; + +const backendCredits: { + name: string; + url: string; +}[] = [ + { + name: "ASP.NET Core", + url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", + }, + { name: "sqlite", url: "https://sqlite.org" }, + { + name: "ImageSharp", + url: "https://github.com/SixLabors/ImageSharp", + }, +]; + +const AboutPage: React.FC = () => { + const { t } = useTranslation(); + + return ( +
+
+

{t("about.author.title")}

+
+
+ +
+

+ {t("about.author.fullname")} + 杨宇千 +

+

+ {t("about.author.nickname")} + crupest +

+

+ {t("about.author.introduction")} + {t("about.author.introductionContent")} +

+
+
+

+ {t("about.author.links")} + + + +

+
+
+
+

{t("about.site.title")}

+

+ + 01234 + 56 + +

+

+ + {t("about.site.repo")} + +

+
+
+

{t("about.credits.title")}

+

{t("about.credits.content")}

+

{t("about.credits.frontend")}

+
    + {frontendCredits.map((item, index) => { + return ( +
  • + + {item.name} + +
  • + ); + })} +
  • ...
  • +
+

{t("about.credits.backend")}

+
    + {backendCredits.map((item, index) => { + return ( +
  • + + {item.name} + +
  • + ); + })} +
  • ...
  • +
+
+
+ ); +}; + +export default AboutPage; diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/views/admin/Admin.tsx new file mode 100644 index 00000000..0b6d1f05 --- /dev/null +++ b/FrontEnd/src/views/admin/Admin.tsx @@ -0,0 +1,48 @@ +import React, { Fragment } from "react"; +import { Redirect, Route, Switch, useRouteMatch, match } from "react-router"; +import { Container } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { AuthUser } from "@/services/user"; + +import AdminNav from "./AdminNav"; +import UserAdmin from "./UserAdmin"; +import MoreAdmin from "./MoreAdmin"; + +interface AdminProps { + user: AuthUser; +} + +const Admin: React.FC = ({ user }) => { + useTranslation("admin"); + + const match = useRouteMatch(); + + return ( + + + + + {(p) => { + const match = p.match as match<{ name: string }>; + const name = match.params["name"]; + return ( + + + {(() => { + if (name === "users") { + return ; + } else if (name === "more") { + return ; + } + })()} + + ); + }} + + + + ); +}; + +export default Admin; diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/views/admin/AdminNav.tsx new file mode 100644 index 00000000..47e2138f --- /dev/null +++ b/FrontEnd/src/views/admin/AdminNav.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Nav } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useHistory, useRouteMatch } from "react-router"; + +const AdminNav: React.FC = () => { + const match = useRouteMatch<{ name: string }>(); + const history = useHistory(); + + const { t } = useTranslation(); + + const name = match.params.name; + + function toggle(newTab: string): void { + history.push(`/admin/${newTab}`); + } + + return ( + + ); +}; + +export default AdminNav; diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/views/admin/MoreAdmin.tsx new file mode 100644 index 00000000..042789a0 --- /dev/null +++ b/FrontEnd/src/views/admin/MoreAdmin.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +import { AuthUser } from "@/services/user"; + +export interface MoreAdminProps { + user: AuthUser; +} + +const MoreAdmin: React.FC = () => { + return <>More...; +}; + +export default MoreAdmin; diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx new file mode 100644 index 00000000..4e9cd600 --- /dev/null +++ b/FrontEnd/src/views/admin/UserAdmin.tsx @@ -0,0 +1,396 @@ +import React, { useState, useEffect } from "react"; +import classnames from "classnames"; +import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap"; + +import OperationDialog, { + OperationDialogBoolInput, +} from "../common/OperationDialog"; + +import { AuthUser } from "@/services/user"; +import { + getHttpUserClient, + HttpUser, + kUserPermissionList, + UserPermission, +} from "http/user"; +import { Trans, useTranslation } from "react-i18next"; + +interface DialogProps { + open: boolean; + close: () => void; + data: TData; + onSuccess: (data: TReturn) => void; +} + +const CreateUserDialog: React.FC> = ({ + open, + close, + onSuccess, +}) => { + return ( + + getHttpUserClient().post({ + username, + password, + }) + } + close={close} + open={open} + onSuccessAndClose={onSuccess} + /> + ); +}; + +const UsernameLabel: React.FC = (props) => { + return {props.children}; +}; + +const UserDeleteDialog: React.FC> = + ({ open, close, data: { username }, onSuccess }) => { + return ( + ( + + 0{username}2 + + )} + onProcess={() => getHttpUserClient().delete(username)} + onSuccessAndClose={onSuccess} + /> + ); + }; + +const UserModifyDialog: React.FC< + DialogProps< + { + oldUser: HttpUser; + }, + HttpUser + > +> = ({ open, close, data: { oldUser }, onSuccess }) => { + return ( + ( + + 0{oldUser.username}2 + + )} + inputScheme={ + [ + { + type: "text", + label: "admin:user.username", + initValue: oldUser.username, + }, + { type: "text", label: "admin:user.password" }, + { + type: "text", + label: "admin:user.nickname", + initValue: oldUser.nickname, + }, + ] as const + } + onProcess={([username, password, nickname]) => + getHttpUserClient().patch(oldUser.username, { + username: username !== oldUser.username ? username : undefined, + password: password !== "" ? password : undefined, + nickname: nickname !== oldUser.nickname ? nickname : undefined, + }) + } + onSuccessAndClose={onSuccess} + /> + ); +}; + +const UserPermissionModifyDialog: React.FC< + DialogProps< + { + username: string; + permissions: UserPermission[]; + }, + UserPermission[] + > +> = ({ open, close, data: { username, permissions }, onSuccess }) => { + const oldPermissionBoolList: boolean[] = kUserPermissionList.map( + (permission) => permissions.includes(permission) + ); + + return ( + ( + + 0{username}2 + + )} + inputScheme={kUserPermissionList.map( + (permission, index) => ({ + type: "bool", + label: permission, + initValue: oldPermissionBoolList[index], + }) + )} + onProcess={async (newPermissionBoolList): Promise => { + for (let index = 0; index < kUserPermissionList.length; index++) { + const oldValue = oldPermissionBoolList[index]; + const newValue = newPermissionBoolList[index]; + const permission = kUserPermissionList[index]; + if (oldValue === newValue) continue; + if (newValue) { + await getHttpUserClient().putUserPermission(username, permission); + } else { + await getHttpUserClient().deleteUserPermission( + username, + permission + ); + } + } + return newPermissionBoolList; + }} + onSuccessAndClose={(newPermissionBoolList: boolean[]) => { + const permissions: UserPermission[] = []; + for (let index = 0; index < kUserPermissionList.length; index++) { + if (newPermissionBoolList[index]) { + permissions.push(kUserPermissionList[index]); + } + } + onSuccess(permissions); + }} + /> + ); +}; + +const kModify = "modify"; +const kModifyPermission = "permission"; +const kDelete = "delete"; + +type TModify = typeof kModify; +type TModifyPermission = typeof kModifyPermission; +type TDelete = typeof kDelete; + +type ContextMenuItem = TModify | TModifyPermission | TDelete; + +interface UserItemProps { + on: { [key in ContextMenuItem]: () => void }; + user: HttpUser; +} + +const UserItem: React.FC = ({ user, on }) => { + const { t } = useTranslation(); + + const [editMaskVisible, setEditMaskVisible] = React.useState(false); + + return ( + + setEditMaskVisible(true)} + /> +

{user.username}

+
+ {t("admin:user.nickname")} + {user.nickname} +
+
+ {t("admin:user.uniqueId")} + {user.uniqueId} +
+
+ {t("admin:user.permissions")} + {user.permissions.map((permission) => { + return ( + + {permission}{" "} + + ); + })} +
+
setEditMaskVisible(false)} + > + + + +
+
+ ); +}; + +interface UserAdminProps { + user: AuthUser; +} + +const UserAdmin: React.FC = () => { + const { t } = useTranslation(); + + type DialogInfo = + | null + | { + type: "create"; + } + | { + type: TModify; + user: HttpUser; + } + | { + type: TModifyPermission; + username: string; + permissions: UserPermission[]; + } + | { type: TDelete; username: string }; + + const [users, setUsers] = useState(null); + const [dialog, setDialog] = useState(null); + const [usersVersion, setUsersVersion] = useState(0); + const updateUsers = (): void => { + setUsersVersion(usersVersion + 1); + }; + + useEffect(() => { + let subscribe = true; + void getHttpUserClient() + .list() + .then((us) => { + if (subscribe) { + setUsers(us); + } + }); + return () => { + subscribe = false; + }; + }, [usersVersion]); + + let dialogNode: React.ReactNode; + if (dialog) { + switch (dialog.type) { + case "create": + dialogNode = ( + setDialog(null)} + data={undefined} + onSuccess={updateUsers} + /> + ); + break; + case kDelete: + dialogNode = ( + setDialog(null)} + data={{ username: dialog.username }} + onSuccess={updateUsers} + /> + ); + break; + case kModify: + dialogNode = ( + setDialog(null)} + data={{ oldUser: dialog.user }} + onSuccess={updateUsers} + /> + ); + break; + case kModifyPermission: + dialogNode = ( + setDialog(null)} + data={{ + username: dialog.username, + permissions: dialog.permissions, + }} + onSuccess={updateUsers} + /> + ); + break; + } + } + + if (users) { + const userComponents = users.map((user) => { + return ( + { + setDialog({ + type: "modify", + user, + }); + }, + permission: () => { + setDialog({ + type: kModifyPermission, + username: user.username, + permissions: user.permissions, + }); + }, + delete: () => { + setDialog({ + type: "delete", + username: user.username, + }); + }, + }} + /> + ); + }); + + return ( + <> + + + + + + {userComponents} + {dialogNode} + + ); + } else { + return ; + } +}; + +export default UserAdmin; diff --git a/FrontEnd/src/views/admin/admin.sass b/FrontEnd/src/views/admin/admin.sass new file mode 100644 index 00000000..1ce010f8 --- /dev/null +++ b/FrontEnd/src/views/admin/admin.sass @@ -0,0 +1,22 @@ +.admin-user-item + position: relative + + .edit-mask + position: absolute + top: 0 + left: 0 + bottom: 0 + right: 0 + + background: #ffffffc5 + position: absolute + + display: flex + justify-content: center + align-items: center + + @include media-breakpoint-down(xs) + flex-direction: column + + button + margin: 0.5em 2em diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/views/center/CenterBoards.tsx new file mode 100644 index 00000000..431d1e9a --- /dev/null +++ b/FrontEnd/src/views/center/CenterBoards.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Row, Col } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { pushAlert } from "@/services/alert"; +import { useUserLoggedIn } from "@/services/user"; + +import { getHttpTimelineClient } from "http/timeline"; +import { getHttpBookmarkClient } from "http/bookmark"; +import { getHttpHighlightClient } from "http/highlight"; + +import TimelineBoard from "./TimelineBoard"; + +const CenterBoards: React.FC = () => { + const { t } = useTranslation(); + + const user = useUserLoggedIn(); + + return ( + <> + + + + + getHttpBookmarkClient().list()} + editHandler={{ + onDelete: (timeline) => { + return getHttpBookmarkClient() + .delete(timeline) + .catch((e) => { + pushAlert({ + message: "home.message.deleteBookmarkFail", + type: "danger", + }); + throw e; + }); + }, + onMove: (timeline, index, offset) => { + return getHttpBookmarkClient() + .move( + { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1 + ) + .catch((e) => { + pushAlert({ + message: "home.message.moveBookmarkFail", + type: "danger", + }); + throw e; + }); + }, + }} + /> + + + getHttpHighlightClient().list()} + editHandler={ + user.hasHighlightTimelineAdministrationPermission + ? { + onDelete: (timeline) => { + return getHttpHighlightClient() + .delete(timeline) + .catch((e) => { + pushAlert({ + message: "home.message.deleteHighlightFail", + type: "danger", + }); + throw e; + }); + }, + onMove: (timeline, index, offset) => { + return getHttpHighlightClient() + .move( + { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1 + ) + .catch((e) => { + pushAlert({ + message: "home.message.moveHighlightFail", + type: "danger", + }); + throw e; + }); + }, + } + : undefined + } + /> + + + + + + getHttpTimelineClient().listTimeline({ relate: user.username }) + } + /> + + + + ); +}; + +export default CenterBoards; diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx new file mode 100644 index 00000000..bb80266b --- /dev/null +++ b/FrontEnd/src/views/center/TimelineBoard.tsx @@ -0,0 +1,370 @@ +import React from "react"; +import classnames from "classnames"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Spinner } from "react-bootstrap"; + +import { HttpTimelineInfo } from "http/timeline"; + +import TimelineLogo from "../common/TimelineLogo"; +import UserTimelineLogo from "../common/UserTimelineLogo"; +import LoadFailReload from "../common/LoadFailReload"; + +interface TimelineBoardItemProps { + timeline: HttpTimelineInfo; + // In height. + offset?: number; + // In px. + arbitraryOffset?: number; + // If not null, will disable navigation on click. + actions?: { + onDelete: () => void; + onMove: { + start: (e: React.PointerEvent) => void; + moving: (e: React.PointerEvent) => void; + end: (e: React.PointerEvent) => void; + }; + }; +} + +const TimelineBoardItem: React.FC = ({ + timeline, + arbitraryOffset, + offset, + actions, +}) => { + const { name, title } = timeline; + const isPersonal = name.startsWith("@"); + const url = isPersonal + ? `/users/${timeline.owner.username}` + : `/timelines/${name}`; + + const content = ( + <> + {isPersonal ? ( + + ) : ( + + )} + {title} + {name} + + {actions != null ? ( +
+ + { + e.currentTarget.setPointerCapture(e.pointerId); + actions.onMove.start(e); + }} + onPointerUp={(e) => { + actions.onMove.end(e); + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch (_) { + void null; + } + }} + onPointerMove={actions.onMove.moving} + /> +
+ ) : null} + + ); + + const offsetStyle: React.CSSProperties = { + transform: + arbitraryOffset != null + ? `translate(0,${arbitraryOffset}px)` + : offset != null + ? `translate(0,${offset * 100}%)` + : undefined, + transition: offset != null ? "transform 0.5s" : undefined, + zIndex: arbitraryOffset != null ? 1 : undefined, + }; + + return actions == null ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +}; + +interface TimelineBoardItemContainerProps { + timelines: HttpTimelineInfo[]; + editHandler?: { + // offset may exceed index range plusing index. + onMove: (timeline: string, index: number, offset: number) => void; + onDelete: (timeline: string) => void; + }; +} + +const TimelineBoardItemContainer: React.FC = ({ + timelines, + editHandler, +}) => { + const [moveState, setMoveState] = React.useState(null); + + return ( + <> + {timelines.map((timeline, index) => { + const height = 48; + + let offset: number | undefined = undefined; + let arbitraryOffset: number | undefined = undefined; + if (moveState != null) { + if (index === moveState.index) { + arbitraryOffset = moveState.offset; + } else { + if (moveState.offset >= 0) { + const offsetCount = Math.round(moveState.offset / height); + if ( + index > moveState.index && + index <= moveState.index + offsetCount + ) { + offset = -1; + } else { + offset = 0; + } + } else { + const offsetCount = Math.round(-moveState.offset / height); + if ( + index < moveState.index && + index >= moveState.index - offsetCount + ) { + offset = 1; + } else { + offset = 0; + } + } + } + } + + return ( + { + editHandler.onDelete(timeline.name); + }, + onMove: { + start: (e) => { + if (moveState != null) return; + setMoveState({ + index, + offset: 0, + startPointY: e.clientY, + }); + }, + moving: (e) => { + if (moveState == null) return; + setMoveState({ + index, + offset: e.clientY - moveState.startPointY, + startPointY: moveState.startPointY, + }); + }, + end: () => { + if (moveState != null) { + const offsetCount = Math.round( + moveState.offset / height + ); + editHandler.onMove( + timeline.name, + moveState.index, + offsetCount + ); + } + setMoveState(null); + }, + }, + } + : undefined + } + /> + ); + })} + + ); +}; + +interface TimelineBoardUIProps { + title?: string; + timelines: HttpTimelineInfo[] | "offline" | "loading"; + onReload: () => void; + className?: string; + editHandler?: { + onMove: (timeline: string, index: number, offset: number) => void; + onDelete: (timeline: string) => void; + }; +} + +const TimelineBoardUI: React.FC = (props) => { + const { title, timelines, className, editHandler } = props; + + const { t } = useTranslation(); + + const editable = editHandler != null; + + const [editing, setEditing] = React.useState(false); + + return ( +
+
+ {title != null &&

{title}

} + {editable && + (editing ? ( +
{ + setEditing(false); + }} + > + {t("done")} +
+ ) : ( +
{ + setEditing(true); + }} + > + {t("edit")} +
+ ))} +
+ {(() => { + if (timelines === "loading") { + return ( +
+ +
+ ); + } else if (timelines === "offline") { + return ( +
+ +
+ ); + } else { + return ( + { + if (index + offset >= timelines.length) { + offset = timelines.length - index - 1; + } else if (index + offset < 0) { + offset = -index; + } + editHandler.onMove(timeline, index, offset); + }, + } + : undefined + } + /> + ); + } + })()} +
+ ); +}; + +export interface TimelineBoardProps { + title?: string; + className?: string; + load: () => Promise; + editHandler?: { + onMove: (timeline: string, index: number, offset: number) => Promise; + onDelete: (timeline: string) => Promise; + }; +} + +const TimelineBoard: React.FC = ({ + className, + title, + load, + editHandler, +}) => { + const [timelines, setTimelines] = React.useState< + HttpTimelineInfo[] | "offline" | "loading" + >("loading"); + + React.useEffect(() => { + let subscribe = true; + if (timelines === "loading") { + void load().then( + (timelines) => { + if (subscribe) { + setTimelines(timelines); + } + }, + () => { + setTimelines("offline"); + } + ); + } + return () => { + subscribe = false; + }; + }, [load, timelines]); + + return ( + { + setTimelines("loading"); + }} + editHandler={ + typeof timelines === "object" && editHandler != null + ? { + onMove: (timeline, index, offset) => { + const newTimelines = timelines.slice(); + const [t] = newTimelines.splice(index, 1); + newTimelines.splice(index + offset, 0, t); + setTimelines(newTimelines); + editHandler.onMove(timeline, index, offset).then(null, () => { + setTimelines(timelines); + }); + }, + onDelete: (timeline) => { + const newTimelines = timelines.slice(); + newTimelines.splice( + timelines.findIndex((t) => t.name === timeline), + 1 + ); + setTimelines(newTimelines); + editHandler.onDelete(timeline).then(null, () => { + setTimelines(timelines); + }); + }, + } + : undefined + } + /> + ); +}; + +export default TimelineBoard; diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx new file mode 100644 index 00000000..a2437ae5 --- /dev/null +++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useHistory } from "react-router"; + +import { validateTimelineName } from "@/services/timeline"; +import OperationDialog from "../common/OperationDialog"; +import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline"; + +interface TimelineCreateDialogProps { + open: boolean; + close: () => void; +} + +const TimelineCreateDialog: React.FC = (props) => { + const history = useHistory(); + + return ( + { + if (name.length === 0) { + return { 0: "home.createDialog.noEmpty" }; + } else if (name.length > 26) { + return { 0: "home.createDialog.tooLong" }; + } else if (!validateTimelineName(name)) { + return { 0: "home.createDialog.badFormat" }; + } else { + return null; + } + }} + onProcess={([name]): Promise => + getHttpTimelineClient().postTimeline({ name }) + } + onSuccessAndClose={(timeline: HttpTimelineInfo) => { + history.push(`timelines/${timeline.name}`); + }} + failurePrompt={(e) => `${e as string}`} + /> + ); +}; + +export default TimelineCreateDialog; diff --git a/FrontEnd/src/views/center/center.sass b/FrontEnd/src/views/center/center.sass new file mode 100644 index 00000000..c0dfb9c0 --- /dev/null +++ b/FrontEnd/src/views/center/center.sass @@ -0,0 +1,36 @@ +.timeline-board + @extend .cru-card + @extend .d-flex + @extend .flex-column + @extend .py-3 + min-height: 200px + height: 100% + position: relative + +.timeline-board-header + @extend .px-3 + display: flex + align-items: center + justify-content: space-between + +.timeline-board-item + font-size: 1.1em + @extend .px-3 + height: 48px + transition: background 0.3s + display: flex + align-items: center + .icon + height: 1.3em + color: black + @extend .me-2 + &:hover + background: $gray-300 + .right + display: flex + align-items: center + flex-shrink: 0 + .title + white-space: nowrap + overflow: hidden + text-overflow: ellipsis diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/views/center/index.tsx new file mode 100644 index 00000000..0a2abb2c --- /dev/null +++ b/FrontEnd/src/views/center/index.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Row, Container, Button, Col } from "react-bootstrap"; + +import { useUserLoggedIn } from "@/services/user"; + +import SearchInput from "../common/SearchInput"; +import CenterBoards from "./CenterBoards"; +import TimelineCreateDialog from "./TimelineCreateDialog"; + +const HomePage: React.FC = () => { + const history = useHistory(); + + const { t } = useTranslation(); + + const user = useUserLoggedIn(); + + const [navText, setNavText] = React.useState(""); + + const [dialog, setDialog] = React.useState<"create" | null>(null); + + return ( + <> + + + + { + history.push(`search?q=${navText}`); + }} + additionalButton={ + user != null && ( + + ) + } + /> + + + + + {dialog === "create" && ( + { + setDialog(null); + }} + /> + )} + + ); +}; + +export default HomePage; diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx new file mode 100644 index 00000000..91dfbee9 --- /dev/null +++ b/FrontEnd/src/views/common/AppBar.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Link, NavLink } from "react-router-dom"; +import classnames from "classnames"; +import { useMediaQuery } from "react-responsive"; + +import { useUser } from "@/services/user"; + +import TimelineLogo from "./TimelineLogo"; +import UserAvatar from "./user/UserAvatar"; + +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(false); + const collapse = (): void => setExpand(false); + const toggleExpand = (): void => setExpand(!expand); + + const createLink = ( + link: string, + label: React.ReactNode, + className?: string + ): React.ReactNode => ( + + {label} + + ); + + return ( + + ); +}; + +export default AppBar; diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx new file mode 100644 index 00000000..0dd25c52 --- /dev/null +++ b/FrontEnd/src/views/common/BlobImage.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +const BlobImage: React.FC< + Omit, "src"> & { + blob?: Blob | unknown; + } +> = (props) => { + const { blob, ...otherProps } = props; + + const [url, setUrl] = React.useState(undefined); + + React.useEffect(() => { + if (blob instanceof Blob) { + const url = URL.createObjectURL(blob); + setUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setUrl(undefined); + } + }, [blob]); + + return ; +}; + +export default BlobImage; diff --git a/FrontEnd/src/views/common/ConfirmDialog.tsx b/FrontEnd/src/views/common/ConfirmDialog.tsx new file mode 100644 index 00000000..72940c51 --- /dev/null +++ b/FrontEnd/src/views/common/ConfirmDialog.tsx @@ -0,0 +1,40 @@ +import { convertI18nText, I18nText } from "@/common"; +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +const ConfirmDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; + title: I18nText; + body: I18nText; +}> = ({ onClose, onConfirm, title, body }) => { + const { t } = useTranslation(); + + return ( + + + + {convertI18nText(title, t)} + + + {convertI18nText(body, t)} + + + + + + ); +}; + +export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/FlatButton.tsx b/FrontEnd/src/views/common/FlatButton.tsx new file mode 100644 index 00000000..b1f7a051 --- /dev/null +++ b/FrontEnd/src/views/common/FlatButton.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import classnames from "classnames"; + +import { BootstrapThemeColor } from "@/common"; + +export interface FlatButtonProps { + variant?: BootstrapThemeColor | string; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + onClick?: () => void; +} + +const FlatButton: React.FC = (props) => { + const { disabled, className, style } = props; + const variant = props.variant ?? "primary"; + + const onClick = disabled ? undefined : props.onClick; + + return ( +
+ {props.children} +
+ ); +}; + +export default FlatButton; diff --git a/FrontEnd/src/views/common/FullPage.tsx b/FrontEnd/src/views/common/FullPage.tsx new file mode 100644 index 00000000..1b59045a --- /dev/null +++ b/FrontEnd/src/views/common/FullPage.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import classnames from "classnames"; + +export interface FullPageProps { + show: boolean; + onBack: () => void; + contentContainerClassName?: string; +} + +const FullPage: React.FC = ({ + show, + onBack, + children, + contentContainerClassName, +}) => { + return ( +
+
+ +
+
+ {children} +
+
+ ); +}; + +export default FullPage; diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx new file mode 100644 index 00000000..2ef5b7ed --- /dev/null +++ b/FrontEnd/src/views/common/ImageCropper.tsx @@ -0,0 +1,306 @@ +import React from "react"; +import classnames from "classnames"; + +import { UiLogicError } from "@/common"; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + imageUrl: string; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, imageUrl, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState( + null + ); + const [imageInfo, setImageInfo] = React.useState(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(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) => { + 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 ( +
+ to crop +
+
+
+
+
+ ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string +): Promise { + 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/LoadFailReload.tsx b/FrontEnd/src/views/common/LoadFailReload.tsx new file mode 100644 index 00000000..a80e7b76 --- /dev/null +++ b/FrontEnd/src/views/common/LoadFailReload.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Trans } from "react-i18next"; + +export interface LoadFailReloadProps { + className?: string; + style?: React.CSSProperties; + onReload: () => void; +} + +const LoadFailReload: React.FC = ({ + onReload, + className, + style, +}) => { + return ( + + 0 + { + onReload(); + e.preventDefault(); + }} + > + 1 + + 2 + + ); +}; + +export default LoadFailReload; diff --git a/FrontEnd/src/views/common/LoadingButton.tsx b/FrontEnd/src/views/common/LoadingButton.tsx new file mode 100644 index 00000000..cd9f1adc --- /dev/null +++ b/FrontEnd/src/views/common/LoadingButton.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Button, ButtonProps, Spinner } from "react-bootstrap"; + +const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({ + loading, + variant, + disabled, + ...otherProps +}) => { + return ( + + ); +}; + +export default LoadingButton; diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx new file mode 100644 index 00000000..590fafa0 --- /dev/null +++ b/FrontEnd/src/views/common/LoadingPage.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Spinner } from "react-bootstrap"; + +const LoadingPage: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default LoadingPage; diff --git a/FrontEnd/src/views/common/Menu.tsx b/FrontEnd/src/views/common/Menu.tsx new file mode 100644 index 00000000..ae73a331 --- /dev/null +++ b/FrontEnd/src/views/common/Menu.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import classnames from "classnames"; +import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common"; + +export type MenuItem = + | { + type: "divider"; + } + | { + type: "button"; + text: I18nText; + iconClassName?: string; + color?: BootstrapThemeColor; + onClick: () => void; + }; + +export type MenuItems = MenuItem[]; + +export interface MenuProps { + items: MenuItems; + className?: string; + onItemClicked?: () => void; +} + +const Menu: React.FC = ({ items, className, onItemClicked }) => { + const { t } = useTranslation(); + + return ( +
+ {items.map((item, index) => { + if (item.type === "divider") { + return
; + } else { + return ( +
{ + item.onClick(); + onItemClicked?.(); + }} + > + {item.iconClassName != null ? ( + + ) : null} + {convertI18nText(item.text, t)} +
+ ); + } + })} +
+ ); +}; + +export default Menu; + +export interface PopupMenuProps { + items: MenuItems; + children: OverlayTriggerProps["children"]; +} + +export const PopupMenu: React.FC = ({ items, children }) => { + const [show, setShow] = React.useState(false); + const toggle = (): void => setShow(!show); + + return ( + + setShow(false)} /> + + } + show={show} + onToggle={toggle} + > + {children} + + ); +}; diff --git a/FrontEnd/src/views/common/OperationDialog.tsx b/FrontEnd/src/views/common/OperationDialog.tsx new file mode 100644 index 00000000..ac4c51b9 --- /dev/null +++ b/FrontEnd/src/views/common/OperationDialog.tsx @@ -0,0 +1,471 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Form, Button, Modal } from "react-bootstrap"; +import { TwitterPicker } from "react-color"; +import moment from "moment"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +import LoadingButton from "./LoadingButton"; + +interface DefaultErrorPromptProps { + error?: string; +} + +const DefaultErrorPrompt: React.FC = (props) => { + const { t } = useTranslation(); + + let result =

{t("operationDialog.error")}

; + + if (props.error != null) { + result = ( + <> + {result} +

{props.error}

+ + ); + } + + return result; +}; + +export interface OperationDialogTextInput { + type: "text"; + label?: I18nText; + password?: boolean; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes, + "type" | "value" | "onChange" | "aria-relevant" + >; + helperText?: string; +} + +export interface OperationDialogBoolInput { + type: "bool"; + label: I18nText; + initValue?: boolean; +} + +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; +} + +export type OperationDialogInput = + | OperationDialogTextInput + | OperationDialogBoolInput + | OperationDialogSelectInput + | OperationDialogColorInput + | OperationDialogDateTimeInput; + +interface OperationInputTypeStringToValueTypeMap { + text: string; + bool: boolean; + select: string; + color: string | null; + datetime: string; +} + +type MapOperationInputTypeStringToValueType = + Type extends keyof OperationInputTypeStringToValueTypeMap + ? OperationInputTypeStringToValueTypeMap[Type] + : never; + +type MapOperationInputInfoValueType = T extends OperationDialogInput + ? MapOperationInputTypeStringToValueType + : T; + +const initValueMapperMap: { + [T in OperationDialogInput as T["type"]]: ( + item: T + ) => MapOperationInputInfoValueType; +} = { + 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; +} & { 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; + close: () => void; + title: I18nText | (() => React.ReactNode); + themeColor?: "danger" | "success" | string; + onProcess: ( + inputs: MapOperationInputInfoValueTypeList + ) => Promise; + inputScheme?: OperationInputInfoList; + inputValidator?: ( + inputs: MapOperationInputInfoValueTypeList + ) => 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 +): 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({ type: "input" }); + + type ValueType = boolean | string | null | undefined; + + const [values, setValues] = useState( + 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(() => + inputScheme.map(() => false) + ); + const [inputError, setInputError] = useState(); + + const close = (): void => { + if (step.type !== "process") { + props.close(); + 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 + ) + .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 =
{inputPrompt}
; + + const validate = (values: ValueType[]): boolean => { + const { inputValidator } = props; + if (inputValidator != null) { + const result = inputValidator( + values as unknown as MapOperationInputInfoValueTypeList + ); + 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 = ( + <> + + {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 ( + + {item.label && ( + {convertI18nText(item.label, t)} + )} + { + const v = e.target.value; + updateValue(index, v); + }} + isInvalid={error != null} + disabled={process} + /> + {error != null && ( + + {error} + + )} + {item.helperText && ( + {t(item.helperText)} + )} + + ); + } else if (item.type === "bool") { + return ( + + + type="checkbox" + checked={value as boolean} + onChange={(event) => { + updateValue(index, event.currentTarget.checked); + }} + label={convertI18nText(item.label, t)} + disabled={process} + /> + + ); + } else if (item.type === "select") { + return ( + + {convertI18nText(item.label, t)} + { + updateValue(index, event.target.value); + }} + disabled={process} + > + {item.options.map((option, i) => { + return ( + + ); + })} + + + ); + } else if (item.type === "color") { + return ( + + {item.canBeNull ? ( + + type="checkbox" + checked={value !== null} + onChange={(event) => { + if (event.currentTarget.checked) { + updateValue(index, "#007bff"); + } else { + updateValue(index, null); + } + }} + label={convertI18nText(item.label, t)} + disabled={process} + /> + ) : ( + {convertI18nText(item.label, t)} + )} + {value !== null && ( + updateValue(index, result.hex)} + /> + )} + + ); + } else if (item.type === "datetime") { + return ( + + {item.label && ( + {convertI18nText(item.label, t)} + )} + { + const v = e.target.value; + updateValue(index, v); + }} + isInvalid={error != null} + disabled={process} + /> + {error != null && ( + + {error} + + )} + + ); + } + })} + + + + { + setDirtyList(inputScheme.map(() => true)); + if (validate(values)) { + onConfirm(); + } + }} + > + {t("operationDialog.confirm")} + + + + ); + } 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 =

{content}

; + } else { + content = props.failurePrompt?.(result.data) ?? ; + if (typeof content === "string") + content = ; + } + body = ( + <> + {content} + + + + + ); + } + + const title = + typeof props.title === "function" + ? props.title() + : convertI18nText(props.title, t); + + return ( + + + {title} + + {body} + + ); +}; + +export default OperationDialog; diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx new file mode 100644 index 00000000..ccb6dad6 --- /dev/null +++ b/FrontEnd/src/views/common/SearchInput.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from "react"; +import classnames from "classnames"; +import { useTranslation } from "react-i18next"; +import { Spinner, Form, Button } from "react-bootstrap"; + +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 = (props) => { + const { onChange, onButtonClick, alwaysOneline } = props; + + const { t } = useTranslation(); + + const onInputChange = useCallback( + (event: React.ChangeEvent): void => { + onChange(event.currentTarget.value); + }, + [onChange] + ); + + const onInputKeyPress = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === "Enter") { + onButtonClick(); + event.preventDefault(); + } + }, + [onButtonClick] + ); + + return ( +
+ + {props.additionalButton ? ( +
+ {props.additionalButton} +
+ ) : null} +
+ {props.loading ? ( + + ) : ( + + )} +
+ + ); +}; + +export default SearchInput; diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx new file mode 100644 index 00000000..14886c71 --- /dev/null +++ b/FrontEnd/src/views/common/Skeleton.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import classnames from "classnames"; +import { range } from "lodash"; + +export interface SkeletonProps { + lineNumber?: number; + className?: string; + style?: React.CSSProperties; +} + +const Skeleton: React.FC = (props) => { + const { lineNumber: lineNumberProps, className, style } = props; + const lineNumber = lineNumberProps ?? 3; + + return ( +
+ {range(lineNumber).map((i) => ( +
+ ))} +
+ ); +}; + +export default Skeleton; diff --git a/FrontEnd/src/views/common/TabPages.tsx b/FrontEnd/src/views/common/TabPages.tsx new file mode 100644 index 00000000..2b1d91cb --- /dev/null +++ b/FrontEnd/src/views/common/TabPages.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Nav } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +export interface TabPage { + id: string; + tabText: I18nText; + page: React.ReactNode; +} + +export interface TabPagesProps { + pages: TabPage[]; + actions?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + navClassName?: string; + navStyle?: React.CSSProperties; + pageContainerClassName?: string; + pageContainerStyle?: React.CSSProperties; +} + +const TabPages: React.FC = ({ + pages, + actions, + className, + style, + navClassName, + navStyle, + pageContainerClassName, + pageContainerStyle, +}) => { + if (pages.length === 0) { + throw new UiLogicError("Page list can't be empty."); + } + + const { t } = useTranslation(); + + const [tab, setTab] = React.useState(pages[0].id); + + const currentPage = pages.find((p) => p.id === tab); + + if (currentPage == null) { + throw new UiLogicError("Current tab value is bad."); + } + + return ( +
+ +
+ {currentPage.page} +
+
+ ); +}; + +export default TabPages; diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/views/common/TimelineLogo.tsx new file mode 100644 index 00000000..27d188fc --- /dev/null +++ b/FrontEnd/src/views/common/TimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from "react"; + +export interface TimelineLogoProps extends SVGAttributes { + color?: string; +} + +const TimelineLogo: React.FC = (props) => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? "currentcolor"; + return ( + + + + + + ); +}; + +export default TimelineLogo; diff --git a/FrontEnd/src/views/common/ToggleIconButton.tsx b/FrontEnd/src/views/common/ToggleIconButton.tsx new file mode 100644 index 00000000..c4d2d132 --- /dev/null +++ b/FrontEnd/src/views/common/ToggleIconButton.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import classnames from "classnames"; + +export interface ToggleIconButtonProps + extends React.HTMLAttributes { + state: boolean; + trueIconClassName: string; + falseIconClassName: string; +} + +const ToggleIconButton: React.FC = ({ + state, + className, + trueIconClassName, + falseIconClassName, + ...otherProps +}) => { + return ( + + ); +}; + +export default ToggleIconButton; diff --git a/FrontEnd/src/views/common/UserTimelineLogo.tsx b/FrontEnd/src/views/common/UserTimelineLogo.tsx new file mode 100644 index 00000000..19b9fee5 --- /dev/null +++ b/FrontEnd/src/views/common/UserTimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from "react"; + +export interface UserTimelineLogoProps extends SVGAttributes { + color?: string; +} + +const UserTimelineLogo: React.FC = (props) => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? "currentcolor"; + + return ( + + + + + + + + + + + + ); +}; + +export default UserTimelineLogo; diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx new file mode 100644 index 00000000..949be7ed --- /dev/null +++ b/FrontEnd/src/views/common/alert/AlertHost.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import without from "lodash/without"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-bootstrap"; + +import { + alertService, + AlertInfoEx, + kAlertHostId, + AlertInfo, +} from "@/services/alert"; +import { convertI18nText } from "@/common"; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC = (props) => { + const { alert, close } = props; + const { dismissTime } = alert; + + const { t } = useTranslation(); + + const timerTag = React.useRef(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 ( + + {(() => { + const { message } = alert; + if (typeof message === "function") { + const Message = message; + return ; + } else return convertI18nText(message, t); + })()} + + ); +}; + +const AlertHost: React.FC = () => { + const [alerts, setAlerts] = React.useState([]); + + // react guarantee that state setters are stable, so we don't need to add it to dependency list + + React.useEffect(() => { + const consume = (alert: AlertInfoEx): void => { + setAlerts((old) => [...old, alert]); + }; + + alertService.registerConsumer(consume); + return () => { + alertService.unregisterConsumer(consume); + }; + }, []); + + return ( +
+ {alerts.map((alert) => { + return ( + { + setAlerts((old) => without(old, alert)); + }} + /> + ); + })} +
+ ); +}; + +export default AlertHost; diff --git a/FrontEnd/src/views/common/alert/alert.sass b/FrontEnd/src/views/common/alert/alert.sass new file mode 100644 index 00000000..c3560b87 --- /dev/null +++ b/FrontEnd/src/views/common/alert/alert.sass @@ -0,0 +1,15 @@ +.alert-container + position: fixed + z-index: $zindex-popover + +@include media-breakpoint-up(sm) + .alert-container + bottom: 0 + right: 0 + +@include media-breakpoint-down(sm) + .alert-container + bottom: 0 + right: 0 + left: 0 + text-align: center diff --git a/FrontEnd/src/views/common/common.sass b/FrontEnd/src/views/common/common.sass new file mode 100644 index 00000000..cbf7292e --- /dev/null +++ b/FrontEnd/src/views/common/common.sass @@ -0,0 +1,191 @@ +.image-cropper-container + position: relative + box-sizing: border-box + user-select: none + +.image-cropper-container img + position: absolute + left: 0 + top: 0 + width: 100% + height: 100% + +.image-cropper-mask-container + position: absolute + left: 0 + top: 0 + right: 0 + bottom: 0 + overflow: hidden + +.image-cropper-mask + position: absolute + box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%) + touch-action: none + +.image-cropper-handler + position: absolute + width: 26px + height: 26px + border: black solid 2px + border-radius: 50% + background: white + touch-action: none + +.app-bar + display: flex + align-items: center + height: 56px + + position: fixed + z-index: 1030 + top: 0 + left: 0 + right: 0 + + background-color: var(--tl-primary-color) + + transition: background-color 1s + + a + color: var(--tl-text-on-primary-inactive-color) + text-decoration: none + margin: 0 1em + + &:hover + color: var(--tl-text-on-primary-color) + + &.active + color: var(--tl-text-on-primary-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(--tl-primary-color) + + flex-direction: column + + &.app-bar-collapse + transform: scale(1,0) + + a + text-align: left + padding: 0.5em 0.5em + + .app-bar-link-area + flex-direction: column + align-items: stretch + + .app-bar-user-area + flex-direction: column + align-items: stretch + margin-left: unset + + .app-bar-avatar + align-self: flex-end + +.app-bar-toggler + margin-left: auto + font-size: 2em + margin-right: 1em + color: var(--tl-text-on-primary-color) + cursor: pointer + user-select: none + +.cru-skeleton + padding: 0 1em + +.cru-skeleton-line + height: 1em + background-color: #e6e6e6 + margin: 0.7em 0 + border-radius: 0.2em + + &.last + width: 50% + +.cru-full-page + position: fixed + z-index: 1031 + 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(--tl-primary-color) + + display: flex + align-items: center + +.cru-full-page-content-container + overflow: scroll + +.cru-menu + min-width: 200px + +.cru-menu-item + font-size: 1.2em + padding: 0.5em 1.5em + cursor: pointer + + @each $color, $value in $theme-colors + &.color-#{$color} + color: $value + + &:hover + color: white + background-color: $value + +.cru-menu-item-icon + margin-right: 1em + +.cru-menu-divider + border-top: 1px solid $gray-200 + +.cru-tab-pages-action-area + display: flex + align-items: center + +.cru-search-input + display: flex + flex-wrap: wrap diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx new file mode 100644 index 00000000..901697db --- /dev/null +++ b/FrontEnd/src/views/common/user/UserAvatar.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import { getHttpUserClient } from "http/user"; + +export interface UserAvatarProps + extends React.ImgHTMLAttributes { + username: string; +} + +const UserAvatar: React.FC = ({ username, ...otherProps }) => { + return ( + + ); +}; + +export default UserAvatar; diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx new file mode 100644 index 00000000..975875af --- /dev/null +++ b/FrontEnd/src/views/home/TimelineListView.tsx @@ -0,0 +1,101 @@ +import React from "react"; + +import { convertI18nText, I18nText } from "@/common"; + +import { HttpTimelineInfo } from "http/timeline"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +interface TimelineListItemProps { + timeline: HttpTimelineInfo; +} + +const TimelineListItem: React.FC = ({ timeline }) => { + const url = React.useMemo( + () => + timeline.name.startsWith("@") + ? `/users/${timeline.owner.username}` + : `/timelines/${timeline.name}`, + [timeline] + ); + + return ( +
+ + + +
+
{timeline.title}
+
+ {timeline.description} +
+
+ + + +
+ ); +}; + +const TimelineListArrow: React.FC = () => { + return ( +
+
+ + + +
+
+ + + +
+
+ ); +}; + +interface TimelineListViewProps { + headerText?: I18nText; + timelines?: HttpTimelineInfo[]; +} + +const TimelineListView: React.FC = ({ + headerText, + timelines, +}) => { + const { t } = useTranslation(); + + return ( +
+
+ + + +

{convertI18nText(headerText, t)}

+
+ {timelines != null + ? timelines.map((t) => ) + : null} + +
+ ); +}; + +export default TimelineListView; diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx new file mode 100644 index 00000000..aea7b4b2 --- /dev/null +++ b/FrontEnd/src/views/home/WebsiteIntroduction.tsx @@ -0,0 +1,77 @@ +import 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 ( +
+

+ 欢迎来到时间线!🎉🎉🎉 +

+

+ 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。 +

+

+ 如果你拥有一个账号,登陆 + 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。 +

+

+ 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。 +

+

+ 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在 + 关于页面找到一些信息。 +

+

+ + 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅 + +

+
+ ); + } else { + return ( +
+

+ Welcome to Timeline!🎉🎉🎉 +

+

+ This website consists of many individual timelines. Each timeline is a + list of messages just like a chat app. +

+

+ If you do have an account, you can login 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. +

+

+ If you don't have an account, you can view some public timelines + like highlight timelines below set by website manager. +

+

+ 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 about{" "} + page. +

+

+ + 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!😅 + +

+
+ ); + } +}; + +export default WebsiteIntroduction; diff --git a/FrontEnd/src/views/home/home.sass b/FrontEnd/src/views/home/home.sass new file mode 100644 index 00000000..b4cda586 --- /dev/null +++ b/FrontEnd/src/views/home/home.sass @@ -0,0 +1,29 @@ +.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 + &:hover + background: $gray-200 + +@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 diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx new file mode 100644 index 00000000..efc364d7 --- /dev/null +++ b/FrontEnd/src/views/home/index.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { useHistory } from "react-router"; + +import { HttpTimelineInfo } from "http/timeline"; +import { getHttpHighlightClient } from "http/highlight"; + +import SearchInput from "../common/SearchInput"; +import TimelineListView from "./TimelineListView"; +import WebsiteIntroduction from "./WebsiteIntroduction"; + +const highlightTimelineMessageMap = { + loading: "home.loadingHighlightTimelines", + done: "home.loadedHighlightTimelines", + error: "home.errorHighlightTimelines", +} as const; + +const HomeV2: React.FC = () => { + const history = useHistory(); + + const [navText, setNavText] = React.useState(""); + + const [highlightTimelineState, setHighlightTimelineState] = React.useState< + "loading" | "done" | "error" + >("loading"); + const [highlightTimelines, setHighlightTimelines] = React.useState< + HttpTimelineInfo[] | undefined + >(); + + React.useEffect(() => { + if (highlightTimelineState === "loading") { + let subscribe = true; + void getHttpHighlightClient() + .list() + .then( + (data) => { + if (subscribe) { + setHighlightTimelineState("done"); + setHighlightTimelines(data); + } + }, + () => { + if (subscribe) { + setHighlightTimelineState("error"); + setHighlightTimelines(undefined); + } + } + ); + return () => { + subscribe = false; + }; + } + }, [highlightTimelineState]); + + return ( + <> + { + history.push(`search?q=${navText}`); + }} + alwaysOneline + /> + + + + ); +}; + +export default HomeV2; diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx new file mode 100644 index 00000000..6adcef39 --- /dev/null +++ b/FrontEnd/src/views/login/index.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Container, Form } from "react-bootstrap"; + +import { useUser, userService } from "@/services/user"; + +import AppBar from "../common/AppBar"; +import LoadingButton from "../common/LoadingButton"; + +const LoginPage: React.FC = (_) => { + const { t } = useTranslation(); + const history = useHistory(); + const [username, setUsername] = React.useState(""); + const [usernameDirty, setUsernameDirty] = React.useState(false); + const [password, setPassword] = React.useState(""); + const [passwordDirty, setPasswordDirty] = React.useState(false); + const [rememberMe, setRememberMe] = React.useState(true); + const [process, setProcess] = React.useState(false); + const [error, setError] = React.useState(null); + + const user = useUser(); + + React.useEffect(() => { + if (user != null) { + const id = setTimeout(() => history.push("/"), 3000); + return () => { + clearTimeout(id); + }; + } + }, [history, user]); + + if (user != null) { + return ( + <> + +

{t("login.alreadyLogin")}

+ + ); + } + + const submit = (): void => { + if (username === "" || password === "") { + setUsernameDirty(true); + setPasswordDirty(true); + return; + } + + setProcess(true); + userService + .login( + { + username: username, + password: password, + }, + rememberMe + ) + .then( + () => { + if (history.length === 0) { + history.push("/"); + } else { + history.goBack(); + } + }, + (e: Error) => { + setProcess(false); + setError(e.message); + } + ); + }; + + const onEnterPressInPassword: React.KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + submit(); + } + }; + + return ( + +

{t("welcome")}

+
+ + {t("user.username")} + { + setUsername(e.target.value); + setUsernameDirty(true); + }} + value={username} + isInvalid={usernameDirty && username === ""} + /> + {usernameDirty && username === "" && ( + + {t("login.emptyUsername")} + + )} + + + {t("user.password")} + { + setPassword(e.target.value); + setPasswordDirty(true); + }} + value={password} + onKeyDown={onEnterPressInPassword} + isInvalid={passwordDirty && password === ""} + /> + {passwordDirty && password === "" && ( + + {t("login.emptyPassword")} + + )} + + + + id="remember-me" + type="checkbox" + checked={rememberMe} + onChange={(e) => { + setRememberMe(e.currentTarget.checked); + }} + label={t("user.rememberMe")} + /> + + {error ?

{t(error)}

: null} +
+ { + submit(); + e.preventDefault(); + }} + disabled={username === "" || password === "" ? true : undefined} + > + {t("user.login")} + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/FrontEnd/src/views/login/login.sass b/FrontEnd/src/views/login/login.sass new file mode 100644 index 00000000..0bf385f5 --- /dev/null +++ b/FrontEnd/src/views/login/login.sass @@ -0,0 +1,2 @@ +.login-container + max-width: 600px diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx new file mode 100644 index 00000000..14a9709c --- /dev/null +++ b/FrontEnd/src/views/search/index.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Container, Row } from "react-bootstrap"; +import { useHistory, useLocation } from "react-router"; +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"; + +const TimelineSearchResultItemView: React.FC<{ + timeline: HttpTimelineInfo; +}> = ({ timeline }) => { + const link = timeline.name.startsWith("@") + ? `users/${timeline.owner.username}` + : `timelines/${timeline.name}`; + + return ( +
+

+ + {timeline.title} + {timeline.name} + +

+
+ + {timeline.owner.nickname} + + @{timeline.owner.username} + +
+
+ ); +}; + +const SearchPage: React.FC = () => { + const { t } = useTranslation(); + + const history = useHistory(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const queryParam = searchParams.get("q"); + + const [searchText, setSearchText] = React.useState(""); + const [state, setState] = React.useState< + HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error" + >("init"); + + const [forceResearchKey, setForceResearchKey] = React.useState(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 ( + + + { + if (queryParam === searchText) { + setForceResearchKey((old) => old + 1); + } else { + history.push(`/search?q=${searchText}`); + } + }} + /> + + {(() => { + switch (state) { + case "init": { + if (queryParam == null || queryParam.length === 0) { + return
{t("searchPage.input")}
; + } + break; + } + case "loading": { + return
{t("searchPage.loading")}
; + } + case "network-error": { + return
{t("error.network")}
; + } + case "error": { + return
{t("error.unknown")}
; + } + default: { + if (state.length === 0) { + return
{t("searchPage.noResult")}
; + } + return state.map((t) => ( + + )); + } + } + })()} +
+ ); +}; + +export default SearchPage; diff --git a/FrontEnd/src/views/search/search.sass b/FrontEnd/src/views/search/search.sass new file mode 100644 index 00000000..83f297fe --- /dev/null +++ b/FrontEnd/src/views/search/search.sass @@ -0,0 +1,13 @@ +.timeline-search-result-item + @extend .rounded + border: 1px solid + border-color: $gray-200 + background: $gray-100 + transition: all 0.3s + &:hover + border-color: $primary + +.timeline-search-result-item-avatar + width: 2em + height: 2em + border-radius: 50% diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..338d2112 --- /dev/null +++ b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx @@ -0,0 +1,305 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { AxiosError } from "axios"; +import { Modal, Row, Button } from "react-bootstrap"; + +import { UiLogicError } from "@/common"; + +import { useUserLoggedIn } from "@/services/user"; + +import { getHttpUserClient } from "http/user"; + +import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; + +export interface ChangeAvatarDialogProps { + open: boolean; + close: () => void; +} + +const ChangeAvatarDialog: React.FC = (props) => { + const { t } = useTranslation(); + + const user = useUserLoggedIn(); + + const [file, setFile] = React.useState(null); + const [fileUrl, setFileUrl] = React.useState(null); + const [clip, setClip] = React.useState(null); + const [cropImgElement, setCropImgElement] = + React.useState(null); + const [resultBlob, setResultBlob] = React.useState(null); + const [resultUrl, setResultUrl] = React.useState(null); + + const [state, setState] = React.useState< + | "select" + | "crop" + | "processcrop" + | "preview" + | "uploading" + | "success" + | "error" + >("select"); + + const [message, setMessage] = useState< + string | { type: "custom"; text: string } | null + >("settings.dialogChangeAvatar.prompt.select"); + + const trueMessage = + message == null + ? null + : typeof message === "string" + ? t(message) + : message.text; + + 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): 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(); + } + + setState("uploading"); + getHttpUserClient() + .putAvatar(user.username, resultBlob) + .then( + () => { + setState("success"); + }, + (e: unknown) => { + setState("error"); + setMessage({ type: "custom", text: (e as AxiosError).message }); + } + ); + }, [user.username, resultBlob]); + + const createPreviewRow = (): React.ReactElement => { + if (resultUrl == null) { + throw new UiLogicError(); + } + return ( + + {t("settings.dialogChangeAvatar.previewImgAlt")} + + ); + }; + + return ( + + + {t("settings.dialogChangeAvatar.title")} + + {(() => { + if (state === "select") { + return ( + <> + + {t("settings.dialogChangeAvatar.prompt.select")} + + + + + + + + + ); + } else if (state === "crop") { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + + + + + {t("settings.dialogChangeAvatar.prompt.crop")} + + + + + + + + ); + } else if (state === "processcrop") { + return ( + <> + + + {t("settings.dialogChangeAvatar.prompt.processingCrop")} + + + + + + + + ); + } else if (state === "preview") { + return ( + <> + + {createPreviewRow()} + {t("settings.dialogChangeAvatar.prompt.preview")} + + + + + + + + ); + } else if (state === "uploading") { + return ( + <> + + {createPreviewRow()} + {t("settings.dialogChangeAvatar.prompt.uploading")} + + + + ); + } else if (state === "success") { + return ( + <> + + + {t("operationDialog.success")} + + + + + + + ); + } else { + return ( + <> + + {createPreviewRow()} + {trueMessage} + + + + + + + ); + } + })()} + + ); +}; + +export default ChangeAvatarDialog; diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..e6420f36 --- /dev/null +++ b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx @@ -0,0 +1,32 @@ +import { getHttpUserClient } from "http/user"; +import { useUserLoggedIn } from "@/services/user"; +import React from "react"; + +import OperationDialog from "../common/OperationDialog"; + +export interface ChangeNicknameDialogProps { + open: boolean; + close: () => void; +} + +const ChangeNicknameDialog: React.FC = (props) => { + const user = useUserLoggedIn(); + + return ( + { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + close={props.close} + /> + ); +}; + +export default ChangeNicknameDialog; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx new file mode 100644 index 00000000..21eeeb09 --- /dev/null +++ b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx @@ -0,0 +1,68 @@ +import React, { useState } from "react"; +import { useHistory } from "react-router"; + +import { userService } from "@/services/user"; + +import OperationDialog from "../common/OperationDialog"; + +export interface ChangePasswordDialogProps { + open: boolean; + close: () => void; +} + +const ChangePasswordDialog: React.FC = (props) => { + const history = useHistory(); + + const [redirect, setRedirect] = useState(false); + + return ( + { + const result: Record = {}; + 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); + }} + close={() => { + props.close(); + if (redirect) { + history.push("/login"); + } + }} + /> + ); +}; + +export default ChangePasswordDialog; diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx new file mode 100644 index 00000000..04a2777a --- /dev/null +++ b/FrontEnd/src/views/settings/index.tsx @@ -0,0 +1,138 @@ +import React, { useState } from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Container, Form, Row, Col, Button, Modal } from "react-bootstrap"; + +import { useUser, userService } from "@/services/user"; + +import ChangePasswordDialog from "./ChangePasswordDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; + +const ConfirmLogoutDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; +}> = ({ onClose, onConfirm }) => { + const { t } = useTranslation(); + + return ( + + + + {t("settings.dialogConfirmLogout.title")} + + + {t("settings.dialogConfirmLogout.prompt")} + + + + + + ); +}; + +const SettingsPage: React.FC = (_) => { + const { i18n, t } = useTranslation(); + const user = useUser(); + const history = useHistory(); + + const [dialog, setDialog] = useState< + null | "changepassword" | "changeavatar" | "changenickname" | "logout" + >(null); + + const language = i18n.language.slice(0, 2); + + return ( + <> + + {user ? ( +
+

+ {t("settings.subheaders.account")} +

+
setDialog("changeavatar")} + > + {t("settings.changeAvatar")} +
+
setDialog("changenickname")} + > + {t("settings.changeNickname")} +
+
setDialog("changepassword")} + > + {t("settings.changePassword")} +
+
{ + setDialog("logout"); + }} + > + {t("settings.logout")} +
+
+ ) : null} +
+

+ {t("settings.subheaders.customization")} +

+ + +
{t("settings.languagePrimary")}
+ + {t("settings.languageSecondary")} + + + + { + void i18n.changeLanguage(e.target.value); + }} + > + + + + +
+
+
+ {(() => { + switch (dialog) { + case "changepassword": + return setDialog(null)} />; + case "logout": + return ( + setDialog(null)} + onConfirm={() => { + void userService.logout().then(() => { + history.push("/"); + }); + }} + /> + ); + case "changeavatar": + return setDialog(null)} />; + case "changenickname": + return setDialog(null)} />; + default: + return null; + } + })()} + + ); +}; + +export default SettingsPage; diff --git a/FrontEnd/src/views/settings/settings.sass b/FrontEnd/src/views/settings/settings.sass new file mode 100644 index 00000000..8c6d24b8 --- /dev/null +++ b/FrontEnd/src/views/settings/settings.sass @@ -0,0 +1,14 @@ +.settings-item + padding: 0.5em 1em + transition: background 0.3s + border-bottom: 1px solid $gray-200 + + &.first + border-top: 1px solid $gray-200 + + &.clickable + cursor: pointer + + &:hover + background: $gray-300 + diff --git a/FrontEnd/src/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/views/timeline-common/CollapseButton.tsx new file mode 100644 index 00000000..12a3b710 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/CollapseButton.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import classnames from "classnames"; + +const CollapseButton: React.FC<{ + collapse: boolean; + onClick: () => void; + className?: string; + style?: React.CSSProperties; +}> = ({ collapse, onClick, className, style }) => { + return ( + + ); +}; + +export default CollapseButton; diff --git a/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx b/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx new file mode 100644 index 00000000..df43d8d2 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import classnames from "classnames"; +import { HubConnectionState } from "@microsoft/signalr"; +import { useTranslation } from "react-i18next"; + +export interface ConnectionStatusBadgeProps { + status: HubConnectionState; + className?: string; + style?: React.CSSProperties; +} + +const classNameMap: Record = { + Connected: "success", + Connecting: "warning", + Disconnected: "danger", + Disconnecting: "warning", + Reconnecting: "warning", +}; + +const ConnectionStatusBadge: React.FC = (props) => { + const { status, className, style } = props; + + const { t } = useTranslation(); + + return ( +
+ {t(`connectionState.${status}`)} +
+ ); +}; + +export default ConnectionStatusBadge; diff --git a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx new file mode 100644 index 00000000..1514d28f --- /dev/null +++ b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx @@ -0,0 +1,205 @@ +import React from "react"; +import classnames from "classnames"; +import { Form, Spinner } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { Prompt } from "react-router"; + +import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline"; + +import FlatButton from "../common/FlatButton"; +import TabPages from "../common/TabPages"; +import TimelinePostBuilder from "@/services/TimelinePostBuilder"; +import ConfirmDialog from "../common/ConfirmDialog"; + +export interface MarkdownPostEditProps { + timeline: string; + onPosted: (post: HttpTimelinePostInfo) => void; + onPostError: () => void; + onClose: () => void; + className?: string; + style?: React.CSSProperties; +} + +const MarkdownPostEdit: React.FC = ({ + timeline: timelineName, + onPosted, + onClose, + onPostError, + className, + style, +}) => { + const { t } = useTranslation(); + + const [canLeave, setCanLeave] = React.useState(true); + + const [process, setProcess] = React.useState(false); + + const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] = + React.useState(false); + + const [text, _setText] = React.useState(""); + const [images, _setImages] = React.useState<{ file: File; url: string }[]>( + [] + ); + const [previewHtml, _setPreviewHtml] = React.useState(""); + + const _builder = React.useRef(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 => { + setProcess(true); + try { + const dataList = await getBuilder().build(); + const post = await getHttpTimelineClient().postPost(timelineName, { + dataList, + }); + onPosted(post); + onClose(); + } catch (e) { + setProcess(false); + onPostError(); + } + }; + + return ( + <> + + + ) : ( + <> + { + if (canLeave) { + onClose(); + } else { + setShowLeaveConfirmDialog(true); + } + }} + > + {t("operationDialog.cancel")} + + + {t("timeline.send")} + + + ) + } + pages={[ + { + id: "text", + tabText: "edit", + page: ( + { + getBuilder().setMarkdownText(event.currentTarget.value); + }} + /> + ), + }, + { + id: "images", + tabText: "image", + page: ( +
+ {images.map((image, index) => ( +
+ + { + getBuilder().deleteImage(index); + }} + /> +
+ ))} + ) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + getBuilder().appendImage(files[0]); + } + }} + disabled={process} + /> +
+ ), + }, + { + id: "preview", + tabText: "preview", + page: ( +
+ ), + }, + ]} + /> + {showLeaveConfirmDialog && ( + setShowLeaveConfirmDialog(false)} + onConfirm={onClose} + title="timeline.dropDraft" + body="timeline.confirmLeave" + /> + )} + + ); +}; + +export default MarkdownPostEdit; diff --git a/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx new file mode 100644 index 00000000..21c5272e --- /dev/null +++ b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline"; + +import OperationDialog from "../common/OperationDialog"; + +function PostPropertyChangeDialog(props: { + onClose: () => void; + post: HttpTimelinePostInfo; + onSuccess: (post: HttpTimelinePostInfo) => void; +}): React.ReactElement | null { + const { onClose, post, onSuccess } = props; + + return ( + { + return getHttpTimelineClient().patchPost(post.timelineName, post.id, { + time: time === "" ? undefined : new Date(time).toISOString(), + }); + }} + onSuccessAndClose={onSuccess} + /> + ); +} + +export default PostPropertyChangeDialog; diff --git a/FrontEnd/src/views/timeline-common/Timeline.tsx b/FrontEnd/src/views/timeline-common/Timeline.tsx new file mode 100644 index 00000000..40619e64 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/Timeline.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { + HttpForbiddenError, + HttpNetworkError, + HttpNotFoundError, +} from "http/common"; +import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline"; + +import { getTimelinePostUpdate$ } from "@/services/timeline"; + +import TimelinePagedPostListView from "./TimelinePagedPostListView"; +import TimelineTop from "./TimelineTop"; +import TimelineLoading from "./TimelineLoading"; + +export interface TimelineProps { + className?: string; + style?: React.CSSProperties; + timelineName?: string; + reloadKey: number; + onReload: () => void; + onConnectionStateChanged?: (state: HubConnectionState) => void; +} + +const Timeline: React.FC = (props) => { + const { timelineName, className, style, reloadKey } = props; + + const [state, setState] = React.useState< + "loading" | "loaded" | "offline" | "notexist" | "forbid" | "error" + >("loading"); + const [posts, setPosts] = React.useState([]); + + React.useEffect(() => { + setState("loading"); + setPosts([]); + }, [timelineName]); + + const onReload = React.useRef<() => void>(props.onReload); + + React.useEffect(() => { + onReload.current = props.onReload; + }, [props.onReload]); + + const onConnectionStateChanged = React.useRef< + ((state: HubConnectionState) => void) | null + >(null); + + React.useEffect(() => { + onConnectionStateChanged.current = props.onConnectionStateChanged ?? null; + }, [props.onConnectionStateChanged]); + + React.useEffect(() => { + if (timelineName != null && state === "loaded") { + const timelinePostUpdate$ = getTimelinePostUpdate$(timelineName); + const subscription = timelinePostUpdate$.subscribe( + ({ update, state }) => { + if (update) { + onReload.current(); + } + onConnectionStateChanged.current?.(state); + } + ); + return () => { + subscription.unsubscribe(); + }; + } + }, [timelineName, state]); + + React.useEffect(() => { + if (timelineName != null) { + let subscribe = true; + + void getHttpTimelineClient() + .listPost(timelineName) + .then( + (data) => { + if (subscribe) { + setState("loaded"); + setPosts(data); + } + }, + (error) => { + if (error instanceof HttpNetworkError) { + setState("offline"); + } else if (error instanceof HttpForbiddenError) { + setState("forbid"); + } else if (error instanceof HttpNotFoundError) { + setState("notexist"); + } else { + console.error(error); + setState("error"); + } + } + ); + + return () => { + subscribe = false; + }; + } + }, [timelineName, reloadKey]); + + switch (state) { + case "loading": + return ; + case "offline": + return ( +
+ Offline. +
+ ); + case "notexist": + return ( +
+ Not exist. +
+ ); + case "forbid": + return ( +
+ Forbid. +
+ ); + case "error": + return ( +
+ Error. +
+ ); + default: + return ( + <> + + + + ); + } +}; + +export default Timeline; diff --git a/FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx new file mode 100644 index 00000000..80968ee2 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import TimelineLine from "./TimelineLine"; + +export interface TimelineDateItemProps { + date: Date; +} + +const TimelineDateLabel: React.FC = ({ date }) => { + return ( +
+ +
+ {date.toLocaleDateString()} +
+
+ ); +}; + +export default TimelineDateLabel; diff --git a/FrontEnd/src/views/timeline-common/TimelineLine.tsx b/FrontEnd/src/views/timeline-common/TimelineLine.tsx new file mode 100644 index 00000000..0a828b32 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelineLine.tsx @@ -0,0 +1,51 @@ +import 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 = ({ + startSegmentLength, + center, + current, + className, + style, +}) => { + return ( +
+
+ {center !== "none" ? ( +
+
+ {center === "loading" ? ( + + + + ) : null} +
+ ) : null} + {center !== "loading" ?
: null} + {current &&
} +
+ ); +}; + +export default TimelineLine; diff --git a/FrontEnd/src/views/timeline-common/TimelineLoading.tsx b/FrontEnd/src/views/timeline-common/TimelineLoading.tsx new file mode 100644 index 00000000..fc42f4b4 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelineLoading.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import TimelineTop from "./TimelineTop"; + +const TimelineLoading: React.FC = () => { + return ( + + ); +}; + +export default TimelineLoading; diff --git a/FrontEnd/src/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/views/timeline-common/TimelineMember.tsx new file mode 100644 index 00000000..3d4de8b8 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelineMember.tsx @@ -0,0 +1,195 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap"; + +import { convertI18nText, I18nText } from "@/common"; + +import { HttpUser } from "http/user"; +import { getHttpSearchClient } from "http/search"; + +import SearchInput from "../common/SearchInput"; +import UserAvatar from "../common/user/UserAvatar"; +import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline"; + +const TimelineMemberItem: React.FC<{ + user: HttpUser; + add?: boolean; + onAction?: (username: string) => void; +}> = ({ user, add, onAction }) => { + const { t } = useTranslation(); + + return ( + + + + + + + {user.nickname} + + {"@" + user.username} + + + {onAction ? ( + + + + ) : null} + + + ); +}; + +const TimelineMemberUserSearch: React.FC<{ + timeline: HttpTimelineInfo; + onChange: () => void; +}> = ({ timeline, onChange }) => { + const { t } = useTranslation(); + + const [userSearchText, setUserSearchText] = useState(""); + const [userSearchState, setUserSearchState] = useState< + | { + type: "users"; + data: HttpUser[]; + } + | { type: "error"; data: I18nText } + | { type: "loading" } + | { type: "init" } + >({ type: "init" }); + + return ( + <> + { + setUserSearchText(v); + }} + loading={userSearchState.type === "loading"} + onButtonClick={() => { + if (userSearchText === "") { + setUserSearchState({ + type: "error", + data: "login.emptyUsername", + }); + return; + } + setUserSearchState({ type: "loading" }); + getHttpSearchClient() + .searchUsers(userSearchText) + .then( + (users) => { + users = users.filter( + (user) => + timeline.members.findIndex( + (m) => m.username === user.username + ) === -1 && timeline.owner.username !== user.username + ); + setUserSearchState({ type: "users", data: users }); + }, + (e) => { + setUserSearchState({ + type: "error", + data: { type: "custom", value: String(e) }, + }); + } + ); + }} + /> + {(() => { + if (userSearchState.type === "users") { + const users = userSearchState.data; + if (users.length === 0) { + return
{t("timeline.member.noUserAvailableToAdd")}
; + } else { + return ( + + {users.map((user) => ( + { + void getHttpTimelineClient() + .memberPut(timeline.name, user.username) + .then(() => { + setUserSearchText(""); + setUserSearchState({ type: "init" }); + onChange(); + }); + }} + /> + ))} + + ); + } + } else if (userSearchState.type === "error") { + return ( +
+ {convertI18nText(userSearchState.data, t)} +
+ ); + } + })()} + + ); +}; + +export interface TimelineMemberProps { + timeline: HttpTimelineInfo; + onChange: () => void; +} + +const TimelineMember: React.FC = (props) => { + const { timeline, onChange } = props; + const members = [timeline.owner, ...timeline.members]; + + return ( + + + {members.map((member, index) => ( + { + void getHttpTimelineClient() + .memberDelete(timeline.name, member.username) + .then(onChange); + } + : undefined + } + /> + ))} + + {timeline.manageable ? ( + + ) : null} + + ); +}; + +export default TimelineMember; + +export interface TimelineMemberDialogProps extends TimelineMemberProps { + open: boolean; + onClose: () => void; +} + +export const TimelineMemberDialog: React.FC = ( + props +) => { + return ( + + + + ); +}; diff --git a/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx new file mode 100644 index 00000000..038ea3ab --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import classnames from "classnames"; +import { useTranslation } from "react-i18next"; + +import { getHttpHighlightClient } from "http/highlight"; +import { getHttpBookmarkClient } from "http/bookmark"; + +import { useUser } from "@/services/user"; +import { pushAlert } from "@/services/alert"; +import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; + +import { useIsSmallScreen } from "@/utilities/mediaQuery"; + +import { TimelinePageCardProps } from "./TimelinePageTemplate"; + +import CollapseButton from "./CollapseButton"; +import { TimelineMemberDialog } from "./TimelineMember"; +import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; +import ConnectionStatusBadge from "./ConnectionStatusBadge"; +import { MenuItems, PopupMenu } from "../common/Menu"; +import FullPage from "../common/FullPage"; + +export interface TimelineCardTemplateProps extends TimelinePageCardProps { + infoArea: React.ReactElement; + manageItems?: MenuItems; + dialog: string | "property" | "member" | null; + setDialog: (dialog: "property" | "member" | null) => void; +} + +const TimelinePageCardTemplate: React.FC = ({ + timeline, + collapse, + toggleCollapse, + infoArea, + manageItems, + connectionStatus, + onReload, + className, + dialog, + setDialog, +}) => { + const { t } = useTranslation(); + + const isSmallScreen = useIsSmallScreen(); + + const user = useUser(); + + const content = ( + <> + {infoArea} +

{timeline.description}

+ + {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} + +
+ { + getHttpHighlightClient() + [timeline.isHighlight ? "delete" : "put"](timeline.name) + .then(onReload, () => { + pushAlert({ + message: timeline.isHighlight + ? "timeline.removeHighlightFail" + : "timeline.addHighlightFail", + type: "danger", + }); + }); + } + : undefined + } + /> + {user != null ? ( + { + getHttpBookmarkClient() + [timeline.isBookmark ? "delete" : "put"](timeline.name) + .then(onReload, () => { + pushAlert({ + message: timeline.isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + type: "danger", + }); + }); + }} + /> + ) : null} + setDialog("member")} + /> + {manageItems != null ? ( + + + + ) : null} +
+ + ); + + return ( + <> +
+
+ + +
+ {isSmallScreen ? ( + + {content} + + ) : ( +
{content}
+ )} +
+ {(() => { + if (dialog === "member") { + return ( + setDialog(null)} + open + onChange={onReload} + /> + ); + } else if (dialog === "property") { + return ( + setDialog(null)} + open + onChange={onReload} + /> + ); + } + })()} + + ); +}; + +export default TimelinePageCardTemplate; diff --git a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx new file mode 100644 index 00000000..44926cc6 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx @@ -0,0 +1,190 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Container } from "react-bootstrap"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { HttpNetworkError, HttpNotFoundError } from "http/common"; +import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline"; + +import { getAlertHost } from "@/services/alert"; + +import Timeline from "./Timeline"; +import TimelinePostEdit from "./TimelinePostEdit"; + +import useReverseScrollPositionRemember from "@/utilities/useReverseScrollPositionRemember"; +import { generatePalette, setPalette } from "@/palette"; + +export interface TimelinePageCardProps { + timeline: HttpTimelineInfo; + collapse: boolean; + toggleCollapse: () => void; + connectionStatus: HubConnectionState; + className?: string; + onReload: () => void; +} + +export interface TimelinePageTemplateProps { + timelineName: string; + notFoundI18nKey: string; + reloadKey: number; + onReload: () => void; + CardComponent: React.ComponentType; +} + +const TimelinePageTemplate: React.FC = (props) => { + const { timelineName, reloadKey, onReload, CardComponent } = props; + + const { t } = useTranslation(); + + const [state, setState] = React.useState< + "loading" | "done" | "offline" | "notexist" | "error" + >("loading"); + const [timeline, setTimeline] = React.useState(null); + + const [connectionStatus, setConnectionStatus] = + React.useState(HubConnectionState.Connecting); + + useReverseScrollPositionRemember(); + + React.useEffect(() => { + setState("loading"); + setTimeline(null); + }, [timelineName]); + + React.useEffect(() => { + let subscribe = true; + void getHttpTimelineClient() + .getTimeline(timelineName) + .then( + (data) => { + if (subscribe) { + setState("done"); + setTimeline(data); + } + }, + (error) => { + if (subscribe) { + if (error instanceof HttpNetworkError) { + setState("offline"); + } else if (error instanceof HttpNotFoundError) { + setState("notexist"); + } else { + console.error(error); + setState("error"); + } + setTimeline(null); + } + } + ); + return () => { + subscribe = false; + }; + }, [timelineName, reloadKey]); + + React.useEffect(() => { + if (timeline != null && timeline.color != null) { + return setPalette(generatePalette({ primary: timeline.color })); + } + }, [timeline]); + + const [bottomSpaceHeight, setBottomSpaceHeight] = React.useState(0); + + const [timelineReloadKey, setTimelineReloadKey] = React.useState(0); + + const reloadTimeline = (): void => { + setTimelineReloadKey((old) => old + 1); + }; + + const onPostEditHeightChange = React.useCallback((height: number): void => { + setBottomSpaceHeight(height); + if (height === 0) { + const alertHost = getAlertHost(); + if (alertHost != null) { + alertHost.style.removeProperty("margin-bottom"); + } + } else { + const alertHost = getAlertHost(); + if (alertHost != null) { + alertHost.style.marginBottom = `${height}px`; + } + } + }, []); + + const cardCollapseLocalStorageKey = `timeline.${timelineName}.cardCollapse`; + + const [cardCollapse, setCardCollapse] = React.useState(true); + + React.useEffect(() => { + const savedCollapse = window.localStorage.getItem( + cardCollapseLocalStorageKey + ); + setCardCollapse(savedCollapse == null ? true : savedCollapse === "true"); + }, [cardCollapseLocalStorageKey]); + + const toggleCardCollapse = (): void => { + const newState = !cardCollapse; + setCardCollapse(newState); + window.localStorage.setItem( + cardCollapseLocalStorageKey, + newState.toString() + ); + }; + + return ( + <> + {timeline != null ? ( + + ) : null} + + {(() => { + if (state === "offline") { + // TODO: i18n + return

Offline!

; + } else if (state === "notexist") { + return

{t(props.notFoundI18nKey)}

; + } else if (state === "error") { + // TODO: i18n + return

Error!

; + } else { + return ( + + ); + } + })()} +
+ {timeline != null && timeline.postable ? ( + <> +
+ + + ) : null} + + ); +}; + +export default TimelinePageTemplate; diff --git a/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx b/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx new file mode 100644 index 00000000..d569a2d7 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +import { HttpTimelinePostInfo } from "http/timeline"; + +import useScrollToTop from "@/utilities/useScrollToTop"; + +import TimelinePostListView from "./TimelinePostListView"; + +export interface TimelinePagedPostListViewProps { + className?: string; + style?: React.CSSProperties; + posts: HttpTimelinePostInfo[]; + onReload: () => void; +} + +const TimelinePagedPostListView: React.FC = ( + props +) => { + const { className, style, posts, onReload } = props; + + const [lastViewCount, setLastViewCount] = React.useState(10); + + const viewingPosts = React.useMemo(() => { + return lastViewCount >= posts.length + ? posts.slice() + : posts.slice(-lastViewCount); + }, [posts, lastViewCount]); + + useScrollToTop(() => { + setLastViewCount(lastViewCount + 10); + }, lastViewCount < posts.length); + + return ( + + ); +}; + +export default TimelinePagedPostListView; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx new file mode 100644 index 00000000..f1b53335 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import classnames from "classnames"; +import { Remarkable } from "remarkable"; + +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 = (props) => { + const { post, className, style } = props; + + const [text, setText] = React.useState(null); + const [error, setError] = React.useState<"offline" | "error" | null>(null); + + const [reloadKey, setReloadKey] = React.useState(0); + + React.useEffect(() => { + let subscribe = true; + + setText(null); + setError(null); + + void getHttpTimelineClient() + .getPostDataAsString(post.timelineName, post.id) + .then( + (data) => { + if (subscribe) setText(data); + }, + (error) => { + if (subscribe) { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else { + setError("error"); + } + } + } + ); + + return () => { + subscribe = false; + }; + }, [post.timelineName, post.id, reloadKey]); + + if (error != null) { + return ( + setReloadKey(reloadKey + 1)} + /> + ); + } else if (text == null) { + return ; + } else { + return ( +
+ {text} +
+ ); + } +}; + +const ImageView: React.FC = (props) => { + const { post, className, style } = props; + + useUser(); + + return ( + + ); +}; + +const MarkdownView: React.FC = (props) => { + const { post, className, style } = props; + + const _remarkable = React.useRef(); + + const getRemarkable = (): Remarkable => { + if (_remarkable.current) { + return _remarkable.current; + } else { + _remarkable.current = new Remarkable(); + return _remarkable.current; + } + }; + + const [markdown, setMarkdown] = React.useState(null); + const [error, setError] = React.useState<"offline" | "error" | null>(null); + + const [reloadKey, setReloadKey] = React.useState(0); + + React.useEffect(() => { + let subscribe = true; + + setMarkdown(null); + setError(null); + + void getHttpTimelineClient() + .getPostDataAsString(post.timelineName, post.id) + .then( + (data) => { + if (subscribe) setMarkdown(data); + }, + (error) => { + if (subscribe) { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else { + setError("error"); + } + } + } + ); + + return () => { + subscribe = false; + }; + }, [post.timelineName, post.id, reloadKey]); + + const markdownHtml = React.useMemo(() => { + if (markdown == null) return null; + return getRemarkable().render(markdown); + }, [markdown]); + + if (error != null) { + return ( + setReloadKey(reloadKey + 1)} + /> + ); + } else if (markdown == null) { + return ; + } else { + if (markdownHtml == null) { + throw new UiLogicError("Markdown is not null but markdown html is."); + } + return ( +
+ ); + } +}; + +export interface TimelinePostContentViewProps { + post: HttpTimelinePostInfo; + className?: string; + style?: React.CSSProperties; +} + +const viewMap: Record> = { + "text/plain": TextView, + "text/markdown": MarkdownView, + "image/png": ImageView, + "image/jpeg": ImageView, + "image/gif": ImageView, + "image/webp": ImageView, +}; + +const TimelinePostContentView: React.FC = ( + props +) => { + const { post, className, style } = props; + + const type = post.dataList[0].kind; + + if (type in viewMap) { + const View = viewMap[type]; + return ; + } else { + // TODO: i18n + console.error("Unknown post type", post); + return
Error, unknown post type!
; + } +}; + +export default TimelinePostContentView; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx new file mode 100644 index 00000000..b2c7a470 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; +}> = ({ onClose, onConfirm }) => { + const { t } = useTranslation(); + + return ( + + + + {t("timeline.post.deleteDialog.title")} + + + {t("timeline.post.deleteDialog.prompt")} + + + + + + ); +}; + +export default TimelinePostDeleteConfirmDialog; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx new file mode 100644 index 00000000..0f470fd6 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx @@ -0,0 +1,291 @@ +import React from "react"; +import classnames from "classnames"; +import { useTranslation } from "react-i18next"; +import { Row, Col, Form } from "react-bootstrap"; + +import { UiLogicError } from "@/common"; + +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePostInfo, + HttpTimelinePostPostRequestData, +} from "http/timeline"; + +import { pushAlert } from "@/services/alert"; +import { base64 } from "http/common"; + +import BlobImage from "../common/BlobImage"; +import LoadingButton from "../common/LoadingButton"; +import { PopupMenu } from "../common/Menu"; +import MarkdownPostEdit from "./MarkdownPostEdit"; + +interface TimelinePostEditTextProps { + text: string; + disabled: boolean; + onChange: (text: string) => void; + className?: string; + style?: React.CSSProperties; +} + +const TimelinePostEditText: React.FC = (props) => { + const { text, disabled, onChange, className, style } = props; + + return ( + { + onChange(event.target.value); + }} + className={className} + style={style} + /> + ); +}; + +interface TimelinePostEditImageProps { + onSelect: (file: File | null) => void; + disabled: boolean; +} + +const TimelinePostEditImage: React.FC = (props) => { + const { onSelect, disabled } = props; + + const { t } = useTranslation(); + + const [file, setFile] = React.useState(null); + const [error, setError] = React.useState(false); + + const onInputChange: React.ChangeEventHandler = (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 ( + <> + + {file != null && !error && ( + onSelect(file)} + onError={() => { + onSelect(null); + setError(true); + }} + /> + )} + {error ?
{t("loadImageError")}
: null} + + ); +}; + +type PostKind = "text" | "markdown" | "image"; + +const postKindIconClassNameMap: Record = { + text: "bi-fonts", + markdown: "bi-markdown", + image: "bi-image", +}; + +export interface TimelinePostEditProps { + className?: string; + timeline: HttpTimelineInfo; + onPosted: (newPost: HttpTimelinePostInfo) => void; + onHeightChange?: (height: number) => void; +} + +const TimelinePostEdit: React.FC = (props) => { + const { timeline, onHeightChange, className, onPosted } = props; + + const { t } = useTranslation(); + + const [process, setProcess] = React.useState(false); + + const [kind, setKind] = React.useState>("text"); + const [showMarkdown, setShowMarkdown] = React.useState(false); + + const [text, setText] = React.useState(""); + const [image, setImage] = React.useState(null); + + const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`; + + React.useEffect(() => { + setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); + }, [draftTextLocalStorageKey]); + + const canSend = + (kind === "text" && text.length !== 0) || + (kind === "image" && image != null); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef(null!); + + const notifyHeightChange = (): void => { + if (onHeightChange) { + onHeightChange(containerRef.current.clientHeight); + } + }; + + React.useEffect(() => { + notifyHeightChange(); + return () => { + if (onHeightChange) { + onHeightChange(0); + } + }; + }); + + const onPostError = (): void => { + pushAlert({ + type: "danger", + message: "timeline.sendPostFailed", + }); + }; + + const onSend = async (): Promise => { + 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.name, { + dataList: [requestData], + }) + .then( + (data) => { + if (kind === "text") { + setText(""); + window.localStorage.removeItem(draftTextLocalStorageKey); + } + setProcess(false); + setKind("text"); + onPosted(data); + }, + (_) => { + setProcess(false); + onPostError(); + } + ); + }; + + return ( +
+ {showMarkdown ? ( + setShowMarkdown(false)} + timeline={timeline.name} + onPosted={onPosted} + onPostError={onPostError} + /> + ) : ( + + + {(() => { + if (kind === "text") { + return ( + { + setText(t); + window.localStorage.setItem(draftTextLocalStorageKey, t); + }} + /> + ); + } else if (kind === "image") { + return ( + + ); + } + })()} + + +
+ ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconClassNameMap[kind], + onClick: () => { + if (kind === "markdown") { + setShowMarkdown(true); + } else { + setKind(kind); + } + }, + }))} + > + + +
+ + {t("timeline.send")} + + +
+ )} +
+ ); +}; + +export default TimelinePostEdit; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx new file mode 100644 index 00000000..49284720 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx @@ -0,0 +1,79 @@ +import React, { Fragment } from "react"; +import classnames from "classnames"; + +import { HttpTimelinePostInfo } from "http/timeline"; + +import TimelinePostView from "./TimelinePostView"; +import TimelineDateLabel from "./TimelineDateLabel"; + +function dateEqual(left: Date, right: Date): boolean { + return ( + left.getDate() == right.getDate() && + left.getMonth() == right.getMonth() && + left.getFullYear() == right.getFullYear() + ); +} + +export interface TimelinePostListViewProps { + className?: string; + style?: React.CSSProperties; + posts: HttpTimelinePostInfo[]; + onReload: () => void; +} + +const TimelinePostListView: React.FC = (props) => { + const { className, style, posts, onReload } = props; + + const groupedPosts = React.useMemo< + { + date: Date; + posts: (HttpTimelinePostInfo & { index: number })[]; + }[] + >(() => { + const result: { + date: Date; + posts: (HttpTimelinePostInfo & { index: number })[]; + }[] = []; + let index = 0; + for (const post of posts) { + const time = new Date(post.time); + if (result.length === 0) { + result.push({ date: time, posts: [{ ...post, index }] }); + } else { + const lastGroup = result[result.length - 1]; + if (dateEqual(lastGroup.date, time)) { + lastGroup.posts.push({ ...post, index }); + } else { + result.push({ date: time, posts: [{ ...post, index }] }); + } + } + index++; + } + return result; + }, [posts]); + + return ( +
+ {groupedPosts.map((group) => { + return ( + + + {group.posts.map((post) => { + return ( + + ); + })} + + ); + })} +
+ ); +}; + +export default TimelinePostListView; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx new file mode 100644 index 00000000..e8b32c71 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import classnames from "classnames"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline"; + +import { pushAlert } from "@/services/alert"; + +import UserAvatar from "../common/user/UserAvatar"; +import TimelineLine from "./TimelineLine"; +import TimelinePostContentView from "./TimelinePostContentView"; +import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog"; +import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; + +export interface TimelinePostViewProps { + post: HttpTimelinePostInfo; + current?: boolean; + className?: string; + style?: React.CSSProperties; + cardStyle?: React.CSSProperties; + onChanged: (post: HttpTimelinePostInfo) => void; + onDeleted: () => void; +} + +const TimelinePostView: React.FC = (props) => { + const { post, className, style, cardStyle, onChanged, onDeleted } = props; + const current = props.current === true; + + const { t } = useTranslation(); + + const [operationMaskVisible, setOperationMaskVisible] = + React.useState(false); + const [dialog, setDialog] = React.useState< + "delete" | "changeproperty" | null + >(null); + + const cardRef = React.useRef(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 ( +
+ +
+ {post.editable ? ( + { + setOperationMaskVisible(true); + e.stopPropagation(); + }} + /> + ) : null} +
+ + + + + + {post.author.nickname} + + {new Date(post.time).toLocaleTimeString()} + + + +
+
+ +
+ {operationMaskVisible ? ( +
{ + setOperationMaskVisible(false); + }} + > + { + setDialog("changeproperty"); + e.stopPropagation(); + }} + > + {t("changeProperty")} + + { + setDialog("delete"); + e.stopPropagation(); + }} + > + {t("delete")} + +
+ ) : null} +
+ {dialog === "delete" ? ( + { + setDialog(null); + setOperationMaskVisible(false); + }} + onConfirm={() => { + void getHttpTimelineClient() + .deletePost(post.timelineName, post.id) + .then(onDeleted, () => { + pushAlert({ + type: "danger", + message: "timeline.deletePostFailed", + }); + }); + }} + /> + ) : dialog === "changeproperty" ? ( + { + setDialog(null); + setOperationMaskVisible(false); + }} + post={post} + onSuccess={onChanged} + /> + ) : null} +
+ ); +}; + +export default TimelinePostView; diff --git a/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..83b24d01 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePatchRequest, + kTimelineVisibilities, + TimelineVisibility, +} from "http/timeline"; + +import OperationDialog from "../common/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 = + (props) => { + const { timeline, onChange } = props; + + return ( + ({ + 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} + close={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.name, req) + .then(onChange); + }} + /> + ); + }; + +export default TimelinePropertyChangeDialog; diff --git a/FrontEnd/src/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/views/timeline-common/TimelineTop.tsx new file mode 100644 index 00000000..dabbdf1e --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelineTop.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import classnames from "classnames"; + +import TimelineLine, { TimelineLineProps } from "./TimelineLine"; + +export interface TimelineTopProps { + height?: number | string; + lineProps?: TimelineLineProps; + className?: string; + style?: React.CSSProperties; +} + +const TimelineTop: React.FC = (props) => { + const { height, style, className } = props; + const lineProps = props.lineProps ?? { center: "none" }; + + return ( +
+ +
+ ); +}; + +export default TimelineTop; diff --git a/FrontEnd/src/views/timeline-common/timeline-common.sass b/FrontEnd/src/views/timeline-common/timeline-common.sass new file mode 100644 index 00000000..4400fead --- /dev/null +++ b/FrontEnd/src/views/timeline-common/timeline-common.sass @@ -0,0 +1,259 @@ +@use 'sass:color' + +.timeline + z-index: 0 + position: relative + width: 100% + overflow-wrap: break-word + animation: 1s timeline-enter + +$timeline-line-width: 7px +$timeline-line-node-radius: 18px +$timeline-line-color: var(--tl-primary-color) +$timeline-line-color-current: var(--tl-primary-enhance-color) + +@keyframes timeline-line-node-noncurrent + to + box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color) + +@keyframes timeline-line-node-current + to + box-shadow: 0 0 20px 3px var(--tl-primary-enhance-lighter-color) + +@keyframes timeline-line-node-loading + to + box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color) + +@keyframes timeline-line-node-loading-edge + from + transform: rotate(0turn) + to + transform: rotate(1turn) + +@keyframes timeline-enter + from + transform: translate(0, -100vh) + +@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 + + @include media-breakpoint-down(sm) + left: 1em + + .segment + width: $timeline-line-width + background: $timeline-line-color + + &.start + height: 1.8em + flex: 0 0 auto + + &.end + flex: 1 1 auto + + &.current-end + height: 2em + flex: 0 0 auto + background: linear-gradient($timeline-line-color-current, white) + + .node-container + flex: 0 0 auto + position: relative + width: $timeline-line-node-radius + height: $timeline-line-node-radius + + .node + width: $timeline-line-node-radius + 2 + height: $timeline-line-node-radius + 2 + position: absolute + background: $timeline-line-color + left: -1px + top: -1px + border-radius: 50% + box-sizing: border-box + z-index: 1 + animation: 1s infinite alternate + animation-name: timeline-line-node-noncurrent + + .node-loading-edge + color: $timeline-line-color + width: $timeline-line-node-radius + 20 + height: $timeline-line-node-radius + 20 + position: absolute + left: -10px + top: -10px + box-sizing: border-box + z-index: 2 + animation: 1.5s linear infinite timeline-line-node-loading-edge + + &.current + .segment + &.start + background: linear-gradient($timeline-line-color, $timeline-line-color-current) + &.end + background: $timeline-line-color-current + .node + background: $timeline-line-color-current + animation-name: timeline-line-node-current + + &.loading + .node + background: $timeline-line-color + animation-name: timeline-line-node-loading + +.timeline-item.current + padding-bottom: 2.5em + +.timeline-top + position: relative + text-align: right + +.timeline-item + position: relative + padding: 0.5em + +.timeline-item-card + @extend .cru-card + position: relative + padding: 0.3em 0.5em 1em 4em + transition: background 0.5s, padding-left 0.5s + animation: 0.6s forwards + opacity: 0 + + @include media-breakpoint-down(sm) + padding-left: 3em + +.timeline-item-header + display: flex + align-items: center + @extend .my-2 + +.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-edit-image + max-width: 100px + max-height: 100px + +.mask + background: change-color($color: white, $alpha: 0.8) + z-index: 100 + +.timeline-sync-state-badge + font-size: 0.8em + padding: 3px 8px + border-radius: 5px + background: #e8fbff + +.timeline-sync-state-badge-pin + display: inline-block + width: 0.4em + height: 0.4em + border-radius: 50% + vertical-align: middle + margin-right: 0.6em + +.timeline-template-card + position: fixed + top: 56px + right: 0 + margin: 0.5em + +.timeline-markdown-post-edit-page + overflow: scroll + 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 + +.connection-status-badge + font-size: 0.8em + border-radius: 5px + padding: 0.1em 1em + background-color: rgb(234 242 255) + + &::before + width: 10px + height: 10px + border-radius: 50% + display: inline-block + content: '' + margin-right: 0.6em + + &.success + color: #006100 + &::before + background-color: #006100 + + &.warning + color: #e4a700 + &::before + background-color: #e4a700 + + &.danger + color: #fd1616 + &::before + background-color: #fd1616 diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx new file mode 100644 index 00000000..e031b565 --- /dev/null +++ b/FrontEnd/src/views/timeline/TimelineCard.tsx @@ -0,0 +1,74 @@ +import React from "react"; + +import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate"; +import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate"; + +import UserAvatar from "../common/user/UserAvatar"; +import TimelineDeleteDialog from "./TimelineDeleteDialog"; + +const TimelineCard: React.FC = (props) => { + const { timeline } = props; + + const [dialog, setDialog] = React.useState< + "member" | "property" | "delete" | null + >(null); + + return ( + <> + +

+ {timeline.title} + {timeline.name} +

+
+ + {timeline.owner.nickname} + + @{timeline.owner.username} + +
+ + } + manageItems={ + timeline.manageable + ? [ + { + type: "button", + text: "timeline.manageItem.property", + onClick: () => setDialog("property"), + }, + { type: "divider" }, + { + type: "button", + onClick: () => setDialog("delete"), + color: "danger", + text: "timeline.manageItem.delete", + }, + ] + : undefined + } + dialog={dialog} + setDialog={setDialog} + {...props} + /> + {(() => { + if (dialog === "delete") { + return ( + setDialog(null)} + /> + ); + } + })()} + + ); +}; + +export default TimelineCard; diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..8821507d --- /dev/null +++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { Trans } from "react-i18next"; + +import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline"; + +import OperationDialog from "../common/OperationDialog"; + +interface TimelineDeleteDialog { + timeline: HttpTimelineInfo; + open: boolean; + close: () => void; +} + +const TimelineDeleteDialog: React.FC = (props) => { + const history = useHistory(); + + const { timeline } = props; + + return ( + { + return ( + + 0{{ name }}2 + + ); + }} + inputScheme={[ + { + type: "text", + }, + ]} + inputValidator={([value]) => { + if (value !== timeline.name) { + return { 0: "timeline.deleteDialog.notMatch" }; + } else { + return null; + } + }} + onProcess={() => { + return getHttpTimelineClient().deleteTimeline(timeline.name); + }} + onSuccessAndClose={() => { + history.replace("/"); + }} + /> + ); +}; + +export default TimelineDeleteDialog; diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/views/timeline/index.tsx new file mode 100644 index 00000000..c5bfd7ab --- /dev/null +++ b/FrontEnd/src/views/timeline/index.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { useParams } from "react-router"; + +import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; +import TimelineCard from "./TimelineCard"; + +const TimelinePage: React.FC = () => { + const { name } = useParams<{ name: string }>(); + + const [reloadKey, setReloadKey] = React.useState(0); + + return ( + setReloadKey(reloadKey + 1)} + /> + ); +}; + +export default TimelinePage; diff --git a/FrontEnd/src/views/timeline/timeline.sass b/FrontEnd/src/views/timeline/timeline.sass new file mode 100644 index 00000000..e69de29b diff --git a/FrontEnd/src/views/user/UserCard.tsx b/FrontEnd/src/views/user/UserCard.tsx new file mode 100644 index 00000000..e7e4252e --- /dev/null +++ b/FrontEnd/src/views/user/UserCard.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate"; +import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate"; +import UserAvatar from "../common/user/UserAvatar"; + +const UserCard: React.FC = (props) => { + const { timeline } = props; + + const [dialog, setDialog] = React.useState<"member" | "property" | null>( + null + ); + + return ( + <> + +

+ {timeline.title} + {timeline.name} +

+
+ + {timeline.owner.nickname} +
+ + } + manageItems={ + timeline.manageable + ? [ + { + type: "button", + text: "timeline.manageItem.property", + onClick: () => setDialog("property"), + }, + ] + : undefined + } + dialog={dialog} + setDialog={setDialog} + {...props} + /> + + ); +}; + +export default UserCard; diff --git a/FrontEnd/src/views/user/index.tsx b/FrontEnd/src/views/user/index.tsx new file mode 100644 index 00000000..57454d0d --- /dev/null +++ b/FrontEnd/src/views/user/index.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { useParams } from "react-router"; + +import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; +import UserCard from "./UserCard"; + +const UserPage: React.FC = () => { + const { username } = useParams<{ username: string }>(); + + const [reloadKey, setReloadKey] = React.useState(0); + + let dialogElement: React.ReactElement | undefined; + + return ( + <> + setReloadKey(reloadKey + 1)} + CardComponent={UserCard} + /> + {dialogElement} + + ); +}; + +export default UserPage; diff --git a/FrontEnd/src/views/user/user.sass b/FrontEnd/src/views/user/user.sass new file mode 100644 index 00000000..63a28e05 --- /dev/null +++ b/FrontEnd/src/views/user/user.sass @@ -0,0 +1,7 @@ +.change-avatar-cropper-row + max-height: 400px + +.change-avatar-img + min-width: 50% + max-width: 100% + max-height: 400px -- cgit v1.2.3