aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FrontEnd/.editorconfig30
-rw-r--r--FrontEnd/.eslintignore1
-rw-r--r--FrontEnd/.eslintrc.js (renamed from FrontEnd/.eslintrc.cjs)2
-rw-r--r--FrontEnd/cspell.json3
-rw-r--r--FrontEnd/package.json63
-rw-r--r--FrontEnd/pnpm-lock.yaml1749
-rw-r--r--FrontEnd/src/App.tsx56
-rw-r--r--FrontEnd/src/common.ts3
-rw-r--r--FrontEnd/src/components/AppBar.css96
-rw-r--r--FrontEnd/src/components/AppBar.tsx106
-rw-r--r--FrontEnd/src/components/BlobImage.tsx41
-rw-r--r--FrontEnd/src/components/Card.css20
-rw-r--r--FrontEnd/src/components/Card.tsx39
-rw-r--r--FrontEnd/src/components/Icon.css4
-rw-r--r--FrontEnd/src/components/Icon.tsx28
-rw-r--r--FrontEnd/src/components/ImageCropper.css (renamed from FrontEnd/src/views/common/ImageCropper.css)18
-rw-r--r--FrontEnd/src/components/ImageCropper.tsx323
-rw-r--r--FrontEnd/src/components/LoadFailReload.tsx (renamed from FrontEnd/src/views/common/LoadFailReload.tsx)0
-rw-r--r--FrontEnd/src/components/Page.css8
-rw-r--r--FrontEnd/src/components/Page.tsx17
-rw-r--r--FrontEnd/src/components/SearchInput.css (renamed from FrontEnd/src/views/common/SearchInput.css)4
-rw-r--r--FrontEnd/src/components/SearchInput.tsx50
-rw-r--r--FrontEnd/src/components/Skeleton.css20
-rw-r--r--FrontEnd/src/components/Skeleton.tsx22
-rw-r--r--FrontEnd/src/components/Spinner.css (renamed from FrontEnd/src/views/common/Spinner.css)0
-rw-r--r--FrontEnd/src/components/Spinner.tsx46
-rw-r--r--FrontEnd/src/components/TimelineLogo.tsx (renamed from FrontEnd/src/views/common/TimelineLogo.tsx)0
-rw-r--r--FrontEnd/src/components/alert/AlertHost.tsx82
-rw-r--r--FrontEnd/src/components/alert/AlertService.ts114
-rw-r--r--FrontEnd/src/components/alert/alert.css21
-rw-r--r--FrontEnd/src/components/alert/index.ts8
-rw-r--r--FrontEnd/src/components/breakpoints.ts3
-rw-r--r--FrontEnd/src/components/button/Button.css64
-rw-r--r--FrontEnd/src/components/button/Button.tsx (renamed from FrontEnd/src/views/common/button/Button.tsx)9
-rw-r--r--FrontEnd/src/components/button/ButtonRow.css0
-rw-r--r--FrontEnd/src/components/button/ButtonRow.tsx62
-rw-r--r--FrontEnd/src/components/button/ButtonRowV2.tsx146
-rw-r--r--FrontEnd/src/components/button/FlatButton.css27
-rw-r--r--FrontEnd/src/components/button/FlatButton.tsx (renamed from FrontEnd/src/views/common/button/FlatButton.tsx)9
-rw-r--r--FrontEnd/src/components/button/IconButton.css30
-rw-r--r--FrontEnd/src/components/button/IconButton.tsx (renamed from FrontEnd/src/views/common/button/IconButton.tsx)7
-rw-r--r--FrontEnd/src/components/button/LoadingButton.css13
-rw-r--r--FrontEnd/src/components/button/LoadingButton.tsx39
-rw-r--r--FrontEnd/src/components/button/index.tsx15
-rw-r--r--FrontEnd/src/components/common.ts22
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.tsx54
-rw-r--r--FrontEnd/src/components/dialog/Dialog.css39
-rw-r--r--FrontEnd/src/components/dialog/Dialog.tsx55
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.css20
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.tsx95
-rw-r--r--FrontEnd/src/components/dialog/DialogProvider.tsx95
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.css30
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.tsx52
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.css4
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.tsx221
-rw-r--r--FrontEnd/src/components/dialog/index.tsx12
-rw-r--r--FrontEnd/src/components/hooks/index.ts5
-rw-r--r--FrontEnd/src/components/hooks/responsive.ts7
-rw-r--r--FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts24
-rw-r--r--FrontEnd/src/components/hooks/useClickOutside.ts (renamed from FrontEnd/src/utilities/hooks/useClickOutside.ts)2
-rw-r--r--FrontEnd/src/components/hooks/useScrollToBottom.ts (renamed from FrontEnd/src/utilities/hooks/useScrollToBottom.ts)9
-rw-r--r--FrontEnd/src/components/hooks/useWindowLeave.ts22
-rw-r--r--FrontEnd/src/components/index.css49
-rw-r--r--FrontEnd/src/components/input/InputGroup.css54
-rw-r--r--FrontEnd/src/components/input/InputGroup.tsx463
-rw-r--r--FrontEnd/src/components/input/index.ts11
-rw-r--r--FrontEnd/src/components/list/ListContainer.css4
-rw-r--r--FrontEnd/src/components/list/ListContainer.tsx23
-rw-r--r--FrontEnd/src/components/list/ListItemContainer.css7
-rw-r--r--FrontEnd/src/components/list/ListItemContainer.tsx23
-rw-r--r--FrontEnd/src/components/list/index.ts4
-rw-r--r--FrontEnd/src/components/menu/Menu.css36
-rw-r--r--FrontEnd/src/components/menu/Menu.tsx62
-rw-r--r--FrontEnd/src/components/menu/PopupMenu.css7
-rw-r--r--FrontEnd/src/components/menu/PopupMenu.tsx72
-rw-r--r--FrontEnd/src/components/tab/TabBar.css32
-rw-r--r--FrontEnd/src/components/tab/TabBar.tsx69
-rw-r--r--FrontEnd/src/components/tab/TabPages.css3
-rw-r--r--FrontEnd/src/components/tab/TabPages.tsx61
-rw-r--r--FrontEnd/src/components/tab/index.ts2
-rw-r--r--FrontEnd/src/components/theme.css201
-rw-r--r--FrontEnd/src/components/user/UserAvatar.tsx22
-rw-r--r--FrontEnd/src/http/bookmark.ts2
-rw-r--r--FrontEnd/src/http/timeline.ts2
-rw-r--r--FrontEnd/src/index.css29
-rw-r--r--FrontEnd/src/index.tsx7
-rw-r--r--FrontEnd/src/locales/en/translation.json23
-rw-r--r--FrontEnd/src/locales/zh/translation.json2
-rw-r--r--FrontEnd/src/migrating/admin/Admin.tsx (renamed from FrontEnd/src/views/admin/Admin.tsx)0
-rw-r--r--FrontEnd/src/migrating/admin/AdminNav.tsx (renamed from FrontEnd/src/views/admin/AdminNav.tsx)0
-rw-r--r--FrontEnd/src/migrating/admin/MoreAdmin.tsx (renamed from FrontEnd/src/views/admin/MoreAdmin.tsx)0
-rw-r--r--FrontEnd/src/migrating/admin/UserAdmin.tsx (renamed from FrontEnd/src/views/admin/UserAdmin.tsx)39
-rw-r--r--FrontEnd/src/migrating/admin/index.css (renamed from FrontEnd/src/views/admin/index.css)0
-rw-r--r--FrontEnd/src/migrating/admin/index.tsx (renamed from FrontEnd/src/views/admin/index.tsx)0
-rw-r--r--FrontEnd/src/migrating/center/CenterBoards.tsx (renamed from FrontEnd/src/views/center/CenterBoards.tsx)10
-rw-r--r--FrontEnd/src/migrating/center/TimelineBoard.tsx (renamed from FrontEnd/src/views/center/TimelineBoard.tsx)2
-rw-r--r--FrontEnd/src/migrating/center/TimelineCreateDialog.tsx (renamed from FrontEnd/src/views/center/TimelineCreateDialog.tsx)6
-rw-r--r--FrontEnd/src/migrating/center/index.css (renamed from FrontEnd/src/views/center/index.css)0
-rw-r--r--FrontEnd/src/migrating/center/index.tsx (renamed from FrontEnd/src/views/center/index.tsx)2
-rw-r--r--FrontEnd/src/pages/404/index.css7
-rw-r--r--FrontEnd/src/pages/404/index.tsx5
-rw-r--r--FrontEnd/src/pages/about/index.css7
-rw-r--r--FrontEnd/src/pages/about/index.tsx87
-rw-r--r--FrontEnd/src/pages/home/index.css13
-rw-r--r--FrontEnd/src/pages/home/index.tsx12
-rw-r--r--FrontEnd/src/pages/loading/index.css7
-rw-r--r--FrontEnd/src/pages/loading/index.tsx11
-rw-r--r--FrontEnd/src/pages/login/index.css14
-rw-r--r--FrontEnd/src/pages/login/index.tsx127
-rw-r--r--FrontEnd/src/pages/register/index.css (renamed from FrontEnd/src/views/register/index.css)0
-rw-r--r--FrontEnd/src/pages/register/index.tsx130
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.css22
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx276
-rw-r--r--FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx26
-rw-r--r--FrontEnd/src/pages/setting/ChangePasswordDialog.tsx70
-rw-r--r--FrontEnd/src/pages/setting/index.css76
-rw-r--r--FrontEnd/src/pages/setting/index.tsx297
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.css (renamed from FrontEnd/src/views/timeline/ConnectionStatusBadge.css)16
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx (renamed from FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx)21
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.css42
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.tsx (renamed from FrontEnd/src/views/timeline/Timeline.tsx)113
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.css9
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.tsx13
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx54
-rw-r--r--FrontEnd/src/pages/timeline/TimelineInfoCard.css63
-rw-r--r--FrontEnd/src/pages/timeline/TimelineInfoCard.tsx208
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.css20
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.tsx (renamed from FrontEnd/src/views/timeline/TimelineMember.tsx)119
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostCard.css9
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostCard.tsx22
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContainer.css3
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContainer.tsx20
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostList.css10
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostList.tsx (renamed from FrontEnd/src/views/timeline/TimelinePostListView.tsx)21
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.css37
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.tsx123
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx79
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.css5
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx36
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css24
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx199
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css12
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx29
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css35
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx193
-rw-r--r--FrontEnd/src/pages/timeline/index.tsx (renamed from FrontEnd/src/views/timeline/index.tsx)13
-rw-r--r--FrontEnd/src/pages/timeline/view/ImagePostView.css0
-rw-r--r--FrontEnd/src/pages/timeline/view/ImagePostView.tsx38
-rw-r--r--FrontEnd/src/pages/timeline/view/MarkdownPostView.css4
-rw-r--r--FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx59
-rw-r--r--FrontEnd/src/pages/timeline/view/PlainTextPostView.css0
-rw-r--r--FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx50
-rw-r--r--FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx37
-rw-r--r--FrontEnd/src/palette.ts167
-rw-r--r--FrontEnd/src/services/TimelinePostBuilder.ts125
-rw-r--r--FrontEnd/src/services/alert.ts6
-rw-r--r--FrontEnd/src/services/timeline.ts199
-rw-r--r--FrontEnd/src/services/user.ts23
-rw-r--r--FrontEnd/src/utilities/array.ts41
-rw-r--r--FrontEnd/src/utilities/base64.ts21
-rw-r--r--FrontEnd/src/utilities/geometry.ts292
-rw-r--r--FrontEnd/src/utilities/hooks.ts5
-rw-r--r--FrontEnd/src/utilities/hooks/mediaQuery.ts5
-rw-r--r--FrontEnd/src/utilities/index.ts27
-rw-r--r--FrontEnd/src/views/about/author-avatar.pngbin12038 -> 0 bytes
-rw-r--r--FrontEnd/src/views/about/github.pngbin4268 -> 0 bytes
-rw-r--r--FrontEnd/src/views/about/index.css4
-rw-r--r--FrontEnd/src/views/about/index.tsx143
-rw-r--r--FrontEnd/src/views/common/AppBar.css95
-rw-r--r--FrontEnd/src/views/common/AppBar.tsx81
-rw-r--r--FrontEnd/src/views/common/BlobImage.tsx27
-rw-r--r--FrontEnd/src/views/common/Card.css15
-rw-r--r--FrontEnd/src/views/common/Card.tsx27
-rw-r--r--FrontEnd/src/views/common/ImageCropper.tsx306
-rw-r--r--FrontEnd/src/views/common/LoadingPage.tsx13
-rw-r--r--FrontEnd/src/views/common/SearchInput.tsx79
-rw-r--r--FrontEnd/src/views/common/Skeleton.css14
-rw-r--r--FrontEnd/src/views/common/Skeleton.tsx32
-rw-r--r--FrontEnd/src/views/common/Spinner.tsx43
-rw-r--r--FrontEnd/src/views/common/alert/AlertHost.tsx113
-rw-r--r--FrontEnd/src/views/common/alert/alert.css33
-rw-r--r--FrontEnd/src/views/common/button/Button.css51
-rw-r--r--FrontEnd/src/views/common/button/FlatButton.css18
-rw-r--r--FrontEnd/src/views/common/button/IconButton.css10
-rw-r--r--FrontEnd/src/views/common/button/LoadingButton.tsx40
-rw-r--r--FrontEnd/src/views/common/button/index.tsx6
-rw-r--r--FrontEnd/src/views/common/dialog/ConfirmDialog.tsx43
-rw-r--r--FrontEnd/src/views/common/dialog/Dialog.css55
-rw-r--r--FrontEnd/src/views/common/dialog/Dialog.tsx51
-rw-r--r--FrontEnd/src/views/common/dialog/FullPageDialog.css44
-rw-r--r--FrontEnd/src/views/common/dialog/FullPageDialog.tsx53
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.css25
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.tsx531
-rw-r--r--FrontEnd/src/views/common/index.css293
-rw-r--r--FrontEnd/src/views/common/input/InputPanel.css25
-rw-r--r--FrontEnd/src/views/common/input/InputPanel.tsx257
-rw-r--r--FrontEnd/src/views/common/menu/Menu.css24
-rw-r--r--FrontEnd/src/views/common/menu/Menu.tsx72
-rw-r--r--FrontEnd/src/views/common/menu/PopupMenu.css6
-rw-r--r--FrontEnd/src/views/common/menu/PopupMenu.tsx71
-rw-r--r--FrontEnd/src/views/common/tab/TabPages.tsx71
-rw-r--r--FrontEnd/src/views/common/tab/Tabs.css33
-rw-r--r--FrontEnd/src/views/common/tab/Tabs.tsx62
-rw-r--r--FrontEnd/src/views/common/user/UserAvatar.tsx19
-rw-r--r--FrontEnd/src/views/home/TimelineListView.tsx97
-rw-r--r--FrontEnd/src/views/home/WebsiteIntroduction.tsx77
-rw-r--r--FrontEnd/src/views/home/index.css42
-rw-r--r--FrontEnd/src/views/home/index.tsx78
-rw-r--r--FrontEnd/src/views/login/index.css8
-rw-r--r--FrontEnd/src/views/login/index.tsx159
-rw-r--r--FrontEnd/src/views/register/index.tsx140
-rw-r--r--FrontEnd/src/views/search/index.css15
-rw-r--r--FrontEnd/src/views/search/index.tsx131
-rw-r--r--FrontEnd/src/views/settings/ChangeAvatarDialog.tsx354
-rw-r--r--FrontEnd/src/views/settings/ChangeNicknameDialog.tsx34
-rw-r--r--FrontEnd/src/views/settings/ChangePasswordDialog.tsx69
-rw-r--r--FrontEnd/src/views/settings/index.css31
-rw-r--r--FrontEnd/src/views/settings/index.tsx338
-rw-r--r--FrontEnd/src/views/timeline/CollapseButton.tsx21
-rw-r--r--FrontEnd/src/views/timeline/MarkdownPostEdit.css21
-rw-r--r--FrontEnd/src/views/timeline/MarkdownPostEdit.tsx215
-rw-r--r--FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx42
-rw-r--r--FrontEnd/src/views/timeline/Timeline.css244
-rw-r--r--FrontEnd/src/views/timeline/TimelineCard.tsx167
-rw-r--r--FrontEnd/src/views/timeline/TimelineDateLabel.tsx19
-rw-r--r--FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx61
-rw-r--r--FrontEnd/src/views/timeline/TimelineEmptyItem.tsx25
-rw-r--r--FrontEnd/src/views/timeline/TimelineLine.tsx51
-rw-r--r--FrontEnd/src/views/timeline/TimelineLoading.tsx16
-rw-r--r--FrontEnd/src/views/timeline/TimelineMember.css8
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostContentView.tsx187
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEdit.css10
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEdit.tsx267
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEditCard.tsx31
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx18
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostView.tsx159
-rw-r--r--FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx88
-rw-r--r--FrontEnd/tools/theme-generator.ts495
-rw-r--r--FrontEnd/tools/tsconfig.json21
-rw-r--r--FrontEnd/tsconfig.json6
-rwxr-xr-xdev4
-rwxr-xr-xdev-backend5
-rw-r--r--dev-backend.ps13
-rwxr-xr-xdev-frontend5
-rw-r--r--dev-frontend.ps13
245 files changed, 8531 insertions, 7448 deletions
diff --git a/FrontEnd/.editorconfig b/FrontEnd/.editorconfig
index 779719e0..7747f7a7 100644
--- a/FrontEnd/.editorconfig
+++ b/FrontEnd/.editorconfig
@@ -1,14 +1,16 @@
-root = true
-end_of_line = lf
-
-[*.ts]
-tab_width = 2
-
-[*.tsx]
-tab_width = 2
-
-[*.css]
-tab_width = 2
-
-[*.sass]
-tab_width = 2
+root = true
+end_of_line = lf
+indent_style = space
+
+[*.ts]
+indent_size = 2
+
+[*.tsx]
+indent_size = 2
+
+[*.css]
+indent_size = 2
+
+[*.sass]
+indent_size = 2
+
diff --git a/FrontEnd/.eslintignore b/FrontEnd/.eslintignore
index 6fc7bee6..19d1b61c 100644
--- a/FrontEnd/.eslintignore
+++ b/FrontEnd/.eslintignore
@@ -1 +1,2 @@
dist
+src/migrating
diff --git a/FrontEnd/.eslintrc.cjs b/FrontEnd/.eslintrc.js
index a9cd8e03..6fcccd3e 100644
--- a/FrontEnd/.eslintrc.cjs
+++ b/FrontEnd/.eslintrc.js
@@ -13,7 +13,7 @@ module.exports = {
plugins: ["@typescript-eslint", "prettier", "react", "react-hooks"],
parser: "@typescript-eslint/parser",
parserOptions: {
- project: true,
+ project: ["./tsconfig.json", "tools/tsconfig.json"],
tsconfigRootDir: __dirname
},
settings: {
diff --git a/FrontEnd/cspell.json b/FrontEnd/cspell.json
index a2e150c9..1d9c118a 100644
--- a/FrontEnd/cspell.json
+++ b/FrontEnd/cspell.json
@@ -7,6 +7,7 @@
"words": [
"languagedetector",
"popperjs",
- "signalr"
+ "signalr",
+ "webp"
]
}
diff --git a/FrontEnd/package.json b/FrontEnd/package.json
index 7f438f0e..4a94d319 100644
--- a/FrontEnd/package.json
+++ b/FrontEnd/package.json
@@ -1,11 +1,9 @@
{
"name": "timeline",
"version": "0.4.0",
- "private": true,
- "type": "module",
"source": "index.html",
"scripts": {
- "start": "parcel --port 5678",
+ "start": "parcel --port 5678 --hmr-port 6789",
"build": "tsc && parcel build",
"type-check": "tsc",
"lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx",
@@ -14,57 +12,56 @@
"check:fix": "pnpm run type-check && pnpm run lint:fix"
},
"dependencies": {
- "@microsoft/signalr": "^7.0.7",
- "@popperjs/core": "^2.11.8",
- "axios": "^1.4.0",
- "bootstrap": "^5.3.0",
- "bootstrap-icons": "^1.10.5",
+ "@floating-ui/react-dom": "^2.0.2",
+ "@microsoft/signalr": "^7.0.11",
+ "axios": "^1.5.0",
+ "bootstrap": "^5.3.2",
+ "bootstrap-icons": "^1.11.1",
"classnames": "^2.3.2",
"color": "^4.2.3",
- "core-js": "^3.31.1",
- "i18next": "^23.2.8",
+ "core-js": "^3.32.2",
+ "i18next": "^23.5.1",
"i18next-browser-languagedetector": "^7.1.0",
- "js-base64": "^3.7.5",
"lodash": "^4.17.21",
- "marked": "^5.1.1",
+ "marked": "^9.0.3",
"moment": "^2.29.4",
"react": "^18.2.0",
- "react-color": "^2.19.3",
"react-dom": "^18.2.0",
- "react-i18next": "^13.0.1",
+ "react-i18next": "^13.2.2",
"react-popper": "^2.3.0",
"react-responsive": "^9.0.2",
- "react-router-dom": "^6.14.1",
+ "react-router-dom": "^6.16.0",
"react-transition-group": "^4.4.5",
- "regenerator-runtime": "^0.13.11",
"rxjs": "^7.8.1",
"xregexp": "^5.1.1"
},
"devDependencies": {
"@parcel/packager-raw-url": "2.9.3",
"@parcel/transformer-webmanifest": "2.9.3",
- "@tsconfig/vite-react": "^2.0.0",
- "@types/color": "^3.0.3",
- "@types/lodash": "^4.14.195",
- "@types/marked": "^5.0.0",
- "@types/node": "^20.4.1",
+ "@tsconfig/node20": "^20.1.2",
+ "@types/color": "^3.0.4",
+ "@types/lodash": "^4.14.198",
+ "@types/marked": "^5.0.1",
+ "@types/node": "^20.6.3",
"@types/parcel-env": "^0.0.1",
- "@types/react": "^18.2.14",
+ "@types/react": "^18.2.22",
"@types/react-color": "^3.0.6",
- "@types/react-dom": "^18.2.6",
+ "@types/react-dom": "^18.2.7",
"@types/react-responsive": "^8.0.5",
"@types/react-transition-group": "^4.4.6",
- "@typescript-eslint/eslint-plugin": "^5.61.0",
- "@typescript-eslint/parser": "^5.61.0",
- "buffer": "^6.0.0",
- "eslint": "^8.44.0",
- "eslint-config-prettier": "^8.8.0",
- "eslint-plugin-prettier": "^4.2.1",
- "eslint-plugin-react": "^7.32.2",
+ "@typescript-eslint/eslint-plugin": "^6.7.2",
+ "@typescript-eslint/parser": "^6.7.2",
+ "buffer": "^6.0.3",
+ "eslint": "^8.49.0",
+ "eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-prettier": "^5.0.0",
+ "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
- "parcel": "latest",
- "prettier": "^3.0.0",
+ "parcel": "^2.9.3",
+ "prettier": "^3.0.3",
"process": "^0.11.10",
- "typescript": "^5.1.6"
+ "ts-node": "^10.9.1",
+ "typescript": "^5.2.2",
+ "typescript-language-server": "^3.3.2"
}
} \ No newline at end of file
diff --git a/FrontEnd/pnpm-lock.yaml b/FrontEnd/pnpm-lock.yaml
index d4a2962c..7084e82c 100644
--- a/FrontEnd/pnpm-lock.yaml
+++ b/FrontEnd/pnpm-lock.yaml
@@ -5,21 +5,21 @@ settings:
excludeLinksFromLockfile: false
dependencies:
+ '@floating-ui/react-dom':
+ specifier: ^2.0.2
+ version: 2.0.2(react-dom@18.2.0)(react@18.2.0)
'@microsoft/signalr':
- specifier: ^7.0.7
- version: 7.0.7
- '@popperjs/core':
- specifier: ^2.11.8
- version: 2.11.8
+ specifier: ^7.0.11
+ version: 7.0.11
axios:
- specifier: ^1.4.0
- version: 1.4.0
+ specifier: ^1.5.0
+ version: 1.5.0
bootstrap:
- specifier: ^5.3.0
- version: 5.3.0(@popperjs/core@2.11.8)
+ specifier: ^5.3.2
+ version: 5.3.2(@popperjs/core@2.11.8)
bootstrap-icons:
- specifier: ^1.10.5
- version: 1.10.5
+ specifier: ^1.11.1
+ version: 1.11.1
classnames:
specifier: ^2.3.2
version: 2.3.2
@@ -27,38 +27,32 @@ dependencies:
specifier: ^4.2.3
version: 4.2.3
core-js:
- specifier: ^3.31.1
- version: 3.31.1
+ specifier: ^3.32.2
+ version: 3.32.2
i18next:
- specifier: ^23.2.8
- version: 23.2.8
+ specifier: ^23.5.1
+ version: 23.5.1
i18next-browser-languagedetector:
specifier: ^7.1.0
version: 7.1.0
- js-base64:
- specifier: ^3.7.5
- version: 3.7.5
lodash:
specifier: ^4.17.21
version: 4.17.21
marked:
- specifier: ^5.1.1
- version: 5.1.1
+ specifier: ^9.0.3
+ version: 9.0.3
moment:
specifier: ^2.29.4
version: 2.29.4
react:
specifier: ^18.2.0
version: 18.2.0
- react-color:
- specifier: ^2.19.3
- version: 2.19.3(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-i18next:
- specifier: ^13.0.1
- version: 13.0.1(i18next@23.2.8)(react-dom@18.2.0)(react@18.2.0)
+ specifier: ^13.2.2
+ version: 13.2.2(i18next@23.5.1)(react-dom@18.2.0)(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0)
@@ -66,14 +60,11 @@ dependencies:
specifier: ^9.0.2
version: 9.0.2(react@18.2.0)
react-router-dom:
- specifier: ^6.14.1
- version: 6.14.1(react-dom@18.2.0)(react@18.2.0)
+ specifier: ^6.16.0
+ version: 6.16.0(react-dom@18.2.0)(react@18.2.0)
react-transition-group:
specifier: ^4.4.5
version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
- regenerator-runtime:
- specifier: ^0.13.11
- version: 0.13.11
rxjs:
specifier: ^7.8.1
version: 7.8.1
@@ -88,33 +79,33 @@ devDependencies:
'@parcel/transformer-webmanifest':
specifier: 2.9.3
version: 2.9.3(@parcel/core@2.9.3)
- '@tsconfig/vite-react':
- specifier: ^2.0.0
- version: 2.0.0
+ '@tsconfig/node20':
+ specifier: ^20.1.2
+ version: 20.1.2
'@types/color':
- specifier: ^3.0.3
- version: 3.0.3
+ specifier: ^3.0.4
+ version: 3.0.4
'@types/lodash':
- specifier: ^4.14.195
- version: 4.14.195
+ specifier: ^4.14.198
+ version: 4.14.198
'@types/marked':
- specifier: ^5.0.0
- version: 5.0.0
+ specifier: ^5.0.1
+ version: 5.0.1
'@types/node':
- specifier: ^20.4.1
- version: 20.4.1
+ specifier: ^20.6.3
+ version: 20.6.3
'@types/parcel-env':
specifier: ^0.0.1
version: 0.0.1
'@types/react':
- specifier: ^18.2.14
- version: 18.2.14
+ specifier: ^18.2.22
+ version: 18.2.22
'@types/react-color':
specifier: ^3.0.6
version: 3.0.6
'@types/react-dom':
- specifier: ^18.2.6
- version: 18.2.6
+ specifier: ^18.2.7
+ version: 18.2.7
'@types/react-responsive':
specifier: ^8.0.5
version: 8.0.5
@@ -122,41 +113,47 @@ devDependencies:
specifier: ^4.4.6
version: 4.4.6
'@typescript-eslint/eslint-plugin':
- specifier: ^5.61.0
- version: 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.1.6)
+ specifier: ^6.7.2
+ version: 6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.2.2)
'@typescript-eslint/parser':
- specifier: ^5.61.0
- version: 5.61.0(eslint@8.44.0)(typescript@5.1.6)
+ specifier: ^6.7.2
+ version: 6.7.2(eslint@8.49.0)(typescript@5.2.2)
buffer:
- specifier: ^6.0.0
+ specifier: ^6.0.3
version: 6.0.3
eslint:
- specifier: ^8.44.0
- version: 8.44.0
+ specifier: ^8.49.0
+ version: 8.49.0
eslint-config-prettier:
- specifier: ^8.8.0
- version: 8.8.0(eslint@8.44.0)
+ specifier: ^9.0.0
+ version: 9.0.0(eslint@8.49.0)
eslint-plugin-prettier:
- specifier: ^4.2.1
- version: 4.2.1(eslint-config-prettier@8.8.0)(eslint@8.44.0)(prettier@3.0.0)
+ specifier: ^5.0.0
+ version: 5.0.0(eslint-config-prettier@9.0.0)(eslint@8.49.0)(prettier@3.0.3)
eslint-plugin-react:
- specifier: ^7.32.2
- version: 7.32.2(eslint@8.44.0)
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.49.0)
eslint-plugin-react-hooks:
specifier: ^4.6.0
- version: 4.6.0(eslint@8.44.0)
+ version: 4.6.0(eslint@8.49.0)
parcel:
- specifier: latest
- version: 2.9.3
+ specifier: ^2.9.3
+ version: 2.9.3(typescript@5.2.2)
prettier:
- specifier: ^3.0.0
- version: 3.0.0
+ specifier: ^3.0.3
+ version: 3.0.3
process:
specifier: ^0.11.10
version: 0.11.10
+ ts-node:
+ specifier: ^10.9.1
+ version: 10.9.1(@types/node@20.6.3)(typescript@5.2.2)
typescript:
- specifier: ^5.1.6
- version: 5.1.6
+ specifier: ^5.2.2
+ version: 5.2.2
+ typescript-language-server:
+ specifier: ^3.3.2
+ version: 3.3.2
packages:
@@ -165,65 +162,73 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
- /@babel/code-frame@7.22.5:
- resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==}
+ /@babel/code-frame@7.22.13:
+ resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/highlight': 7.22.5
+ '@babel/highlight': 7.22.20
+ chalk: 2.4.2
dev: true
- /@babel/helper-validator-identifier@7.22.5:
- resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==}
+ /@babel/helper-validator-identifier@7.22.20:
+ resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==}
engines: {node: '>=6.9.0'}
dev: true
- /@babel/highlight@7.22.5:
- resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==}
+ /@babel/highlight@7.22.20:
+ resolution: {integrity: sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/helper-validator-identifier': 7.22.5
+ '@babel/helper-validator-identifier': 7.22.20
chalk: 2.4.2
js-tokens: 4.0.0
dev: true
- /@babel/runtime-corejs3@7.22.6:
- resolution: {integrity: sha512-M+37LLIRBTEVjktoJjbw4KVhupF0U/3PYUCbBwgAd9k17hoKhRu1n935QiG7Tuxv0LJOMrb2vuKEeYUlv0iyiw==}
+ /@babel/runtime-corejs3@7.22.15:
+ resolution: {integrity: sha512-SAj8oKi8UogVi6eXQXKNPu8qZ78Yzy7zawrlTr0M+IuW/g8Qe9gVDhGcF9h1S69OyACpYoLxEzpjs1M15sI5wQ==}
engines: {node: '>=6.9.0'}
dependencies:
- core-js-pure: 3.31.1
- regenerator-runtime: 0.13.11
+ core-js-pure: 3.32.2
+ regenerator-runtime: 0.14.0
dev: false
- /@babel/runtime@7.22.6:
- resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==}
+ /@babel/runtime@7.22.15:
+ resolution: {integrity: sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==}
engines: {node: '>=6.9.0'}
dependencies:
- regenerator-runtime: 0.13.11
+ regenerator-runtime: 0.14.0
dev: false
- /@eslint-community/eslint-utils@4.4.0(eslint@8.44.0):
+ /@cspotcode/source-map-support@0.8.1:
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
+ dev: true
+
+ /@eslint-community/eslint-utils@4.4.0(eslint@8.49.0):
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
dependencies:
- eslint: 8.44.0
- eslint-visitor-keys: 3.4.1
+ eslint: 8.49.0
+ eslint-visitor-keys: 3.4.3
dev: true
- /@eslint-community/regexpp@4.5.1:
- resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==}
+ /@eslint-community/regexpp@4.8.1:
+ resolution: {integrity: sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
dev: true
- /@eslint/eslintrc@2.1.0:
- resolution: {integrity: sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==}
+ /@eslint/eslintrc@2.1.2:
+ resolution: {integrity: sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
ajv: 6.12.6
debug: 4.3.4
- espree: 9.6.0
- globals: 13.20.0
+ espree: 9.6.1
+ globals: 13.21.0
ignore: 5.2.4
import-fresh: 3.3.0
js-yaml: 4.1.0
@@ -233,13 +238,41 @@ packages:
- supports-color
dev: true
- /@eslint/js@8.44.0:
- resolution: {integrity: sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==}
+ /@eslint/js@8.49.0:
+ resolution: {integrity: sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
- /@humanwhocodes/config-array@0.11.10:
- resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
+ /@floating-ui/core@1.5.0:
+ resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==}
+ dependencies:
+ '@floating-ui/utils': 0.1.4
+ dev: false
+
+ /@floating-ui/dom@1.5.3:
+ resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==}
+ dependencies:
+ '@floating-ui/core': 1.5.0
+ '@floating-ui/utils': 0.1.4
+ dev: false
+
+ /@floating-ui/react-dom@2.0.2(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+ dependencies:
+ '@floating-ui/dom': 1.5.3
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
+ /@floating-ui/utils@0.1.4:
+ resolution: {integrity: sha512-qprfWkn82Iw821mcKofJ5Pk9wgioHicxcQMxx+5zt5GSKoqdWvgG5AxVmpmUUjzTLPVSH5auBrhI93Deayn/DA==}
+ dev: false
+
+ /@humanwhocodes/config-array@0.11.11:
+ resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
engines: {node: '>=10.10.0'}
dependencies:
'@humanwhocodes/object-schema': 1.2.1
@@ -258,13 +291,21 @@ packages:
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
dev: true
- /@icons/material@0.2.4(react@18.2.0):
- resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
- peerDependencies:
- react: '*'
+ /@jridgewell/resolve-uri@3.1.1:
+ resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
+ engines: {node: '>=6.0.0'}
+ dev: true
+
+ /@jridgewell/sourcemap-codec@1.4.15:
+ resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
+ dev: true
+
+ /@jridgewell/trace-mapping@0.3.9:
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
dependencies:
- react: 18.2.0
- dev: false
+ '@jridgewell/resolve-uri': 3.1.1
+ '@jridgewell/sourcemap-codec': 1.4.15
+ dev: true
/@lezer/common@0.15.12:
resolution: {integrity: sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig==}
@@ -324,13 +365,13 @@ packages:
dev: true
optional: true
- /@microsoft/signalr@7.0.7:
- resolution: {integrity: sha512-RMWZLTxnjWPSaS9PYZxXAttql2JDM/+IsSJk0nACFhpLjnSw8UWfvUxOv/QjZSqLxhuksXxzBJ/91xUP6Y7Nvg==}
+ /@microsoft/signalr@7.0.11:
+ resolution: {integrity: sha512-//6ipnYKhHf2MJgM+MQSlgB5L/pcYeZ+v4w6YAr4epRM1iSDQ6WjUkCVX2ZMxcY06XGlLzggs3Z9ZIcL9ws9KQ==}
dependencies:
abort-controller: 3.0.0
eventsource: 2.0.2
fetch-cookie: 2.1.0
- node-fetch: 2.6.12
+ node-fetch: 2.7.0
ws: 7.5.9
transitivePeerDependencies:
- bufferutil
@@ -459,7 +500,7 @@ packages:
- '@parcel/core'
dev: true
- /@parcel/config-default@2.9.3(@parcel/core@2.9.3):
+ /@parcel/config-default@2.9.3(@parcel/core@2.9.3)(typescript@5.2.2):
resolution: {integrity: sha512-tqN5tF7QnVABDZAu76co5E6N8mA9n8bxiWdK4xYyINYFIEHgX172oRTqXTnhEMjlMrdmASxvnGlbaPBaVnrCTw==}
peerDependencies:
'@parcel/core': ^2.9.3
@@ -469,7 +510,7 @@ packages:
'@parcel/core': 2.9.3
'@parcel/namer-default': 2.9.3(@parcel/core@2.9.3)
'@parcel/optimizer-css': 2.9.3(@parcel/core@2.9.3)
- '@parcel/optimizer-htmlnano': 2.9.3(@parcel/core@2.9.3)
+ '@parcel/optimizer-htmlnano': 2.9.3(@parcel/core@2.9.3)(typescript@5.2.2)
'@parcel/optimizer-image': 2.9.3(@parcel/core@2.9.3)
'@parcel/optimizer-svgo': 2.9.3(@parcel/core@2.9.3)
'@parcel/optimizer-swc': 2.9.3(@parcel/core@2.9.3)
@@ -503,6 +544,7 @@ packages:
- relateurl
- srcset
- terser
+ - typescript
- uncss
dev: true
@@ -527,12 +569,12 @@ packages:
'@parcel/workers': 2.9.3(@parcel/core@2.9.3)
abortcontroller-polyfill: 1.7.5
base-x: 3.0.9
- browserslist: 4.21.9
+ browserslist: 4.21.10
clone: 2.1.2
dotenv: 7.0.0
dotenv-expand: 5.1.0
json5: 2.2.3
- msgpackr: 1.9.5
+ msgpackr: 1.9.9
nullthrows: 1.1.1
semver: 7.5.4
dev: true
@@ -565,7 +607,7 @@ packages:
'@parcel/fs-search': 2.9.3
'@parcel/types': 2.9.3(@parcel/core@2.9.3)
'@parcel/utils': 2.9.3
- '@parcel/watcher': 2.2.0
+ '@parcel/watcher': 2.3.0
'@parcel/workers': 2.9.3(@parcel/core@2.9.3)
dev: true
@@ -631,19 +673,19 @@ packages:
'@parcel/plugin': 2.9.3(@parcel/core@2.9.3)
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.9.3
- browserslist: 4.21.9
- lightningcss: 1.21.5
+ browserslist: 4.21.10
+ lightningcss: 1.22.0
nullthrows: 1.1.1
transitivePeerDependencies:
- '@parcel/core'
dev: true
- /@parcel/optimizer-htmlnano@2.9.3(@parcel/core@2.9.3):
+ /@parcel/optimizer-htmlnano@2.9.3(@parcel/core@2.9.3)(typescript@5.2.2):
resolution: {integrity: sha512-9g/KBck3c6DokmJfvJ5zpHFBiCSolaGrcsTGx8C3YPdCTVTI9P1TDCwUxvAr4LjpcIRSa82wlLCI+nF6sSgxKA==}
engines: {node: '>= 12.0.0', parcel: ^2.9.3}
dependencies:
'@parcel/plugin': 2.9.3(@parcel/core@2.9.3)
- htmlnano: 2.0.4(svgo@2.8.0)
+ htmlnano: 2.0.4(svgo@2.8.0)(typescript@5.2.2)
nullthrows: 1.1.1
posthtml: 0.16.6
svgo: 2.8.0
@@ -655,6 +697,7 @@ packages:
- relateurl
- srcset
- terser
+ - typescript
- uncss
dev: true
@@ -691,7 +734,7 @@ packages:
'@parcel/plugin': 2.9.3(@parcel/core@2.9.3)
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.9.3
- '@swc/core': 1.3.68
+ '@swc/core': 1.3.86
nullthrows: 1.1.1
transitivePeerDependencies:
- '@parcel/core'
@@ -750,7 +793,7 @@ packages:
'@parcel/plugin': 2.9.3(@parcel/core@2.9.3)
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.9.3
- globals: 13.20.0
+ globals: 13.21.0
nullthrows: 1.1.1
transitivePeerDependencies:
- '@parcel/core'
@@ -910,7 +953,7 @@ packages:
'@parcel/plugin': 2.9.3(@parcel/core@2.9.3)
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.9.3
- browserslist: 4.21.9
+ browserslist: 4.21.10
json5: 2.2.3
nullthrows: 1.1.1
semver: 7.5.4
@@ -926,8 +969,8 @@ packages:
'@parcel/plugin': 2.9.3(@parcel/core@2.9.3)
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.9.3
- browserslist: 4.21.9
- lightningcss: 1.21.5
+ browserslist: 4.21.10
+ lightningcss: 1.22.0
nullthrows: 1.1.1
transitivePeerDependencies:
- '@parcel/core'
@@ -975,8 +1018,8 @@ packages:
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.9.3
'@parcel/workers': 2.9.3(@parcel/core@2.9.3)
- '@swc/helpers': 0.5.1
- browserslist: 4.21.9
+ '@swc/helpers': 0.5.2
+ browserslist: 4.21.10
nullthrows: 1.1.1
regenerator-runtime: 0.13.11
semver: 7.5.4
@@ -1099,8 +1142,8 @@ packages:
nullthrows: 1.1.1
dev: true
- /@parcel/watcher-android-arm64@2.2.0:
- resolution: {integrity: sha512-nU2wh00CTQT9rr1TIKTjdQ9lAGYpmz6XuKw0nAwAN+S2A5YiD55BK1u+E5WMCT8YOIDe/n6gaj4o/Bi9294SSQ==}
+ /@parcel/watcher-android-arm64@2.3.0:
+ resolution: {integrity: sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
@@ -1108,8 +1151,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-darwin-arm64@2.2.0:
- resolution: {integrity: sha512-cJl0UZDcodciy3TDMomoK/Huxpjlkkim3SyMgWzjovHGOZKNce9guLz2dzuFwfObBFCjfznbFMIvAZ5syXotYw==}
+ /@parcel/watcher-darwin-arm64@2.3.0:
+ resolution: {integrity: sha512-mKY+oijI4ahBMc/GygVGvEdOq0L4DxhYgwQqYAz/7yPzuGi79oXrZG52WdpGA1wLBPrYb0T8uBaGFo7I6rvSKw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
@@ -1117,8 +1160,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-darwin-x64@2.2.0:
- resolution: {integrity: sha512-QI77zxaGrCV1StKcoRYfsUfmUmvPMPfQrubkBBy5XujV2fwaLgZivQOTQMBgp5K2+E19u1ufpspKXAPqSzpbyg==}
+ /@parcel/watcher-darwin-x64@2.3.0:
+ resolution: {integrity: sha512-20oBj8LcEOnLE3mgpy6zuOq8AplPu9NcSSSfyVKgfOhNAc4eF4ob3ldj0xWjGGbOF7Dcy1Tvm6ytvgdjlfUeow==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
@@ -1126,8 +1169,17 @@ packages:
dev: true
optional: true
- /@parcel/watcher-linux-arm-glibc@2.2.0:
- resolution: {integrity: sha512-I2GPBcAXazPzabCmfsa3HRRW+MGlqxYd8g8RIueJU+a4o5nyNZDz0CR1cu0INT0QSQXEZV7w6UE8Hz9CF8u3Pg==}
+ /@parcel/watcher-freebsd-x64@2.3.0:
+ resolution: {integrity: sha512-7LftKlaHunueAEiojhCn+Ef2CTXWsLgTl4hq0pkhkTBFI3ssj2bJXmH2L67mKpiAD5dz66JYk4zS66qzdnIOgw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@parcel/watcher-linux-arm-glibc@2.3.0:
+ resolution: {integrity: sha512-1apPw5cD2xBv1XIHPUlq0cO6iAaEUQ3BcY0ysSyD9Kuyw4MoWm1DV+W9mneWI+1g6OeP6dhikiFE6BlU+AToTQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
@@ -1135,8 +1187,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-linux-arm64-glibc@2.2.0:
- resolution: {integrity: sha512-St5mlfp+2lS9AmgixUqfwJa/DwVmTCJxC1HcOubUTz6YFOKIlkHCeUa1Bxi4E/tR/HSez8+heXHL8HQkJ4Bd8g==}
+ /@parcel/watcher-linux-arm64-glibc@2.3.0:
+ resolution: {integrity: sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
@@ -1144,8 +1196,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-linux-arm64-musl@2.2.0:
- resolution: {integrity: sha512-jS+qfhhoOBVWwMLP65MaG8xdInMK30pPW8wqTCg2AAuVJh5xepMbzkhHJ4zURqHiyY3EiIRuYu4ONJKCxt8iqA==}
+ /@parcel/watcher-linux-arm64-musl@2.3.0:
+ resolution: {integrity: sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
@@ -1153,8 +1205,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-linux-x64-glibc@2.2.0:
- resolution: {integrity: sha512-xJvJ7R2wJdi47WZBFS691RDOWvP1j/IAs3EXaWVhDI8FFITbWrWaln7KoNcR0Y3T+ZwimFY/cfb0PNht1q895g==}
+ /@parcel/watcher-linux-x64-glibc@2.3.0:
+ resolution: {integrity: sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
@@ -1162,8 +1214,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-linux-x64-musl@2.2.0:
- resolution: {integrity: sha512-D+NMpgr23a+RI5mu8ZPKWy7AqjBOkURFDgP5iIXXEf/K3hm0jJ3ogzi0Ed2237B/CdYREimCgXyeiAlE/FtwyA==}
+ /@parcel/watcher-linux-x64-musl@2.3.0:
+ resolution: {integrity: sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
@@ -1171,8 +1223,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher-win32-arm64@2.2.0:
- resolution: {integrity: sha512-z225cPn3aygJsyVUOWwfyW+fY0Tvk7N3XCOl66qUPFxpbuXeZuiuuJemmtm8vxyqa3Ur7peU/qJxrpC64aeI7Q==}
+ /@parcel/watcher-win32-arm64@2.3.0:
+ resolution: {integrity: sha512-35gXCnaz1AqIXpG42evcoP2+sNL62gZTMZne3IackM+6QlfMcJLy3DrjuL6Iks7Czpd3j4xRBzez3ADCj1l7Aw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
@@ -1180,8 +1232,17 @@ packages:
dev: true
optional: true
- /@parcel/watcher-win32-x64@2.2.0:
- resolution: {integrity: sha512-JqGW0RJ61BkKx+yYzIURt9s53P7xMVbv0uxYPzAXLBINGaFmkIKSuUPyBVfy8TMbvp93lvF4SPBNDzVRJfvgOw==}
+ /@parcel/watcher-win32-ia32@2.3.0:
+ resolution: {integrity: sha512-FJS/IBQHhRpZ6PiCjFt1UAcPr0YmCLHRbTc00IBTrelEjlmmgIVLeOx4MSXzx2HFEy5Jo5YdhGpxCuqCyDJ5ow==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@parcel/watcher-win32-x64@2.3.0:
+ resolution: {integrity: sha512-dLx+0XRdMnVI62kU3wbXvbIRhLck4aE28bIGKbRGS7BJNt54IIj9+c/Dkqb+7DJEbHUZAX1bwaoM8PqVlHJmCA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
@@ -1189,8 +1250,8 @@ packages:
dev: true
optional: true
- /@parcel/watcher@2.2.0:
- resolution: {integrity: sha512-71S4TF+IMyAn24PK4KSkdKtqJDR3zRzb0HE3yXpacItqTM7XfF2f5q9NEGLEVl0dAaBAGfNwDCjH120y25F6Tg==}
+ /@parcel/watcher@2.3.0:
+ resolution: {integrity: sha512-pW7QaFiL11O0BphO+bq3MgqeX/INAk9jgBldVDYjlQPO4VddoZnF22TcF9onMhnLVHuNqBJeRf+Fj7eezi/+rQ==}
engines: {node: '>= 10.0.0'}
dependencies:
detect-libc: 1.0.3
@@ -1198,16 +1259,18 @@ packages:
micromatch: 4.0.5
node-addon-api: 7.0.0
optionalDependencies:
- '@parcel/watcher-android-arm64': 2.2.0
- '@parcel/watcher-darwin-arm64': 2.2.0
- '@parcel/watcher-darwin-x64': 2.2.0
- '@parcel/watcher-linux-arm-glibc': 2.2.0
- '@parcel/watcher-linux-arm64-glibc': 2.2.0
- '@parcel/watcher-linux-arm64-musl': 2.2.0
- '@parcel/watcher-linux-x64-glibc': 2.2.0
- '@parcel/watcher-linux-x64-musl': 2.2.0
- '@parcel/watcher-win32-arm64': 2.2.0
- '@parcel/watcher-win32-x64': 2.2.0
+ '@parcel/watcher-android-arm64': 2.3.0
+ '@parcel/watcher-darwin-arm64': 2.3.0
+ '@parcel/watcher-darwin-x64': 2.3.0
+ '@parcel/watcher-freebsd-x64': 2.3.0
+ '@parcel/watcher-linux-arm-glibc': 2.3.0
+ '@parcel/watcher-linux-arm64-glibc': 2.3.0
+ '@parcel/watcher-linux-arm64-musl': 2.3.0
+ '@parcel/watcher-linux-x64-glibc': 2.3.0
+ '@parcel/watcher-linux-x64-musl': 2.3.0
+ '@parcel/watcher-win32-arm64': 2.3.0
+ '@parcel/watcher-win32-ia32': 2.3.0
+ '@parcel/watcher-win32-x64': 2.3.0
dev: true
/@parcel/workers@2.9.3(@parcel/core@2.9.3):
@@ -1225,17 +1288,29 @@ packages:
nullthrows: 1.1.1
dev: true
+ /@pkgr/utils@2.4.2:
+ resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==}
+ engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ dependencies:
+ cross-spawn: 7.0.3
+ fast-glob: 3.3.1
+ is-glob: 4.0.3
+ open: 9.1.0
+ picocolors: 1.0.0
+ tslib: 2.6.2
+ dev: true
+
/@popperjs/core@2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
- /@remix-run/router@1.7.1:
- resolution: {integrity: sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ==}
- engines: {node: '>=14'}
+ /@remix-run/router@1.9.0:
+ resolution: {integrity: sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==}
+ engines: {node: '>=14.0.0'}
dev: false
- /@swc/core-darwin-arm64@1.3.68:
- resolution: {integrity: sha512-Z5pNxeuP2NxpOHTzDQkJs0wAPLnTlglZnR3WjObijwvdwT/kw1Y5EPDKM/BVSIeG40SPMkDLBbI0aj0qyXzrBA==}
+ /@swc/core-darwin-arm64@1.3.86:
+ resolution: {integrity: sha512-hMvSDms0sJJHNtRa3Vhmr9StWN1vmikbf5VE0IZUYGnF1/JZTkXU1h6CdNUY4Hr6i7uCZjH6BEhxFHX1JtKV4w==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
@@ -1243,8 +1318,8 @@ packages:
dev: true
optional: true
- /@swc/core-darwin-x64@1.3.68:
- resolution: {integrity: sha512-ZHl42g6yXhfX4PzAQ0BNvBXpt/OcbAHfubWRN6eXELK3fiNnxL7QBW1if7iizlq6iA+Mj1pwHyyUit1pz0+fgA==}
+ /@swc/core-darwin-x64@1.3.86:
+ resolution: {integrity: sha512-Jro6HVH4uSOBM7tTDaQNKLNc8BJV7n+SO+Ft2HAZINyeKJS/8MfEYneG7Vmqg18gv00c6dz9AOCcyz+BR7BFkQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
@@ -1252,8 +1327,8 @@ packages:
dev: true
optional: true
- /@swc/core-linux-arm-gnueabihf@1.3.68:
- resolution: {integrity: sha512-Mk8f6KCOQ2CNAR4PtWajIjS6XKSSR7ZYDOCf1GXRxhS3qEyQH7V8elWvqWYqHcT4foO60NUmxA/NOM/dQrdO1A==}
+ /@swc/core-linux-arm-gnueabihf@1.3.86:
+ resolution: {integrity: sha512-wYB9m0pzXJVSzedXSl4JwS3gKtvcPinpe9MbkddezpqL7OjyDP6pHHW9qIucsfgCrtMtbPC2nqulXLPtAAyIjw==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
@@ -1261,8 +1336,8 @@ packages:
dev: true
optional: true
- /@swc/core-linux-arm64-gnu@1.3.68:
- resolution: {integrity: sha512-RhBllggh9t9sIxaRgRcGrVaS7fDk6KsIqR6b9+dwU5OyDr4ZyHWw1ZaH/1/HAebuXYhNBjoNUiRtca6lKRIPgQ==}
+ /@swc/core-linux-arm64-gnu@1.3.86:
+ resolution: {integrity: sha512-fR44IyK5cdCaO8cC++IEH0Jn03tWnunJnjzA99LxlE5TRInSIOvFm+g5OSUQZDAvEXmQ38sd31LO2HOoDS1Edw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
@@ -1270,8 +1345,8 @@ packages:
dev: true
optional: true
- /@swc/core-linux-arm64-musl@1.3.68:
- resolution: {integrity: sha512-8K3zjU+tFgn6yGDEeD343gkKaHU9dhz77NiVkI1VzwRaT/Ag5pwl5eMQ1yStm8koNFzn3zq6rGjHfI5g2yI5Wg==}
+ /@swc/core-linux-arm64-musl@1.3.86:
+ resolution: {integrity: sha512-EUPfdbK4dUk/nkX3Vmv/47XH+DqHOa9JI0CTthvJ8/ZXei1MKDUsUc+tI1zMQX2uCuSkSWsEIEpCmA0tMwFhtw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
@@ -1279,8 +1354,8 @@ packages:
dev: true
optional: true
- /@swc/core-linux-x64-gnu@1.3.68:
- resolution: {integrity: sha512-4xAnvsBOyeTL0AB8GWlRKDM/hsysJ5jr5qvdKKI3rZfJgnnxl/xSX6TJKPsJ8gygfUJ3BmfCbmUmEyeDZ3YPvA==}
+ /@swc/core-linux-x64-gnu@1.3.86:
+ resolution: {integrity: sha512-snVZZWv8XgNVaKrTxtO3rUN+BbbB6I8Fqwe8zM/DWGJ096J13r89doQ48x5ZyO+bW4D48eZIWP5pdfSW7oBE3w==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
@@ -1288,8 +1363,8 @@ packages:
dev: true
optional: true
- /@swc/core-linux-x64-musl@1.3.68:
- resolution: {integrity: sha512-RCpaBo1fcpy1EFdjF+I7N4lfzOaHXVV0iMw/ABM+0PD6tp3V/9pxsguaZyeAHyEiUlDA6PZ4TfXv5zfnXEgW4Q==}
+ /@swc/core-linux-x64-musl@1.3.86:
+ resolution: {integrity: sha512-PnnksUJymEJkdnbV2orOSOSB441UqsxYbJge9zbr5UTRXUfWO3eFRV0iTBegjTlOQGbW6yN+YRSDkenTbmCI6g==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
@@ -1297,8 +1372,8 @@ packages:
dev: true
optional: true
- /@swc/core-win32-arm64-msvc@1.3.68:
- resolution: {integrity: sha512-v2WZvXrSslYEpY1nqpItyamL4DyaJinmOkXvM8Bc1LLKU5rGuvmBdjUYg/5Y+o0AUynuiWubpgHNOkBWiCvfqw==}
+ /@swc/core-win32-arm64-msvc@1.3.86:
+ resolution: {integrity: sha512-XlGEGyHwLndm08VvgeAPGj40L+Hx575MQC+2fsyB1uSNUN+uf7fvke+wc7k50a92CaQe/8foLyIR5faayozEJA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
@@ -1306,8 +1381,8 @@ packages:
dev: true
optional: true
- /@swc/core-win32-ia32-msvc@1.3.68:
- resolution: {integrity: sha512-HH5NJrIdzkJs+1xxprie0qSCMBeL9yeEhcC1yZTzYv8bwmabOUSdtKIqS55iYP/2hLWn9CTbvKPmLOIhCopW3Q==}
+ /@swc/core-win32-ia32-msvc@1.3.86:
+ resolution: {integrity: sha512-U1BhZa1x9yn+wZGTQmt1cYR79a0FzW/wL6Jas1Pn0bykKLxdRU4mCeZt2P+T3buLm8jr8LpPWiCrbvr658PzwA==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
@@ -1315,8 +1390,8 @@ packages:
dev: true
optional: true
- /@swc/core-win32-x64-msvc@1.3.68:
- resolution: {integrity: sha512-9HZVtLQUgK8r/yXQdwe0VBexbIcrY6+fBROhs7AAPWdewpaUeLkwQEJk6TbYr9CQuHw26FFGg6SjwAiqXF+kgQ==}
+ /@swc/core-win32-x64-msvc@1.3.86:
+ resolution: {integrity: sha512-wRoQUajqpE3wITHhZVj/6BPu/QwHriFHLHuJA+9y6PeGtUtTmntL42aBKXIFhfL767dYFtohyNg1uZ9eqbGyGQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
@@ -1324,8 +1399,8 @@ packages:
dev: true
optional: true
- /@swc/core@1.3.68:
- resolution: {integrity: sha512-njGQuJO+Wy06dEayt70cf0c/KI3HGjm4iW9LLViVLBuYNzJ4SSdNfzejludzufu6im+dsDJ0i3QjgWhAIcVHMQ==}
+ /@swc/core@1.3.86:
+ resolution: {integrity: sha512-bEXUtm37bcmJ3q+geG7Zy4rJNUzpxalXQUrrqX1ZoGj3HRtzdeVZ0L/um3fG2j16qe61t8TX/OIZ2G6j6dkG/w==}
engines: {node: '>=10'}
requiresBuild: true
peerDependencies:
@@ -1333,23 +1408,29 @@ packages:
peerDependenciesMeta:
'@swc/helpers':
optional: true
+ dependencies:
+ '@swc/types': 0.1.5
optionalDependencies:
- '@swc/core-darwin-arm64': 1.3.68
- '@swc/core-darwin-x64': 1.3.68
- '@swc/core-linux-arm-gnueabihf': 1.3.68
- '@swc/core-linux-arm64-gnu': 1.3.68
- '@swc/core-linux-arm64-musl': 1.3.68
- '@swc/core-linux-x64-gnu': 1.3.68
- '@swc/core-linux-x64-musl': 1.3.68
- '@swc/core-win32-arm64-msvc': 1.3.68
- '@swc/core-win32-ia32-msvc': 1.3.68
- '@swc/core-win32-x64-msvc': 1.3.68
+ '@swc/core-darwin-arm64': 1.3.86
+ '@swc/core-darwin-x64': 1.3.86
+ '@swc/core-linux-arm-gnueabihf': 1.3.86
+ '@swc/core-linux-arm64-gnu': 1.3.86
+ '@swc/core-linux-arm64-musl': 1.3.86
+ '@swc/core-linux-x64-gnu': 1.3.86
+ '@swc/core-linux-x64-musl': 1.3.86
+ '@swc/core-win32-arm64-msvc': 1.3.86
+ '@swc/core-win32-ia32-msvc': 1.3.86
+ '@swc/core-win32-x64-msvc': 1.3.86
dev: true
- /@swc/helpers@0.5.1:
- resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
+ /@swc/helpers@0.5.2:
+ resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==}
dependencies:
- tslib: 2.6.0
+ tslib: 2.6.2
+ dev: true
+
+ /@swc/types@0.1.5:
+ resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==}
dev: true
/@trysound/sax@0.2.0:
@@ -1357,12 +1438,28 @@ packages:
engines: {node: '>=10.13.0'}
dev: true
- /@tsconfig/vite-react@2.0.0:
- resolution: {integrity: sha512-erT+k9yzjRYnqRn6Fmvz+Y8+AtE+/YE954frGGwwit2ifsoWzRzYaOTlGj9/z0xJyYiaKNnNiFhid312QdC4rw==}
+ /@tsconfig/node10@1.0.9:
+ resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
+ dev: true
+
+ /@tsconfig/node12@1.0.11:
+ resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
+ dev: true
+
+ /@tsconfig/node14@1.0.3:
+ resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
+ dev: true
+
+ /@tsconfig/node16@1.0.4:
+ resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
+ dev: true
+
+ /@tsconfig/node20@20.1.2:
+ resolution: {integrity: sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==}
dev: true
- /@types/color-convert@2.0.0:
- resolution: {integrity: sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==}
+ /@types/color-convert@2.0.1:
+ resolution: {integrity: sha512-GwXanrvq/tBHJtudbl1lSy9Ybt7KS9+rA+YY3bcuIIM+d6jSHUr+5yjO83gtiRpuaPiBccwFjSnAK2qSrIPA7w==}
dependencies:
'@types/color-name': 1.1.1
dev: true
@@ -1371,65 +1468,65 @@ packages:
resolution: {integrity: sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==}
dev: true
- /@types/color@3.0.3:
- resolution: {integrity: sha512-X//qzJ3d3Zj82J9sC/C18ZY5f43utPbAJ6PhYt/M7uG6etcF6MRpKdN880KBy43B0BMzSfeT96MzrsNjFI3GbA==}
+ /@types/color@3.0.4:
+ resolution: {integrity: sha512-OpisS4bqJJwbkkQRrMvURf3DOxBoAg9mysHYI7WgrWpSYHqHGKYBULHdz4ih77SILcLDo/zyHGFyfIl9yb8NZQ==}
dependencies:
- '@types/color-convert': 2.0.0
+ '@types/color-convert': 2.0.1
dev: true
- /@types/json-schema@7.0.12:
- resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
+ /@types/json-schema@7.0.13:
+ resolution: {integrity: sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==}
dev: true
- /@types/lodash@4.14.195:
- resolution: {integrity: sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==}
+ /@types/lodash@4.14.198:
+ resolution: {integrity: sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg==}
dev: true
- /@types/marked@5.0.0:
- resolution: {integrity: sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==}
+ /@types/marked@5.0.1:
+ resolution: {integrity: sha512-Y3pAUzHKh605fN6fvASsz5FDSWbZcs/65Q6xYRmnIP9ZIYz27T4IOmXfH9gWJV1dpi7f1e7z7nBGUTx/a0ptpA==}
dev: true
- /@types/node@20.4.1:
- resolution: {integrity: sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==}
+ /@types/node@20.6.3:
+ resolution: {integrity: sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==}
dev: true
/@types/parcel-env@0.0.1:
resolution: {integrity: sha512-8WmdiJ1uEBcW6AOWzQH7i0141ZXZr7B03YfTpguUDrTHXJHwYU9eEOckBRCZzYGrzb4pdoyBlaIMiTee04uqPQ==}
dev: true
- /@types/prop-types@15.7.5:
- resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
+ /@types/prop-types@15.7.6:
+ resolution: {integrity: sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg==}
dev: true
/@types/react-color@3.0.6:
resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==}
dependencies:
- '@types/react': 18.2.14
+ '@types/react': 18.2.22
'@types/reactcss': 1.2.6
dev: true
- /@types/react-dom@18.2.6:
- resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==}
+ /@types/react-dom@18.2.7:
+ resolution: {integrity: sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==}
dependencies:
- '@types/react': 18.2.14
+ '@types/react': 18.2.22
dev: true
/@types/react-responsive@8.0.5:
resolution: {integrity: sha512-k3gQJgI87oP5IrVZe//3LKJFnAeFaqqWmmtl5eoYL2H3HqFcIhUaE30kRK1CsW3DHdojZxcVj4ZNc2ClsEu2PA==}
dependencies:
- '@types/react': 18.2.14
+ '@types/react': 18.2.22
dev: true
/@types/react-transition-group@4.4.6:
resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==}
dependencies:
- '@types/react': 18.2.14
+ '@types/react': 18.2.22
dev: true
- /@types/react@18.2.14:
- resolution: {integrity: sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==}
+ /@types/react@18.2.22:
+ resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==}
dependencies:
- '@types/prop-types': 15.7.5
+ '@types/prop-types': 15.7.6
'@types/scheduler': 0.16.3
csstype: 3.1.2
dev: true
@@ -1437,145 +1534,146 @@ packages:
/@types/reactcss@1.2.6:
resolution: {integrity: sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==}
dependencies:
- '@types/react': 18.2.14
+ '@types/react': 18.2.22
dev: true
/@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: true
- /@types/semver@7.5.0:
- resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
+ /@types/semver@7.5.2:
+ resolution: {integrity: sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==}
dev: true
- /@typescript-eslint/eslint-plugin@5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.1.6):
- resolution: {integrity: sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/eslint-plugin@6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.2.2):
+ resolution: {integrity: sha512-ooaHxlmSgZTM6CHYAFRlifqh1OAr3PAQEwi7lhYhaegbnXrnh7CDcHmc3+ihhbQC7H0i4JF0psI5ehzkF6Yl6Q==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
- '@typescript-eslint/parser': ^5.0.0
- eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
+ '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
+ eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@eslint-community/regexpp': 4.5.1
- '@typescript-eslint/parser': 5.61.0(eslint@8.44.0)(typescript@5.1.6)
- '@typescript-eslint/scope-manager': 5.61.0
- '@typescript-eslint/type-utils': 5.61.0(eslint@8.44.0)(typescript@5.1.6)
- '@typescript-eslint/utils': 5.61.0(eslint@8.44.0)(typescript@5.1.6)
+ '@eslint-community/regexpp': 4.8.1
+ '@typescript-eslint/parser': 6.7.2(eslint@8.49.0)(typescript@5.2.2)
+ '@typescript-eslint/scope-manager': 6.7.2
+ '@typescript-eslint/type-utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2)
+ '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2)
+ '@typescript-eslint/visitor-keys': 6.7.2
debug: 4.3.4
- eslint: 8.44.0
+ eslint: 8.49.0
graphemer: 1.4.0
ignore: 5.2.4
- natural-compare-lite: 1.4.0
+ natural-compare: 1.4.0
semver: 7.5.4
- tsutils: 3.21.0(typescript@5.1.6)
- typescript: 5.1.6
+ ts-api-utils: 1.0.3(typescript@5.2.2)
+ typescript: 5.2.2
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/parser@5.61.0(eslint@8.44.0)(typescript@5.1.6):
- resolution: {integrity: sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/parser@6.7.2(eslint@8.49.0)(typescript@5.2.2):
+ resolution: {integrity: sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
- eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
+ eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/scope-manager': 5.61.0
- '@typescript-eslint/types': 5.61.0
- '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.1.6)
+ '@typescript-eslint/scope-manager': 6.7.2
+ '@typescript-eslint/types': 6.7.2
+ '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.2.2)
+ '@typescript-eslint/visitor-keys': 6.7.2
debug: 4.3.4
- eslint: 8.44.0
- typescript: 5.1.6
+ eslint: 8.49.0
+ typescript: 5.2.2
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/scope-manager@5.61.0:
- resolution: {integrity: sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/scope-manager@6.7.2:
+ resolution: {integrity: sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==}
+ engines: {node: ^16.0.0 || >=18.0.0}
dependencies:
- '@typescript-eslint/types': 5.61.0
- '@typescript-eslint/visitor-keys': 5.61.0
+ '@typescript-eslint/types': 6.7.2
+ '@typescript-eslint/visitor-keys': 6.7.2
dev: true
- /@typescript-eslint/type-utils@5.61.0(eslint@8.44.0)(typescript@5.1.6):
- resolution: {integrity: sha512-kk8u//r+oVK2Aj3ph/26XdH0pbAkC2RiSjUYhKD+PExemG4XSjpGFeyZ/QM8lBOa7O8aGOU+/yEbMJgQv/DnCg==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/type-utils@6.7.2(eslint@8.49.0)(typescript@5.2.2):
+ resolution: {integrity: sha512-36F4fOYIROYRl0qj95dYKx6kybddLtsbmPIYNK0OBeXv2j9L5nZ17j9jmfy+bIDHKQgn2EZX+cofsqi8NPATBQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
- eslint: '*'
+ eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.1.6)
- '@typescript-eslint/utils': 5.61.0(eslint@8.44.0)(typescript@5.1.6)
+ '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.2.2)
+ '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2)
debug: 4.3.4
- eslint: 8.44.0
- tsutils: 3.21.0(typescript@5.1.6)
- typescript: 5.1.6
+ eslint: 8.49.0
+ ts-api-utils: 1.0.3(typescript@5.2.2)
+ typescript: 5.2.2
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/types@5.61.0:
- resolution: {integrity: sha512-ldyueo58KjngXpzloHUog/h9REmHl59G1b3a5Sng1GfBo14BkS3ZbMEb3693gnP1k//97lh7bKsp6/V/0v1veQ==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/types@6.7.2:
+ resolution: {integrity: sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==}
+ engines: {node: ^16.0.0 || >=18.0.0}
dev: true
- /@typescript-eslint/typescript-estree@5.61.0(typescript@5.1.6):
- resolution: {integrity: sha512-Fud90PxONnnLZ36oR5ClJBLTLfU4pIWBmnvGwTbEa2cXIqj70AEDEmOmpkFComjBZ/037ueKrOdHuYmSFVD7Rw==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/typescript-estree@6.7.2(typescript@5.2.2):
+ resolution: {integrity: sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/types': 5.61.0
- '@typescript-eslint/visitor-keys': 5.61.0
+ '@typescript-eslint/types': 6.7.2
+ '@typescript-eslint/visitor-keys': 6.7.2
debug: 4.3.4
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
- tsutils: 3.21.0(typescript@5.1.6)
- typescript: 5.1.6
+ ts-api-utils: 1.0.3(typescript@5.2.2)
+ typescript: 5.2.2
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/utils@5.61.0(eslint@8.44.0)(typescript@5.1.6):
- resolution: {integrity: sha512-mV6O+6VgQmVE6+xzlA91xifndPW9ElFW8vbSF0xCT/czPXVhwDewKila1jOyRwa9AE19zKnrr7Cg5S3pJVrTWQ==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/utils@6.7.2(eslint@8.49.0)(typescript@5.2.2):
+ resolution: {integrity: sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
- eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
- dependencies:
- '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0)
- '@types/json-schema': 7.0.12
- '@types/semver': 7.5.0
- '@typescript-eslint/scope-manager': 5.61.0
- '@typescript-eslint/types': 5.61.0
- '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.1.6)
- eslint: 8.44.0
- eslint-scope: 5.1.1
+ eslint: ^7.0.0 || ^8.0.0
+ dependencies:
+ '@eslint-community/eslint-utils': 4.4.0(eslint@8.49.0)
+ '@types/json-schema': 7.0.13
+ '@types/semver': 7.5.2
+ '@typescript-eslint/scope-manager': 6.7.2
+ '@typescript-eslint/types': 6.7.2
+ '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.2.2)
+ eslint: 8.49.0
semver: 7.5.4
transitivePeerDependencies:
- supports-color
- typescript
dev: true
- /@typescript-eslint/visitor-keys@5.61.0:
- resolution: {integrity: sha512-50XQ5VdbWrX06mQXhy93WywSFZZGsv3EOjq+lqp6WC2t+j3mb6A9xYVdrRxafvK88vg9k9u+CT4l6D8PEatjKg==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ /@typescript-eslint/visitor-keys@6.7.2:
+ resolution: {integrity: sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
dependencies:
- '@typescript-eslint/types': 5.61.0
- eslint-visitor-keys: 3.4.1
+ '@typescript-eslint/types': 6.7.2
+ eslint-visitor-keys: 3.4.3
dev: true
/abort-controller@3.0.0:
@@ -1597,6 +1695,11 @@ packages:
acorn: 8.10.0
dev: true
+ /acorn-walk@8.2.0:
+ resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==}
+ engines: {node: '>=0.4.0'}
+ dev: true
+
/acorn@8.10.0:
resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==}
engines: {node: '>=0.4.0'}
@@ -1631,6 +1734,10 @@ packages:
color-convert: 2.0.1
dev: true
+ /arg@4.1.3:
+ resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
+ dev: true
+
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
dev: true
@@ -1642,13 +1749,13 @@ packages:
is-array-buffer: 3.0.2
dev: true
- /array-includes@3.1.6:
- resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==}
+ /array-includes@3.1.7:
+ resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
get-intrinsic: 1.2.1
is-string: 1.0.7
dev: true
@@ -1658,36 +1765,55 @@ packages:
engines: {node: '>=8'}
dev: true
- /array.prototype.flat@1.3.1:
- resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==}
+ /array.prototype.flat@1.3.2:
+ resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
es-shim-unscopables: 1.0.0
dev: true
- /array.prototype.flatmap@1.3.1:
- resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==}
+ /array.prototype.flatmap@1.3.2:
+ resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
es-shim-unscopables: 1.0.0
dev: true
- /array.prototype.tosorted@1.1.1:
- resolution: {integrity: sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==}
+ /array.prototype.tosorted@1.1.2:
+ resolution: {integrity: sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
es-shim-unscopables: 1.0.0
get-intrinsic: 1.2.1
dev: true
+ /arraybuffer.prototype.slice@1.0.2:
+ resolution: {integrity: sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ array-buffer-byte-length: 1.0.0
+ call-bind: 1.0.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
+ get-intrinsic: 1.2.1
+ is-array-buffer: 3.0.2
+ is-shared-array-buffer: 1.0.2
+ dev: true
+
+ /asynciterator.prototype@1.0.0:
+ resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==}
+ dependencies:
+ has-symbols: 1.0.3
+ dev: true
+
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false
@@ -1697,10 +1823,10 @@ packages:
engines: {node: '>= 0.4'}
dev: true
- /axios@1.4.0:
- resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==}
+ /axios@1.5.0:
+ resolution: {integrity: sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==}
dependencies:
- follow-redirects: 1.15.2
+ follow-redirects: 1.15.3
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
@@ -1721,22 +1847,34 @@ packages:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
+ /big-integer@1.6.51:
+ resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==}
+ engines: {node: '>=0.6'}
+ dev: true
+
/boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
dev: true
- /bootstrap-icons@1.10.5:
- resolution: {integrity: sha512-oSX26F37V7QV7NCE53PPEL45d7EGXmBgHG3pDpZvcRaKVzWMqIRL9wcqJUyEha1esFtM3NJzvmxFXDxjJYD0jQ==}
+ /bootstrap-icons@1.11.1:
+ resolution: {integrity: sha512-F0DDp7nKUX+x/QtpfRZ+XHFya60ng9nfdpdS59vDDfs4Uhuxp7zym/QavMsu/xx51txkoM9eVmpE7D08N35blw==}
dev: false
- /bootstrap@5.3.0(@popperjs/core@2.11.8):
- resolution: {integrity: sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==}
+ /bootstrap@5.3.2(@popperjs/core@2.11.8):
+ resolution: {integrity: sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==}
peerDependencies:
- '@popperjs/core': ^2.11.7
+ '@popperjs/core': ^2.11.8
dependencies:
'@popperjs/core': 2.11.8
dev: false
+ /bplist-parser@0.2.0:
+ resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==}
+ engines: {node: '>= 5.10.0'}
+ dependencies:
+ big-integer: 1.6.51
+ dev: true
+
/brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
@@ -1751,15 +1889,15 @@ packages:
fill-range: 7.0.1
dev: true
- /browserslist@4.21.9:
- resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==}
+ /browserslist@4.21.10:
+ resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
- caniuse-lite: 1.0.30001513
- electron-to-chromium: 1.4.454
+ caniuse-lite: 1.0.30001538
+ electron-to-chromium: 1.4.525
node-releases: 2.0.13
- update-browserslist-db: 1.0.11(browserslist@4.21.9)
+ update-browserslist-db: 1.0.11(browserslist@4.21.10)
dev: true
/buffer@6.0.3:
@@ -1769,6 +1907,13 @@ packages:
ieee754: 1.2.1
dev: true
+ /bundle-name@3.0.0:
+ resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==}
+ engines: {node: '>=12'}
+ dependencies:
+ run-applescript: 5.0.0
+ dev: true
+
/call-bind@1.0.2:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
dependencies:
@@ -1781,8 +1926,8 @@ packages:
engines: {node: '>=6'}
dev: true
- /caniuse-lite@1.0.30001513:
- resolution: {integrity: sha512-pnjGJo7SOOjAGytZZ203Em95MRM8Cr6jhCXNF/FAXTpCTRTECnqQWLpiTRqrFtdYcth8hf4WECUpkezuYsMVww==}
+ /caniuse-lite@1.0.30001538:
+ resolution: {integrity: sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==}
dev: true
/chalk@2.4.2:
@@ -1866,24 +2011,34 @@ packages:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
- /core-js-pure@3.31.1:
- resolution: {integrity: sha512-w+C62kvWti0EPs4KPMCMVv9DriHSXfQOCQ94bGGBiEW5rrbtt/Rz8n5Krhfw9cpFyzXBjf3DB3QnPdEzGDY4Fw==}
+ /core-js-pure@3.32.2:
+ resolution: {integrity: sha512-Y2rxThOuNywTjnX/PgA5vWM6CZ9QB9sz9oGeCixV8MqXZO70z/5SHzf9EeBrEBK0PN36DnEBBu9O/aGWzKuMZQ==}
requiresBuild: true
dev: false
- /core-js@3.31.1:
- resolution: {integrity: sha512-2sKLtfq1eFST7l7v62zaqXacPc7uG8ZAya8ogijLhTtaKNcpzpB4TMoTw2Si+8GYKRwFPMMtUT0263QFWFfqyQ==}
+ /core-js@3.32.2:
+ resolution: {integrity: sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==}
requiresBuild: true
dev: false
- /cosmiconfig@8.2.0:
- resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
+ /cosmiconfig@8.3.6(typescript@5.2.2):
+ resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
engines: {node: '>=14'}
+ peerDependencies:
+ typescript: '>=4.9.5'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
dependencies:
import-fresh: 3.3.0
js-yaml: 4.1.0
parse-json: 5.2.0
path-type: 4.0.0
+ typescript: 5.2.2
+ dev: true
+
+ /create-require@1.1.1:
+ resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
dev: true
/cross-spawn@7.0.3:
@@ -1948,10 +2103,43 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
- /define-properties@1.2.0:
- resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==}
+ /default-browser-id@3.0.0:
+ resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==}
+ engines: {node: '>=12'}
+ dependencies:
+ bplist-parser: 0.2.0
+ untildify: 4.0.0
+ dev: true
+
+ /default-browser@4.0.0:
+ resolution: {integrity: sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ bundle-name: 3.0.0
+ default-browser-id: 3.0.0
+ execa: 7.2.0
+ titleize: 3.0.0
+ dev: true
+
+ /define-data-property@1.1.0:
+ resolution: {integrity: sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ get-intrinsic: 1.2.1
+ gopd: 1.0.1
+ has-property-descriptors: 1.0.0
+ dev: true
+
+ /define-lazy-prop@3.0.0:
+ resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
dependencies:
+ define-data-property: 1.1.0
has-property-descriptors: 1.0.0
object-keys: 1.1.1
dev: true
@@ -1967,6 +2155,11 @@ packages:
hasBin: true
dev: true
+ /diff@4.0.2:
+ resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+ engines: {node: '>=0.3.1'}
+ dev: true
+
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -1991,7 +2184,7 @@ packages:
/dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dependencies:
- '@babel/runtime': 7.22.6
+ '@babel/runtime': 7.22.15
csstype: 3.1.2
dev: false
@@ -2031,8 +2224,8 @@ packages:
engines: {node: '>=6'}
dev: true
- /electron-to-chromium@1.4.454:
- resolution: {integrity: sha512-pmf1rbAStw8UEQ0sr2cdJtWl48ZMuPD9Sto8HVQOq9vx9j2WgDEN6lYoaqFvqEHYOmGA9oRGn7LqWI9ta0YugQ==}
+ /electron-to-chromium@1.4.525:
+ resolution: {integrity: sha512-GIZ620hDK4YmIqAWkscG4W6RwY6gOx1y5J6f4JUQwctiJrqH2oxZYU4mXHi35oV32tr630UcepBzSBGJ/WYcZA==}
dev: true
/entities@2.2.0:
@@ -2050,16 +2243,17 @@ packages:
is-arrayish: 0.2.1
dev: true
- /es-abstract@1.21.2:
- resolution: {integrity: sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==}
+ /es-abstract@1.22.2:
+ resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==}
engines: {node: '>= 0.4'}
dependencies:
array-buffer-byte-length: 1.0.0
+ arraybuffer.prototype.slice: 1.0.2
available-typed-arrays: 1.0.5
call-bind: 1.0.2
es-set-tostringtag: 2.0.1
es-to-primitive: 1.2.1
- function.prototype.name: 1.1.5
+ function.prototype.name: 1.1.6
get-intrinsic: 1.2.1
get-symbol-description: 1.0.0
globalthis: 1.0.3
@@ -2075,19 +2269,42 @@ packages:
is-regex: 1.1.4
is-shared-array-buffer: 1.0.2
is-string: 1.0.7
- is-typed-array: 1.1.10
+ is-typed-array: 1.1.12
is-weakref: 1.0.2
object-inspect: 1.12.3
object-keys: 1.1.1
object.assign: 4.1.4
- regexp.prototype.flags: 1.5.0
+ regexp.prototype.flags: 1.5.1
+ safe-array-concat: 1.0.1
safe-regex-test: 1.0.0
- string.prototype.trim: 1.2.7
- string.prototype.trimend: 1.0.6
- string.prototype.trimstart: 1.0.6
+ string.prototype.trim: 1.2.8
+ string.prototype.trimend: 1.0.7
+ string.prototype.trimstart: 1.0.7
+ typed-array-buffer: 1.0.0
+ typed-array-byte-length: 1.0.0
+ typed-array-byte-offset: 1.0.0
typed-array-length: 1.0.4
unbox-primitive: 1.0.2
- which-typed-array: 1.1.9
+ which-typed-array: 1.1.11
+ dev: true
+
+ /es-iterator-helpers@1.0.15:
+ resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==}
+ dependencies:
+ asynciterator.prototype: 1.0.0
+ call-bind: 1.0.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
+ es-set-tostringtag: 2.0.1
+ function-bind: 1.1.1
+ get-intrinsic: 1.2.1
+ globalthis: 1.0.3
+ has-property-descriptors: 1.0.0
+ has-proto: 1.0.1
+ has-symbols: 1.0.3
+ internal-slot: 1.0.5
+ iterator.prototype: 1.1.2
+ safe-array-concat: 1.0.1
dev: true
/es-set-tostringtag@2.0.1:
@@ -2129,96 +2346,93 @@ packages:
engines: {node: '>=10'}
dev: true
- /eslint-config-prettier@8.8.0(eslint@8.44.0):
- resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==}
+ /eslint-config-prettier@9.0.0(eslint@8.49.0):
+ resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
dependencies:
- eslint: 8.44.0
+ eslint: 8.49.0
dev: true
- /eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.8.0)(eslint@8.44.0)(prettier@3.0.0):
- resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==}
- engines: {node: '>=12.0.0'}
+ /eslint-plugin-prettier@5.0.0(eslint-config-prettier@9.0.0)(eslint@8.49.0)(prettier@3.0.3):
+ resolution: {integrity: sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==}
+ engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
- eslint: '>=7.28.0'
+ '@types/eslint': '>=8.0.0'
+ eslint: '>=8.0.0'
eslint-config-prettier: '*'
- prettier: '>=2.0.0'
+ prettier: '>=3.0.0'
peerDependenciesMeta:
+ '@types/eslint':
+ optional: true
eslint-config-prettier:
optional: true
dependencies:
- eslint: 8.44.0
- eslint-config-prettier: 8.8.0(eslint@8.44.0)
- prettier: 3.0.0
+ eslint: 8.49.0
+ eslint-config-prettier: 9.0.0(eslint@8.49.0)
+ prettier: 3.0.3
prettier-linter-helpers: 1.0.0
+ synckit: 0.8.5
dev: true
- /eslint-plugin-react-hooks@4.6.0(eslint@8.44.0):
+ /eslint-plugin-react-hooks@4.6.0(eslint@8.49.0):
resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
dependencies:
- eslint: 8.44.0
+ eslint: 8.49.0
dev: true
- /eslint-plugin-react@7.32.2(eslint@8.44.0):
- resolution: {integrity: sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==}
+ /eslint-plugin-react@7.33.2(eslint@8.49.0):
+ resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==}
engines: {node: '>=4'}
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
dependencies:
- array-includes: 3.1.6
- array.prototype.flatmap: 1.3.1
- array.prototype.tosorted: 1.1.1
+ array-includes: 3.1.7
+ array.prototype.flatmap: 1.3.2
+ array.prototype.tosorted: 1.1.2
doctrine: 2.1.0
- eslint: 8.44.0
+ es-iterator-helpers: 1.0.15
+ eslint: 8.49.0
estraverse: 5.3.0
- jsx-ast-utils: 3.3.4
+ jsx-ast-utils: 3.3.5
minimatch: 3.1.2
- object.entries: 1.1.6
- object.fromentries: 2.0.6
- object.hasown: 1.1.2
- object.values: 1.1.6
+ object.entries: 1.1.7
+ object.fromentries: 2.0.7
+ object.hasown: 1.1.3
+ object.values: 1.1.7
prop-types: 15.8.1
resolve: 2.0.0-next.4
- semver: 6.3.0
- string.prototype.matchall: 4.0.8
- dev: true
-
- /eslint-scope@5.1.1:
- resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
- engines: {node: '>=8.0.0'}
- dependencies:
- esrecurse: 4.3.0
- estraverse: 4.3.0
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.10
dev: true
- /eslint-scope@7.2.0:
- resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==}
+ /eslint-scope@7.2.2:
+ resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
esrecurse: 4.3.0
estraverse: 5.3.0
dev: true
- /eslint-visitor-keys@3.4.1:
- resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==}
+ /eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
- /eslint@8.44.0:
- resolution: {integrity: sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==}
+ /eslint@8.49.0:
+ resolution: {integrity: sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
hasBin: true
dependencies:
- '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0)
- '@eslint-community/regexpp': 4.5.1
- '@eslint/eslintrc': 2.1.0
- '@eslint/js': 8.44.0
- '@humanwhocodes/config-array': 0.11.10
+ '@eslint-community/eslint-utils': 4.4.0(eslint@8.49.0)
+ '@eslint-community/regexpp': 4.8.1
+ '@eslint/eslintrc': 2.1.2
+ '@eslint/js': 8.49.0
+ '@humanwhocodes/config-array': 0.11.11
'@humanwhocodes/module-importer': 1.0.1
'@nodelib/fs.walk': 1.2.8
ajv: 6.12.6
@@ -2227,19 +2441,18 @@ packages:
debug: 4.3.4
doctrine: 3.0.0
escape-string-regexp: 4.0.0
- eslint-scope: 7.2.0
- eslint-visitor-keys: 3.4.1
- espree: 9.6.0
+ eslint-scope: 7.2.2
+ eslint-visitor-keys: 3.4.3
+ espree: 9.6.1
esquery: 1.5.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 6.0.1
find-up: 5.0.0
glob-parent: 6.0.2
- globals: 13.20.0
+ globals: 13.21.0
graphemer: 1.4.0
ignore: 5.2.4
- import-fresh: 3.3.0
imurmurhash: 0.1.4
is-glob: 4.0.3
is-path-inside: 3.0.3
@@ -2251,19 +2464,18 @@ packages:
natural-compare: 1.4.0
optionator: 0.9.3
strip-ansi: 6.0.1
- strip-json-comments: 3.1.1
text-table: 0.2.0
transitivePeerDependencies:
- supports-color
dev: true
- /espree@9.6.0:
- resolution: {integrity: sha512-1FH/IiruXZ84tpUlm0aCUEwMl2Ho5ilqVh0VvQXw+byAz/4SAciyHLlfmL5WYqsvD38oymdUwBss0LtK8m4s/A==}
+ /espree@9.6.1:
+ resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
acorn: 8.10.0
acorn-jsx: 5.3.2(acorn@8.10.0)
- eslint-visitor-keys: 3.4.1
+ eslint-visitor-keys: 3.4.3
dev: true
/esquery@1.5.0:
@@ -2280,11 +2492,6 @@ packages:
estraverse: 5.3.0
dev: true
- /estraverse@4.3.0:
- resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==}
- engines: {node: '>=4.0'}
- dev: true
-
/estraverse@5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
@@ -2305,6 +2512,36 @@ packages:
engines: {node: '>=12.0.0'}
dev: false
+ /execa@5.1.1:
+ resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+ engines: {node: '>=10'}
+ dependencies:
+ cross-spawn: 7.0.3
+ get-stream: 6.0.1
+ human-signals: 2.1.0
+ is-stream: 2.0.1
+ merge-stream: 2.0.0
+ npm-run-path: 4.0.1
+ onetime: 5.1.2
+ signal-exit: 3.0.7
+ strip-final-newline: 2.0.0
+ dev: true
+
+ /execa@7.2.0:
+ resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==}
+ engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0}
+ dependencies:
+ cross-spawn: 7.0.3
+ get-stream: 6.0.1
+ human-signals: 4.3.1
+ is-stream: 3.0.0
+ merge-stream: 2.0.0
+ npm-run-path: 5.1.0
+ onetime: 6.0.0
+ signal-exit: 3.0.7
+ strip-final-newline: 3.0.0
+ dev: true
+
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true
@@ -2313,8 +2550,8 @@ packages:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: true
- /fast-glob@3.3.0:
- resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==}
+ /fast-glob@3.3.1:
+ resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2349,7 +2586,7 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
dependencies:
- flat-cache: 3.0.4
+ flat-cache: 3.1.0
dev: true
/fill-range@7.0.1:
@@ -2367,20 +2604,21 @@ packages:
path-exists: 4.0.0
dev: true
- /flat-cache@3.0.4:
- resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /flat-cache@3.1.0:
+ resolution: {integrity: sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==}
+ engines: {node: '>=12.0.0'}
dependencies:
- flatted: 3.2.7
+ flatted: 3.2.9
+ keyv: 4.5.3
rimraf: 3.0.2
dev: true
- /flatted@3.2.7:
- resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
+ /flatted@3.2.9:
+ resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==}
dev: true
- /follow-redirects@1.15.2:
- resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
+ /follow-redirects@1.15.3:
+ resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -2412,13 +2650,13 @@ packages:
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
dev: true
- /function.prototype.name@1.1.5:
- resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==}
+ /function.prototype.name@1.1.6:
+ resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
functions-have-names: 1.2.3
dev: true
@@ -2440,6 +2678,11 @@ packages:
engines: {node: '>=6'}
dev: true
+ /get-stream@6.0.1:
+ resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+ engines: {node: '>=10'}
+ dev: true
+
/get-symbol-description@1.0.0:
resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==}
engines: {node: '>= 0.4'}
@@ -2473,8 +2716,8 @@ packages:
path-is-absolute: 1.0.1
dev: true
- /globals@13.20.0:
- resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==}
+ /globals@13.21.0:
+ resolution: {integrity: sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==}
engines: {node: '>=8'}
dependencies:
type-fest: 0.20.2
@@ -2484,7 +2727,7 @@ packages:
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
engines: {node: '>= 0.4'}
dependencies:
- define-properties: 1.2.0
+ define-properties: 1.2.1
dev: true
/globby@11.1.0:
@@ -2493,7 +2736,7 @@ packages:
dependencies:
array-union: 2.1.0
dir-glob: 3.0.1
- fast-glob: 3.3.0
+ fast-glob: 3.3.1
ignore: 5.2.4
merge2: 1.4.1
slash: 3.0.0
@@ -2559,7 +2802,7 @@ packages:
void-elements: 3.1.0
dev: false
- /htmlnano@2.0.4(svgo@2.8.0):
+ /htmlnano@2.0.4(svgo@2.8.0)(typescript@5.2.2):
resolution: {integrity: sha512-WGCkyGFwjKW1GeCBsPYacMvaMnZtFJ0zIRnC2NCddkA+IOEhTqskXrS7lep+3yYZw/nQ3dW1UAX4yA/GJyR8BA==}
peerDependencies:
cssnano: ^6.0.0
@@ -2588,10 +2831,12 @@ packages:
uncss:
optional: true
dependencies:
- cosmiconfig: 8.2.0
+ cosmiconfig: 8.3.6(typescript@5.2.2)
posthtml: 0.16.6
svgo: 2.8.0
timsort: 0.3.0
+ transitivePeerDependencies:
+ - typescript
dev: true
/htmlparser2@7.2.0:
@@ -2603,6 +2848,16 @@ packages:
entities: 3.0.1
dev: true
+ /human-signals@2.1.0:
+ resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+ engines: {node: '>=10.17.0'}
+ dev: true
+
+ /human-signals@4.3.1:
+ resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
+ engines: {node: '>=14.18.0'}
+ dev: true
+
/hyphenate-style-name@1.0.4:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
dev: false
@@ -2610,13 +2865,13 @@ packages:
/i18next-browser-languagedetector@7.1.0:
resolution: {integrity: sha512-cr2k7u1XJJ4HTOjM9GyOMtbOA47RtUoWRAtt52z43r3AoMs2StYKyjS3URPhzHaf+mn10hY9dZWamga5WPQjhA==}
dependencies:
- '@babel/runtime': 7.22.6
+ '@babel/runtime': 7.22.15
dev: false
- /i18next@23.2.8:
- resolution: {integrity: sha512-wU0pMlJ91ZbB89i77G3YQ11/pBQrzgWpxJYl7HFyA9aU9v3aHMI/oBKQmAJNURr0A8cLG4EHjgSMK8IqQTp4PQ==}
+ /i18next@23.5.1:
+ resolution: {integrity: sha512-JelYzcaCoFDaa+Ysbfz2JsGAKkrHiMG6S61+HLBUEIPaF40WMwW9hCPymlQGrP+wWawKxKPuSuD71WZscCsWHg==}
dependencies:
- '@babel/runtime': 7.22.6
+ '@babel/runtime': 7.22.15
dev: false
/ieee754@1.2.1:
@@ -2666,7 +2921,7 @@ packages:
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.2.1
- is-typed-array: 1.1.10
+ is-typed-array: 1.1.12
dev: true
/is-arrayish@0.2.1:
@@ -2677,6 +2932,13 @@ packages:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
dev: false
+ /is-async-function@2.0.0:
+ resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-tostringtag: 1.0.0
+ dev: true
+
/is-bigint@1.0.4:
resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==}
dependencies:
@@ -2696,8 +2958,8 @@ packages:
engines: {node: '>= 0.4'}
dev: true
- /is-core-module@2.12.1:
- resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==}
+ /is-core-module@2.13.0:
+ resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==}
dependencies:
has: 1.0.3
dev: true
@@ -2709,11 +2971,36 @@ packages:
has-tostringtag: 1.0.0
dev: true
+ /is-docker@2.2.1:
+ resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+ engines: {node: '>=8'}
+ hasBin: true
+ dev: true
+
+ /is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+ dev: true
+
/is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
dev: true
+ /is-finalizationregistry@1.0.2:
+ resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==}
+ dependencies:
+ call-bind: 1.0.2
+ dev: true
+
+ /is-generator-function@1.0.10:
+ resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-tostringtag: 1.0.0
+ dev: true
+
/is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@@ -2721,10 +3008,22 @@ packages:
is-extglob: 2.1.1
dev: true
+ /is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+ dependencies:
+ is-docker: 3.0.0
+ dev: true
+
/is-json@2.0.1:
resolution: {integrity: sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==}
dev: true
+ /is-map@2.0.2:
+ resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==}
+ dev: true
+
/is-negative-zero@2.0.2:
resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==}
engines: {node: '>= 0.4'}
@@ -2755,12 +3054,26 @@ packages:
has-tostringtag: 1.0.0
dev: true
+ /is-set@2.0.2:
+ resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==}
+ dev: true
+
/is-shared-array-buffer@1.0.2:
resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
dependencies:
call-bind: 1.0.2
dev: true
+ /is-stream@2.0.1:
+ resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /is-stream@3.0.0:
+ resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dev: true
+
/is-string@1.0.7:
resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==}
engines: {node: '>= 0.4'}
@@ -2775,15 +3088,15 @@ packages:
has-symbols: 1.0.3
dev: true
- /is-typed-array@1.1.10:
- resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==}
+ /is-typed-array@1.1.12:
+ resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==}
engines: {node: '>= 0.4'}
dependencies:
- available-typed-arrays: 1.0.5
- call-bind: 1.0.2
- for-each: 0.3.3
- gopd: 1.0.1
- has-tostringtag: 1.0.0
+ which-typed-array: 1.1.11
+ dev: true
+
+ /is-weakmap@2.0.1:
+ resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==}
dev: true
/is-weakref@1.0.2:
@@ -2792,13 +3105,37 @@ packages:
call-bind: 1.0.2
dev: true
+ /is-weakset@2.0.2:
+ resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==}
+ dependencies:
+ call-bind: 1.0.2
+ get-intrinsic: 1.2.1
+ dev: true
+
+ /is-wsl@2.2.0:
+ resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+ engines: {node: '>=8'}
+ dependencies:
+ is-docker: 2.2.1
+ dev: true
+
+ /isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+ dev: true
+
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
- /js-base64@3.7.5:
- resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
- dev: false
+ /iterator.prototype@1.1.2:
+ resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==}
+ dependencies:
+ define-properties: 1.2.1
+ get-intrinsic: 1.2.1
+ has-symbols: 1.0.3
+ reflect.getprototypeof: 1.0.4
+ set-function-name: 2.0.1
+ dev: true
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2810,6 +3147,10 @@ packages:
argparse: 2.0.1
dev: true
+ /json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+ dev: true
+
/json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
dev: true
@@ -2828,14 +3169,20 @@ packages:
hasBin: true
dev: true
- /jsx-ast-utils@3.3.4:
- resolution: {integrity: sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==}
+ /jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
dependencies:
- array-includes: 3.1.6
- array.prototype.flat: 1.3.1
+ array-includes: 3.1.7
+ array.prototype.flat: 1.3.2
object.assign: 4.1.4
- object.values: 1.1.6
+ object.values: 1.1.7
+ dev: true
+
+ /keyv@4.5.3:
+ resolution: {integrity: sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==}
+ dependencies:
+ json-buffer: 3.0.1
dev: true
/levn@0.4.1:
@@ -2846,8 +3193,8 @@ packages:
type-check: 0.4.0
dev: true
- /lightningcss-darwin-arm64@1.21.5:
- resolution: {integrity: sha512-z05hyLX85WY0UfhkFUOrWEFqD69lpVAmgl3aDzMKlIZJGygbhbegqb4PV8qfUrKKNBauut/qVNPKZglhTaDDxA==}
+ /lightningcss-darwin-arm64@1.22.0:
+ resolution: {integrity: sha512-aH2be3nNny+It5YEVm8tBSSdRlBVWQV8m2oJ7dESiYRzyY/E/bQUe2xlw5caaMuhlM9aoTMtOH25yzMhir0qPg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
@@ -2855,8 +3202,8 @@ packages:
dev: true
optional: true
- /lightningcss-darwin-x64@1.21.5:
- resolution: {integrity: sha512-MSJhmej/U9MrdPxDk7+FWhO8+UqVoZUHG4VvKT5RQ4RJtqtANTiWiI97LvoVNMtdMnHaKs1Pkji6wHUFxjJsHQ==}
+ /lightningcss-darwin-x64@1.22.0:
+ resolution: {integrity: sha512-9KHRFA0Y6mNxRHeoQMp0YaI0R0O2kOgUlYPRjuasU4d+pI8NRhVn9bt0yX9VPs5ibWX1RbDViSPtGJvYYrfVAQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
@@ -2864,8 +3211,17 @@ packages:
dev: true
optional: true
- /lightningcss-linux-arm-gnueabihf@1.21.5:
- resolution: {integrity: sha512-xN6+5/JsMrbZHL1lPl+MiNJ3Xza12ueBKPepiyDCFQzlhFRTj7D0LG+cfNTzPBTO8KcYQynLpl1iBB8LGp3Xtw==}
+ /lightningcss-freebsd-x64@1.22.0:
+ resolution: {integrity: sha512-xaYL3xperGwD85rQioDb52ozF3NAJb+9wrge3jD9lxGffplu0Mn35rXMptB8Uc2N9Mw1i3Bvl7+z1evlqVl7ww==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /lightningcss-linux-arm-gnueabihf@1.22.0:
+ resolution: {integrity: sha512-epQGvXIjOuxrZpMpMnRjK54ZqzhiHhCPLtHvw2fb6NeK2kK9YtF0wqmeTBiQ1AkbWfnnXGTstYaFNiadNK+StQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
@@ -2873,8 +3229,8 @@ packages:
dev: true
optional: true
- /lightningcss-linux-arm64-gnu@1.21.5:
- resolution: {integrity: sha512-KfzFNhC4XTbmG3ma/xcTs/IhCwieW89XALIusKmnV0N618ZDXEB0XjWOYQRCXeK9mfqPdbTBpurEHV/XZtkniQ==}
+ /lightningcss-linux-arm64-gnu@1.22.0:
+ resolution: {integrity: sha512-AArGtKSY4DGTA8xP8SDyNyKtpsUl1Rzq6FW4JomeyUQ4nBrR71uPChksTpj3gmWuGhZeRKLeCUI1DBid/zhChg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
@@ -2882,8 +3238,8 @@ packages:
dev: true
optional: true
- /lightningcss-linux-arm64-musl@1.21.5:
- resolution: {integrity: sha512-bc0GytQO5Mn9QM6szaZ+31fQHNdidgpM1sSCwzPItz8hg3wOvKl8039rU0veMJV3ZgC9z0ypNRceLrSHeRHmXw==}
+ /lightningcss-linux-arm64-musl@1.22.0:
+ resolution: {integrity: sha512-RRraNgP8hnBPhInTTUdlFm+z16C/ghbxBG51Sw00hd7HUyKmEUKRozyc5od+/N6pOrX/bIh5vIbtMXIxsos0lg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
@@ -2891,8 +3247,8 @@ packages:
dev: true
optional: true
- /lightningcss-linux-x64-gnu@1.21.5:
- resolution: {integrity: sha512-JwMbgypPQgc2kW2av3OwzZ8cbrEuIiDiXPJdXRE6aVxu67yHauJawQLqJKTGUhiAhy6iLDG8Wg0a3/ziL+m+Kw==}
+ /lightningcss-linux-x64-gnu@1.22.0:
+ resolution: {integrity: sha512-grdrhYGRi2KrR+bsXJVI0myRADqyA7ekprGxiuK5QRNkv7kj3Yq1fERDNyzZvjisHwKUi29sYMClscbtl+/Zpw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
@@ -2900,8 +3256,8 @@ packages:
dev: true
optional: true
- /lightningcss-linux-x64-musl@1.21.5:
- resolution: {integrity: sha512-Ib8b6IQ/OR/VrPU6YBgy4T3QnuHY7DUa95O+nz+cwrTkMSN6fuHcTcIaz4t8TJ6HI5pl3uxUOZjmtls2pyQWow==}
+ /lightningcss-linux-x64-musl@1.22.0:
+ resolution: {integrity: sha512-t5f90X+iQUtIyR56oXIHMBUyQFX/zwmPt72E6Dane3P8KNGlkijTg2I75XVQS860gNoEFzV7Mm5ArRRA7u5CAQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
@@ -2909,8 +3265,8 @@ packages:
dev: true
optional: true
- /lightningcss-win32-x64-msvc@1.21.5:
- resolution: {integrity: sha512-A8cSi8lUpBeVmoF+DqqW7cd0FemDbCuKr490IXdjyeI+KL8adpSKUs8tcqO0OXPh1EoDqK7JNkD/dELmd4Iz5g==}
+ /lightningcss-win32-x64-msvc@1.22.0:
+ resolution: {integrity: sha512-64HTDtOOZE9PUCZJiZZQpyqXBbdby1lnztBccnqh+NtbKxjnGzP92R2ngcgeuqMPecMNqNWxgoWgTGpC+yN5Sw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
@@ -2918,20 +3274,21 @@ packages:
dev: true
optional: true
- /lightningcss@1.21.5:
- resolution: {integrity: sha512-/pEUPeih2EwIx9n4T82aOG6CInN83tl/mWlw6B5gWLf36UplQi1L+5p3FUHsdt4fXVfOkkh9KIaM3owoq7ss8A==}
+ /lightningcss@1.22.0:
+ resolution: {integrity: sha512-+z0qvwRVzs4XGRXelnWRNwqsXUx8k3bSkbP8vD42kYKSk3z9OM2P3e/gagT7ei/gwh8DTS80LZOFZV6lm8Z8Fg==}
engines: {node: '>= 12.0.0'}
dependencies:
detect-libc: 1.0.3
optionalDependencies:
- lightningcss-darwin-arm64: 1.21.5
- lightningcss-darwin-x64: 1.21.5
- lightningcss-linux-arm-gnueabihf: 1.21.5
- lightningcss-linux-arm64-gnu: 1.21.5
- lightningcss-linux-arm64-musl: 1.21.5
- lightningcss-linux-x64-gnu: 1.21.5
- lightningcss-linux-x64-musl: 1.21.5
- lightningcss-win32-x64-msvc: 1.21.5
+ lightningcss-darwin-arm64: 1.22.0
+ lightningcss-darwin-x64: 1.22.0
+ lightningcss-freebsd-x64: 1.22.0
+ lightningcss-linux-arm-gnueabihf: 1.22.0
+ lightningcss-linux-arm64-gnu: 1.22.0
+ lightningcss-linux-arm64-musl: 1.22.0
+ lightningcss-linux-x64-gnu: 1.22.0
+ lightningcss-linux-x64-musl: 1.22.0
+ lightningcss-win32-x64-msvc: 1.22.0
dev: true
/lines-and-columns@1.2.4:
@@ -2964,10 +3321,6 @@ packages:
p-locate: 5.0.0
dev: true
- /lodash-es@4.17.21:
- resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
- dev: false
-
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -2989,9 +3342,13 @@ packages:
yallist: 4.0.0
dev: true
- /marked@5.1.1:
- resolution: {integrity: sha512-bTmmGdEINWmOMDjnPWDxGPQ4qkDLeYorpYbEtFOXzOruTwUE671q4Guiuchn4N8h/v6NGd7916kXsm3Iz4iUSg==}
- engines: {node: '>= 18'}
+ /make-error@1.3.6:
+ resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+ dev: true
+
+ /marked@9.0.3:
+ resolution: {integrity: sha512-pI/k4nzBG1PEq1J3XFEHxVvjicfjl8rgaMaqclouGSMPhk7Q3Ejb2ZRxx/ZQOcQ1909HzVoWCFYq6oLgtL4BpQ==}
+ engines: {node: '>= 16'}
hasBin: true
dev: false
@@ -3001,14 +3358,14 @@ packages:
css-mediaquery: 0.1.2
dev: false
- /material-colors@1.2.6:
- resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
- dev: false
-
/mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
dev: true
+ /merge-stream@2.0.0:
+ resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+ dev: true
+
/merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -3034,6 +3391,16 @@ packages:
mime-db: 1.52.0
dev: false
+ /mimic-fn@2.1.0:
+ resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /mimic-fn@4.0.0:
+ resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
+ engines: {node: '>=12'}
+ dev: true
+
/minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
@@ -3070,16 +3437,12 @@ packages:
msgpackr-extract: 3.0.2
dev: true
- /msgpackr@1.9.5:
- resolution: {integrity: sha512-/IJ3cFSN6Ci3eG2wLhbFEL6GT63yEaoN/R5My2QkV6zro+OJaVRLPlwvxY7EtHYSmDlQpk8stvOQTL2qJFkDRg==}
+ /msgpackr@1.9.9:
+ resolution: {integrity: sha512-sbn6mioS2w0lq1O6PpGtsv6Gy8roWM+o3o4Sqjd6DudrL/nOugY+KyJUimoWzHnf9OkO0T6broHFnYE/R05t9A==}
optionalDependencies:
msgpackr-extract: 3.0.2
dev: true
- /natural-compare-lite@1.4.0:
- resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
- dev: true
-
/natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
@@ -3092,8 +3455,8 @@ packages:
resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==}
dev: true
- /node-fetch@2.6.12:
- resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
+ /node-fetch@2.7.0:
+ resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
@@ -3112,6 +3475,7 @@ packages:
/node-gyp-build-optional-packages@5.0.7:
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==}
hasBin: true
+ requiresBuild: true
dev: true
optional: true
@@ -3119,6 +3483,20 @@ packages:
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}
dev: true
+ /npm-run-path@4.0.1:
+ resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+ engines: {node: '>=8'}
+ dependencies:
+ path-key: 3.1.1
+ dev: true
+
+ /npm-run-path@5.1.0:
+ resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dependencies:
+ path-key: 4.0.0
+ dev: true
+
/nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
dependencies:
@@ -3147,43 +3525,43 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
+ define-properties: 1.2.1
has-symbols: 1.0.3
object-keys: 1.1.1
dev: true
- /object.entries@1.1.6:
- resolution: {integrity: sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==}
+ /object.entries@1.1.7:
+ resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
- /object.fromentries@2.0.6:
- resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==}
+ /object.fromentries@2.0.7:
+ resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
- /object.hasown@1.1.2:
- resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==}
+ /object.hasown@1.1.3:
+ resolution: {integrity: sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==}
dependencies:
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
- /object.values@1.1.6:
- resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==}
+ /object.values@1.1.7:
+ resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
/once@1.4.0:
@@ -3192,6 +3570,30 @@ packages:
wrappy: 1.0.2
dev: true
+ /onetime@5.1.2:
+ resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+ engines: {node: '>=6'}
+ dependencies:
+ mimic-fn: 2.1.0
+ dev: true
+
+ /onetime@6.0.0:
+ resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ mimic-fn: 4.0.0
+ dev: true
+
+ /open@9.1.0:
+ resolution: {integrity: sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ default-browser: 4.0.0
+ define-lazy-prop: 3.0.0
+ is-inside-container: 1.0.0
+ is-wsl: 2.2.0
+ dev: true
+
/optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
@@ -3222,7 +3624,7 @@ packages:
p-limit: 3.1.0
dev: true
- /parcel@2.9.3:
+ /parcel@2.9.3(typescript@5.2.2):
resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==}
engines: {node: '>= 12.0.0'}
hasBin: true
@@ -3230,7 +3632,7 @@ packages:
'@parcel/core':
optional: true
dependencies:
- '@parcel/config-default': 2.9.3(@parcel/core@2.9.3)
+ '@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(typescript@5.2.2)
'@parcel/core': 2.9.3
'@parcel/diagnostic': 2.9.3
'@parcel/events': 2.9.3
@@ -3252,6 +3654,7 @@ packages:
- relateurl
- srcset
- terser
+ - typescript
- uncss
dev: true
@@ -3266,7 +3669,7 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
dependencies:
- '@babel/code-frame': 7.22.5
+ '@babel/code-frame': 7.22.13
error-ex: 1.3.2
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -3287,6 +3690,11 @@ packages:
engines: {node: '>=8'}
dev: true
+ /path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
+ dev: true
+
/path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
dev: true
@@ -3350,8 +3758,8 @@ packages:
fast-diff: 1.3.0
dev: true
- /prettier@3.0.0:
- resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==}
+ /prettier@3.0.3:
+ resolution: {integrity: sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==}
engines: {node: '>=14'}
hasBin: true
dev: true
@@ -3388,21 +3796,6 @@ packages:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
- /react-color@2.19.3(react@18.2.0):
- resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
- peerDependencies:
- react: '*'
- dependencies:
- '@icons/material': 0.2.4(react@18.2.0)
- lodash: 4.17.21
- lodash-es: 4.17.21
- material-colors: 1.2.6
- prop-types: 15.8.1
- react: 18.2.0
- reactcss: 1.2.3(react@18.2.0)
- tinycolor2: 1.6.0
- dev: false
-
/react-dom@18.2.0(react@18.2.0):
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
@@ -3421,8 +3814,8 @@ packages:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false
- /react-i18next@13.0.1(i18next@23.2.8)(react-dom@18.2.0)(react@18.2.0):
- resolution: {integrity: sha512-gMO6N2GfSfuH7xlHSsZ/mZf+Py9bLm/+EDKIn5fNTuDTjcCcwmMU5UEuGCDk5mdfivbo7ySyYXBN7B9tbGUxiA==}
+ /react-i18next@13.2.2(i18next@23.5.1)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-+nFUkbRByFwnrfDcYqvzBuaeZb+nACHx+fAWN/pZMddWOCJH5hoc21+Sa/N/Lqi6ne6/9wC/qRGOoQhJa6IkEQ==}
peerDependencies:
i18next: '>= 23.2.3'
react: '>= 16.8.0'
@@ -3434,9 +3827,9 @@ packages:
react-native:
optional: true
dependencies:
- '@babel/runtime': 7.22.6
+ '@babel/runtime': 7.22.15
html-parse-stringify: 3.0.1
- i18next: 23.2.8
+ i18next: 23.5.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
@@ -3476,26 +3869,26 @@ packages:
shallow-equal: 1.2.1
dev: false
- /react-router-dom@6.14.1(react-dom@18.2.0)(react@18.2.0):
- resolution: {integrity: sha512-ssF6M5UkQjHK70fgukCJyjlda0Dgono2QGwqGvuk7D+EDGHdacEN3Yke2LTMjkrpHuFwBfDFsEjGVXBDmL+bWw==}
- engines: {node: '>=14'}
+ /react-router-dom@6.16.0(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==}
+ engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
dependencies:
- '@remix-run/router': 1.7.1
+ '@remix-run/router': 1.9.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
- react-router: 6.14.1(react@18.2.0)
+ react-router: 6.16.0(react@18.2.0)
dev: false
- /react-router@6.14.1(react@18.2.0):
- resolution: {integrity: sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g==}
- engines: {node: '>=14'}
+ /react-router@6.16.0(react@18.2.0):
+ resolution: {integrity: sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==}
+ engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
dependencies:
- '@remix-run/router': 1.7.1
+ '@remix-run/router': 1.9.0
react: 18.2.0
dev: false
@@ -3505,7 +3898,7 @@ packages:
react: '>=16.6.0'
react-dom: '>=16.6.0'
dependencies:
- '@babel/runtime': 7.22.6
+ '@babel/runtime': 7.22.15
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -3520,25 +3913,33 @@ packages:
loose-envify: 1.4.0
dev: false
- /reactcss@1.2.3(react@18.2.0):
- resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
- peerDependencies:
- react: '*'
+ /reflect.getprototypeof@1.0.4:
+ resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==}
+ engines: {node: '>= 0.4'}
dependencies:
- lodash: 4.17.21
- react: 18.2.0
- dev: false
+ call-bind: 1.0.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
+ get-intrinsic: 1.2.1
+ globalthis: 1.0.3
+ which-builtin-type: 1.1.3
+ dev: true
/regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
+ dev: true
+
+ /regenerator-runtime@0.14.0:
+ resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==}
+ dev: false
- /regexp.prototype.flags@1.5.0:
- resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==}
+ /regexp.prototype.flags@1.5.1:
+ resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- functions-have-names: 1.2.3
+ define-properties: 1.2.1
+ set-function-name: 2.0.1
dev: true
/requires-port@1.0.0:
@@ -3554,7 +3955,7 @@ packages:
resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==}
hasBin: true
dependencies:
- is-core-module: 2.12.1
+ is-core-module: 2.13.0
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
dev: true
@@ -3571,6 +3972,13 @@ packages:
glob: 7.2.3
dev: true
+ /run-applescript@5.0.0:
+ resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==}
+ engines: {node: '>=12'}
+ dependencies:
+ execa: 5.1.1
+ dev: true
+
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
@@ -3580,9 +3988,19 @@ packages:
/rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
dependencies:
- tslib: 2.6.0
+ tslib: 2.6.2
dev: false
+ /safe-array-concat@1.0.1:
+ resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==}
+ engines: {node: '>=0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ get-intrinsic: 1.2.1
+ has-symbols: 1.0.3
+ isarray: 2.0.5
+ dev: true
+
/safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: true
@@ -3601,8 +4019,8 @@ packages:
loose-envify: 1.4.0
dev: false
- /semver@6.3.0:
- resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
+ /semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
dev: true
@@ -3618,6 +4036,15 @@ packages:
resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==}
dev: false
+ /set-function-name@2.0.1:
+ resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ define-data-property: 1.1.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.0
+ dev: true
+
/shallow-equal@1.2.1:
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
dev: false
@@ -3642,6 +4069,10 @@ packages:
object-inspect: 1.12.3
dev: true
+ /signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+ dev: true
+
/simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
dependencies:
@@ -3668,42 +4099,43 @@ packages:
deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
dev: true
- /string.prototype.matchall@4.0.8:
- resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==}
+ /string.prototype.matchall@4.0.10:
+ resolution: {integrity: sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
get-intrinsic: 1.2.1
has-symbols: 1.0.3
internal-slot: 1.0.5
- regexp.prototype.flags: 1.5.0
+ regexp.prototype.flags: 1.5.1
+ set-function-name: 2.0.1
side-channel: 1.0.4
dev: true
- /string.prototype.trim@1.2.7:
- resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==}
+ /string.prototype.trim@1.2.8:
+ resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
- /string.prototype.trimend@1.0.6:
- resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==}
+ /string.prototype.trimend@1.0.7:
+ resolution: {integrity: sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
- /string.prototype.trimstart@1.0.6:
- resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==}
+ /string.prototype.trimstart@1.0.7:
+ resolution: {integrity: sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==}
dependencies:
call-bind: 1.0.2
- define-properties: 1.2.0
- es-abstract: 1.21.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.2
dev: true
/strip-ansi@6.0.1:
@@ -3713,6 +4145,16 @@ packages:
ansi-regex: 5.0.1
dev: true
+ /strip-final-newline@2.0.0:
+ resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /strip-final-newline@3.0.0:
+ resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
+ engines: {node: '>=12'}
+ dev: true
+
/strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -3751,6 +4193,14 @@ packages:
stable: 0.1.8
dev: true
+ /synckit@0.8.5:
+ resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ dependencies:
+ '@pkgr/utils': 2.4.2
+ tslib: 2.6.2
+ dev: true
+
/term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
@@ -3764,9 +4214,10 @@ packages:
resolution: {integrity: sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==}
dev: true
- /tinycolor2@1.6.0:
- resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
- dev: false
+ /titleize@3.0.0:
+ resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}
+ engines: {node: '>=12'}
+ dev: true
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
@@ -3789,23 +4240,49 @@ packages:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: false
- /tslib@1.14.1:
- resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+ /ts-api-utils@1.0.3(typescript@5.2.2):
+ resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==}
+ engines: {node: '>=16.13.0'}
+ peerDependencies:
+ typescript: '>=4.2.0'
+ dependencies:
+ typescript: 5.2.2
dev: true
- /tslib@2.6.0:
- resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==}
-
- /tsutils@3.21.0(typescript@5.1.6):
- resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
- engines: {node: '>= 6'}
+ /ts-node@10.9.1(@types/node@20.6.3)(typescript@5.2.2):
+ resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
+ hasBin: true
peerDependencies:
- typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
+ '@swc/core': '>=1.2.50'
+ '@swc/wasm': '>=1.2.50'
+ '@types/node': '*'
+ typescript: '>=2.7'
+ peerDependenciesMeta:
+ '@swc/core':
+ optional: true
+ '@swc/wasm':
+ optional: true
dependencies:
- tslib: 1.14.1
- typescript: 5.1.6
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.9
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 20.6.3
+ acorn: 8.10.0
+ acorn-walk: 8.2.0
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.2.2
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
dev: true
+ /tslib@2.6.2:
+ resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
+
/type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3818,16 +4295,55 @@ packages:
engines: {node: '>=10'}
dev: true
+ /typed-array-buffer@1.0.0:
+ resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ get-intrinsic: 1.2.1
+ is-typed-array: 1.1.12
+ dev: true
+
+ /typed-array-byte-length@1.0.0:
+ resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ for-each: 0.3.3
+ has-proto: 1.0.1
+ is-typed-array: 1.1.12
+ dev: true
+
+ /typed-array-byte-offset@1.0.0:
+ resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ available-typed-arrays: 1.0.5
+ call-bind: 1.0.2
+ for-each: 0.3.3
+ has-proto: 1.0.1
+ is-typed-array: 1.1.12
+ dev: true
+
/typed-array-length@1.0.4:
resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==}
dependencies:
call-bind: 1.0.2
for-each: 0.3.3
- is-typed-array: 1.1.10
+ is-typed-array: 1.1.12
+ dev: true
+
+ /typescript-language-server@3.3.2:
+ resolution: {integrity: sha512-jzun53CIkTbpAki0nP+hk5baGW+86SNNlVhyIj2ZUy45zUkCnmoetWuAtfRRQYrlIr8x4QB3ymGJPuwDQSd/ew==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+ dependencies:
+ vscode-jsonrpc: 5.0.1
+ vscode-languageserver-protocol: 3.17.4
dev: true
- /typescript@5.1.6:
- resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
+ /typescript@5.2.2:
+ resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
@@ -3846,13 +4362,18 @@ packages:
engines: {node: '>= 4.0.0'}
dev: false
- /update-browserslist-db@1.0.11(browserslist@4.21.9):
+ /untildify@4.0.0:
+ resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /update-browserslist-db@1.0.11(browserslist@4.21.10):
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
dependencies:
- browserslist: 4.21.9
+ browserslist: 4.21.10
escalade: 3.1.1
picocolors: 1.0.0
dev: true
@@ -3875,11 +4396,36 @@ packages:
engines: {node: '>= 4'}
dev: true
+ /v8-compile-cache-lib@3.0.1:
+ resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
+ dev: true
+
/void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
dev: false
+ /vscode-jsonrpc@5.0.1:
+ resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==}
+ engines: {node: '>=8.0.0 || >=10.0.0'}
+ dev: true
+
+ /vscode-jsonrpc@8.2.0:
+ resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
+ engines: {node: '>=14.0.0'}
+ dev: true
+
+ /vscode-languageserver-protocol@3.17.4:
+ resolution: {integrity: sha512-IpaHLPft+UBWf4dOIH15YEgydTbXGz52EMU2h16SfFpYu/yOQt3pY14049mtpJu+4CBHn+hq7S67e7O0AwpRqQ==}
+ dependencies:
+ vscode-jsonrpc: 8.2.0
+ vscode-languageserver-types: 3.17.4
+ dev: true
+
+ /vscode-languageserver-types@3.17.4:
+ resolution: {integrity: sha512-9YXi5pA3XF2V+NUQg6g+lulNS0ncRCKASYdK3Cs7kiH9sVFXWq27prjkC/B8M/xJLRPPRSPCHVMuBTgRNFh2sQ==}
+ dev: true
+
/warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
dependencies:
@@ -3911,8 +4457,35 @@ packages:
is-symbol: 1.0.4
dev: true
- /which-typed-array@1.1.9:
- resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==}
+ /which-builtin-type@1.1.3:
+ resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ function.prototype.name: 1.1.6
+ has-tostringtag: 1.0.0
+ is-async-function: 2.0.0
+ is-date-object: 1.0.5
+ is-finalizationregistry: 1.0.2
+ is-generator-function: 1.0.10
+ is-regex: 1.1.4
+ is-weakref: 1.0.2
+ isarray: 2.0.5
+ which-boxed-primitive: 1.0.2
+ which-collection: 1.0.1
+ which-typed-array: 1.1.11
+ dev: true
+
+ /which-collection@1.0.1:
+ resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==}
+ dependencies:
+ is-map: 2.0.2
+ is-set: 2.0.2
+ is-weakmap: 2.0.1
+ is-weakset: 2.0.2
+ dev: true
+
+ /which-typed-array@1.1.11:
+ resolution: {integrity: sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==}
engines: {node: '>= 0.4'}
dependencies:
available-typed-arrays: 1.0.5
@@ -3920,7 +4493,6 @@ packages:
for-each: 0.3.3
gopd: 1.0.1
has-tostringtag: 1.0.0
- is-typed-array: 1.1.10
dev: true
/which@2.0.2:
@@ -3951,7 +4523,7 @@ packages:
/xregexp@5.1.1:
resolution: {integrity: sha512-fKXeVorD+CzWvFs7VBuKTYIW63YD1e1osxwQ8caZ6o1jg6pDAbABDG54LCIq0j5cy7PjRvGIq6sef9DYPXpncg==}
dependencies:
- '@babel/runtime-corejs3': 7.22.6
+ '@babel/runtime-corejs3': 7.22.15
dev: false
/xxhash-wasm@0.4.2:
@@ -3962,6 +4534,11 @@ packages:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
dev: true
+ /yn@3.1.1:
+ resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+ engines: {node: '>=6'}
+ dev: true
+
/yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index cfdab229..58463d08 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -1,51 +1,35 @@
-import * as React from "react";
+import { Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
-import AppBar from "./views/common/AppBar";
-import LoadingPage from "./views/common/LoadingPage";
-import Center from "./views/center";
-import Home from "./views/home";
-import Login from "./views/login";
-import Register from "./views/register";
-import Settings from "./views/settings";
-import About from "./views/about";
-import TimelinePage from "./views/timeline";
-import Search from "./views/search";
-import Admin from "./views/admin";
-import AlertHost from "./views/common/alert/AlertHost";
-
-import { useUser } from "./services/user";
-
-const NoMatch: React.FC = () => {
- return <div>Ah-oh, 404!</div>;
-};
-
-function App(): JSX.Element {
- const user = useUser();
+import AppBar from "./components/AppBar";
+import NotFoundPage from "./pages/404";
+import HomePage from "./pages/home";
+import AboutPage from "./pages/about";
+import SettingPage from "./pages/setting";
+import LoginPage from "./pages/login";
+import RegisterPage from "./pages/register";
+import TimelinePage from "./pages/timeline";
+import LoadingPage from "./pages/loading";
+import { AlertHost } from "./components/alert";
+export default function App() {
return (
- <React.Suspense fallback={<LoadingPage />}>
+ <Suspense fallback={<LoadingPage />}>
<BrowserRouter>
<AppBar />
<div style={{ height: 56 }} />
<Routes>
- <Route index element={user == null ? <Home /> : <Center />} />
- <Route path="home" element={<Home />} />
- <Route path="center" element={<Center />} />
- <Route path="login" element={<Login />} />
- <Route path="register" element={<Register />} />
- <Route path="settings" element={<Settings />} />
- <Route path="about" element={<About />} />
- <Route path="search" element={<Search />} />
- <Route path="admin/*" element={<Admin />} />
+ <Route path="login" element={<LoginPage />} />
+ <Route path="register" element={<RegisterPage />} />
+ <Route path="settings" element={<SettingPage />} />
+ <Route path="about" element={<AboutPage />} />
<Route path=":owner" element={<TimelinePage />} />
<Route path=":owner/:timeline" element={<TimelinePage />} />
- <Route element={<NoMatch />} />
+ <Route path="" element={<HomePage />} />
+ <Route path="*" element={<NotFoundPage />} />
</Routes>
<AlertHost />
</BrowserRouter>
- </React.Suspense>
+ </Suspense>
);
}
-
-export default App;
diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts
index 965f9933..1ca796c3 100644
--- a/FrontEnd/src/common.ts
+++ b/FrontEnd/src/common.ts
@@ -3,8 +3,7 @@
// This error should never occur. If it does, it indicates there is some logic bug in codes.
export class UiLogicError extends Error {}
-export const highlightTimelineUsername = "crupest";
-
export type { I18nText } from "./i18n";
+export type { I18nText as Text } from "./i18n";
export { c, convertI18nText } from "./i18n";
export { default as useC } from "./utilities/hooks/use-c";
diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css
new file mode 100644
index 00000000..38497478
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.css
@@ -0,0 +1,96 @@
+.app-bar {
+ height: 56px;
+ position: fixed;
+ z-index: 1030;
+ top: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--cru-primary-color);
+}
+
+.app-bar {
+ display: flex;
+}
+
+.app-bar > * {
+ background-color: var(--cru-primary-color);
+}
+
+.app-bar .app-bar-brand {
+ display: flex;
+ align-items: center;
+}
+
+.app-bar .app-bar-brand-icon {
+ height: 2em;
+}
+
+.app-bar .app-bar-space {
+ flex-grow: 1;
+}
+
+.app-bar .app-bar-user-area {
+ display: flex;
+}
+
+.app-bar a {
+ background-color: var(--cru-primary-color);
+ color: var(--cru-push-button-text-color);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ padding: 0 1em;
+ transition: all 0.5s;
+}
+
+.app-bar a:hover {
+ background-color: var(--cru-clickable-primary-hover-color);
+}
+
+.app-bar a:focus {
+ background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar a:active {
+ background-color: var(--cru-clickable-primary-active-color);
+}
+
+/* the current page */
+.app-bar a.active {
+ background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar .app-bar-avatar img {
+ width: 45px;
+ height: 45px;
+ background-color: white;
+ border-radius: 50%;
+}
+
+.app-bar.desktop .app-bar-link-area {
+ display: flex;
+}
+
+.app-bar.mobile .app-bar-link-area {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 100%;
+ translate: 0 -100%;
+ transition: transform 0.5s;
+}
+
+.app-bar.mobile a {
+ height: 56px;
+}
+
+.app-bar.mobile.expand .app-bar-link-area {
+ transform: translateY(100%);
+}
+
+.app-bar .toggler {
+ font-size: 2em;
+ padding-right: 0.5em;
+}
+
diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx
new file mode 100644
index 00000000..d40c8105
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.tsx
@@ -0,0 +1,106 @@
+import { useState } from "react";
+import classnames from "classnames";
+import { Link, NavLink } from "react-router-dom";
+
+import { useUser } from "~src/services/user";
+
+import { I18nText, useC } from "./common";
+import { useMobile } from "./hooks";
+import TimelineLogo from "./TimelineLogo";
+import { IconButton } from "./button";
+import UserAvatar from "./user/UserAvatar";
+
+import "./AppBar.css";
+
+function AppBarNavLink({
+ link,
+ className,
+ label,
+ onClick,
+ children,
+}: {
+ link: string;
+ className?: string;
+ label?: I18nText;
+ onClick?: () => void;
+ children?: React.ReactNode;
+}) {
+ if (label != null && children != null) {
+ throw new Error("AppBarNavLink: label and children cannot be both set");
+ }
+
+ const c = useC();
+
+ return (
+ <NavLink
+ to={link}
+ className={({ isActive }) => classnames(className, isActive && "active")}
+ onClick={onClick}
+ >
+ {children != null ? children : c(label)}
+ </NavLink>
+ );
+}
+
+export default function AppBar() {
+ const isMobile = useMobile();
+
+ const [isCollapse, setIsCollapse] = useState<boolean>(true);
+ const collapse = isMobile ? () => setIsCollapse(true) : undefined;
+ const toggleCollapse = () => setIsCollapse(!isCollapse);
+
+ const user = useUser();
+ const hasAdministrationPermission = user && user.hasAdministrationPermission;
+
+ return (
+ <nav
+ className={classnames(
+ isMobile ? "mobile" : "desktop",
+ "app-bar",
+ isCollapse || "expand",
+ )}
+ >
+ <Link to="/" className="app-bar-brand" onClick={collapse}>
+ <TimelineLogo className="app-bar-brand-icon" />
+ Timeline
+ </Link>
+
+ <div className="app-bar-link-area">
+ <AppBarNavLink
+ link="/settings"
+ label="nav.settings"
+ onClick={collapse}
+ />
+ <AppBarNavLink link="/about" label="nav.about" onClick={collapse} />
+ {hasAdministrationPermission && (
+ <AppBarNavLink
+ link="/admin"
+ label="nav.administration"
+ onClick={collapse}
+ />
+ )}
+ </div>
+
+ <div className="app-bar-space" />
+
+ <div className="app-bar-user-area">
+ {user != null ? (
+ <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}>
+ <UserAvatar username={user.username} />
+ </AppBarNavLink>
+ ) : (
+ <AppBarNavLink link="/login" label="nav.login" onClick={collapse} />
+ )}
+ </div>
+
+ {isMobile && (
+ <IconButton
+ icon="list"
+ color="light"
+ className="toggler"
+ onClick={toggleCollapse}
+ />
+ )}
+ </nav>
+ );
+}
diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
new file mode 100644
index 00000000..047a13b4
--- /dev/null
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -0,0 +1,41 @@
+import {
+ useState,
+ useEffect,
+ useMemo,
+ Ref,
+ ComponentPropsWithoutRef,
+} from "react";
+
+type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
+ imgRef?: Ref<HTMLImageElement>;
+ src?: Blob | string | null;
+ keyBySrc?: boolean;
+};
+
+export default function BlobImage(props: BlobImageProps) {
+ const { imgRef, src, keyBySrc, ...otherProps } = props;
+
+ const [url, setUrl] = useState<string | null | undefined>(undefined);
+
+ useEffect(() => {
+ if (src instanceof Blob) {
+ const url = URL.createObjectURL(src);
+ setUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setUrl(src);
+ }
+ }, [src]);
+
+ const key = useMemo(() => {
+ if (keyBySrc) {
+ return url == null ? undefined : btoa(url);
+ } else {
+ return undefined;
+ }
+ }, [url, keyBySrc]);
+
+ return <img key={key} ref={imgRef} {...otherProps} src={url ?? undefined} />;
+}
diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css
new file mode 100644
index 00000000..6d655eb9
--- /dev/null
+++ b/FrontEnd/src/components/Card.css
@@ -0,0 +1,20 @@
+.cru-card {
+ border-radius: var(--cru-card-border-radius);
+ transition: all 0.3s;
+}
+
+.cru-card-background-none {
+ background-color: transparent;
+}
+
+.cru-card-background-solid {
+ background-color: var(--cru-background-color);
+}
+
+.cru-card-background-grayscale {
+ background-color: var(--cru-container-background-color);
+}
+
+.cru-card-border-color {
+ border: 2px solid var(--cru-card-border-color);
+}
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx
new file mode 100644
index 00000000..5d3ef630
--- /dev/null
+++ b/FrontEnd/src/components/Card.tsx
@@ -0,0 +1,39 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Card.css";
+
+interface CardProps extends ComponentPropsWithoutRef<"div"> {
+ containerRef?: Ref<HTMLDivElement>;
+ color?: ThemeColor;
+ border?: "color" | "none";
+ background?: "color" | "solid" | "grayscale" | "none";
+}
+
+export default function Card({
+ color,
+ background,
+ border,
+ className,
+ children,
+ containerRef,
+ ...otherProps
+}: CardProps) {
+ return (
+ <div
+ ref={containerRef}
+ className={classNames(
+ "cru-card",
+ `cru-card-${color ?? "primary"}`,
+ `cru-card-border-${border ?? "color"}`,
+ `cru-card-background-${background ?? "solid"}`,
+ className,
+ )}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css
new file mode 100644
index 00000000..3c83b0e9
--- /dev/null
+++ b/FrontEnd/src/components/Icon.css
@@ -0,0 +1,4 @@
+.cru-icon {
+ color: var(--cru-theme-color);
+ font-size: 1.4rem;
+}
diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx
new file mode 100644
index 00000000..e5cf598e
--- /dev/null
+++ b/FrontEnd/src/components/Icon.tsx
@@ -0,0 +1,28 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Icon.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+ icon: string;
+ color?: ThemeColor;
+ size?: string | number;
+}
+
+export default function Icon(props: IconButtonProps) {
+ const { icon, color, size, style, className, ...otherProps } = props;
+
+ return (
+ <i
+ style={size != null ? { ...style, fontSize: size } : style}
+ className={classNames(
+ `cru-theme-${color ?? "primary"}`,
+ `bi-${icon} cru-icon`,
+ className,
+ )}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/views/common/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css
index 2c4d0a8c..03d2038f 100644
--- a/FrontEnd/src/views/common/ImageCropper.css
+++ b/FrontEnd/src/components/ImageCropper.css
@@ -1,18 +1,16 @@
-.image-cropper-container {
+.cru-image-cropper-container {
position: relative;
box-sizing: border-box;
+ display: flex;
user-select: none;
}
-.image-cropper-container img {
- position: absolute;
- left: 0;
- top: 0;
+.cru-image-cropper-container img {
width: 100%;
height: 100%;
}
-.image-cropper-mask-container {
+.cru-image-cropper-mask-container {
position: absolute;
left: 0;
top: 0;
@@ -21,18 +19,16 @@
overflow: hidden;
}
-.image-cropper-mask {
+.cru-image-cropper-mask {
position: absolute;
box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
touch-action: none;
}
-.image-cropper-handler {
+.cru-image-cropper-handler {
position: absolute;
- width: 26px;
- height: 26px;
border: black solid 2px;
border-radius: 50%;
background: white;
touch-action: none;
-}
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
new file mode 100644
index 00000000..4dfdd0cd
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -0,0 +1,323 @@
+import { useState, useRef, PointerEvent } from "react";
+import classnames from "classnames";
+
+import { UiLogicError, geometry } from "./common";
+
+import BlobImage from "./BlobImage";
+
+import "./ImageCropper.css";
+
+const { Rect } = geometry;
+
+type Rect = geometry.Rect;
+type Movement = geometry.Movement;
+
+export function crop(
+ image: HTMLImageElement,
+ clip: Rect,
+ mimeType: string,
+): Promise<Blob> {
+ return new Promise((resolve, reject) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = clip.width;
+ canvas.height = clip.height;
+ const context = canvas.getContext("2d");
+
+ if (context == null) throw new Error("Failed to create context.");
+
+ context.drawImage(
+ image,
+ clip.left,
+ clip.top,
+ clip.width,
+ clip.height,
+ 0,
+ 0,
+ clip.width,
+ clip.height,
+ );
+
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ reject(new Error("canvas.toBlob returns null"));
+ } else {
+ resolve(blob);
+ }
+ }, mimeType);
+ });
+}
+
+interface ImageInfo {
+ element: HTMLImageElement;
+ width: number;
+ height: number;
+ ratio: number;
+ landscape: boolean;
+ rect: Rect;
+}
+
+export interface CropConstraint {
+ ratio?: number;
+ // minClipWidth?: number;
+ // minClipHeight?: number;
+ // maxClipWidth?: number;
+ // maxClipHeight?: number;
+}
+
+function generateImageInfo(imageElement: HTMLImageElement): ImageInfo {
+ const { naturalWidth, naturalHeight } = imageElement;
+ const imageRatio = naturalHeight / naturalWidth;
+
+ return {
+ element: imageElement,
+ width: naturalWidth,
+ height: naturalHeight,
+ ratio: imageRatio,
+ landscape: imageRatio < 1,
+ rect: new Rect(0, 0, naturalWidth, naturalHeight),
+ };
+}
+
+interface ImageCropperProps {
+ clip: Rect;
+ image: Blob | string | null;
+ imageElementCallback: (element: HTMLImageElement | null) => void;
+ onImageLoad: () => void;
+ onMove: (movement: Movement, originalClip: Rect) => void;
+ onResize: (movement: Movement, originalClip: Rect) => void;
+ containerClassName?: string;
+}
+
+export function useImageCrop(
+ file: File | null,
+ options?: {
+ constraint?: CropConstraint;
+ },
+): {
+ clip: Rect;
+ setClip: (clip: Rect) => void;
+ canCrop: boolean;
+ crop: () => Promise<Blob>;
+ imageCropperProps: ImageCropperProps;
+} {
+ const targetRatio = options?.constraint?.ratio;
+
+ const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
+ null,
+ );
+ const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
+ const [clip, setClip] = useState<Rect>(Rect.empty);
+
+ if (imageElement == null && imageInfo != null) {
+ setImageInfo(null);
+ setClip(Rect.empty);
+ }
+
+ const canCrop = file != null && imageElement != null && imageInfo != null;
+
+ return {
+ clip,
+ setClip,
+ canCrop,
+ crop() {
+ if (!canCrop) throw new UiLogicError();
+ return crop(imageElement, clip, file.type);
+ },
+ imageCropperProps: {
+ clip,
+ image: file,
+ imageElementCallback: setImageElement,
+ onMove: (movement, originalClip) => {
+ if (imageInfo == null) return;
+ const newClip = geometry.adjustRectToContainer(
+ originalClip.copy().move(movement),
+ imageInfo.rect,
+ "move",
+ {
+ targetRatio,
+ },
+ );
+ setClip(newClip);
+ },
+ onResize: (movement, originalClip) => {
+ if (imageInfo == null) return;
+ const newClip = geometry.adjustRectToContainer(
+ originalClip.copy().expand(movement),
+ imageInfo.rect,
+ "resize",
+ { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" },
+ );
+ setClip(newClip);
+ },
+ onImageLoad: () => {
+ if (imageElement == null) throw new UiLogicError();
+ const image = generateImageInfo(imageElement);
+ setImageInfo(image);
+ setClip(
+ geometry.adjustRectToContainer(Rect.max, image.rect, "both", {
+ targetRatio,
+ }),
+ );
+ },
+ },
+ };
+}
+
+interface PointerState {
+ x: number;
+ y: number;
+ pointerId: number;
+ originalClip: Rect;
+}
+
+const imageCropperHandlerSize = 15;
+
+export function ImageCropper(props: ImageCropperProps) {
+ function convertClipToElement(
+ clip: Rect,
+ imageElement: HTMLImageElement,
+ ): Rect {
+ const xRatio = imageElement.clientWidth / imageElement.naturalWidth;
+ const yRatio = imageElement.clientHeight / imageElement.naturalHeight;
+ return Rect.from({
+ left: xRatio * clip.left,
+ top: yRatio * clip.top,
+ width: xRatio * clip.width,
+ height: yRatio * clip.height,
+ });
+ }
+
+ function convertMovementFromElement(
+ move: Movement,
+ imageElement: HTMLImageElement,
+ ): Movement {
+ const xRatio = imageElement.naturalWidth / imageElement.clientWidth;
+ const yRatio = imageElement.naturalHeight / imageElement.clientHeight;
+ return {
+ x: xRatio * move.x,
+ y: yRatio * move.y,
+ };
+ }
+
+ const {
+ clip,
+ image,
+ imageElementCallback,
+ onImageLoad,
+ onMove,
+ onResize,
+ containerClassName,
+ } = props;
+
+ const pointerStateRef = useRef<PointerState | null>(null);
+ const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
+ null,
+ );
+
+ const clipInElement: Rect =
+ imageElement != null
+ ? convertClipToElement(clip, imageElement)
+ : Rect.empty;
+
+ const actOnMovement = (
+ e: PointerEvent,
+ change: (movement: Movement, originalClip: Rect) => void,
+ ) => {
+ if (
+ imageElement == null ||
+ pointerStateRef.current == null ||
+ pointerStateRef.current.pointerId != e.pointerId
+ ) {
+ return;
+ }
+
+ const { x, y, originalClip } = pointerStateRef.current;
+
+ const movement = {
+ x: e.clientX - x,
+ y: e.clientY - y,
+ };
+
+ change(convertMovementFromElement(movement, imageElement), originalClip);
+ };
+
+ const onPointerDown = (e: PointerEvent) => {
+ if (imageElement == null || pointerStateRef.current != null) return;
+
+ e.currentTarget.setPointerCapture(e.pointerId);
+
+ pointerStateRef.current = {
+ x: e.clientX,
+ y: e.clientY,
+ pointerId: e.pointerId,
+ originalClip: clip,
+ };
+ };
+
+ const onPointerUp = (e: PointerEvent) => {
+ if (
+ pointerStateRef.current == null ||
+ pointerStateRef.current.pointerId != e.pointerId
+ ) {
+ return;
+ }
+
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ pointerStateRef.current = null;
+ };
+
+ const onMaskPointerMove = (e: PointerEvent) => {
+ actOnMovement(e, onMove);
+ };
+
+ const onResizeHandlerPointerMove = (e: PointerEvent) => {
+ actOnMovement(e, onResize);
+ };
+
+ return (
+ <div
+ className={classnames("cru-image-cropper-container", containerClassName)}
+ >
+ <BlobImage
+ imgRef={(element) => {
+ setImageElement(element);
+ imageElementCallback(element);
+ }}
+ src={image}
+ onLoad={onImageLoad}
+ />
+ <div className="cru-image-cropper-mask-container">
+ <div
+ className="cru-image-cropper-mask"
+ style={
+ clipInElement == null
+ ? undefined
+ : {
+ left: clipInElement.left,
+ top: clipInElement.top,
+ width: clipInElement.width,
+ height: clipInElement.height,
+ }
+ }
+ onPointerMove={onMaskPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="cru-image-cropper-handler"
+ style={{
+ left:
+ clipInElement.left + clipInElement.width - imageCropperHandlerSize,
+ top:
+ clipInElement.top + clipInElement.height - imageCropperHandlerSize,
+ width: imageCropperHandlerSize * 2,
+ height: imageCropperHandlerSize * 2,
+ }}
+ onPointerMove={onResizeHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
index 81ba1f67..81ba1f67 100644
--- a/FrontEnd/src/views/common/LoadFailReload.tsx
+++ b/FrontEnd/src/components/LoadFailReload.tsx
diff --git a/FrontEnd/src/components/Page.css b/FrontEnd/src/components/Page.css
new file mode 100644
index 00000000..b22d83af
--- /dev/null
+++ b/FrontEnd/src/components/Page.css
@@ -0,0 +1,8 @@
+.cru-page {
+ padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+ padding-top: 0;
+}
+
diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx
new file mode 100644
index 00000000..8c9febcc
--- /dev/null
+++ b/FrontEnd/src/components/Page.tsx
@@ -0,0 +1,17 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./Page.css";
+
+interface PageProps extends ComponentPropsWithoutRef<"div"> {
+ noTopPadding?: boolean;
+ pageRef?: Ref<HTMLDivElement>;
+}
+
+export default function Page({ noTopPadding, pageRef, className, children }: PageProps) {
+ return (
+ <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}>
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/views/common/SearchInput.css b/FrontEnd/src/components/SearchInput.css
index f0503016..818b2917 100644
--- a/FrontEnd/src/views/common/SearchInput.css
+++ b/FrontEnd/src/components/SearchInput.css
@@ -1,8 +1,8 @@
.cru-search-input {
display: flex;
- flex-wrap: wrap;
+ gap: 1em;
}
.cru-search-input-input {
width: 100%;
-}
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx
new file mode 100644
index 00000000..b1de6227
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.tsx
@@ -0,0 +1,50 @@
+import classNames from "classnames";
+
+import { useC, Text } from "./common";
+import { LoadingButton } from "./button";
+
+import "./SearchInput.css";
+
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ loading?: boolean;
+ className?: string;
+ buttonText?: Text;
+}
+
+export default function SearchInput({
+ value,
+ onChange,
+ onButtonClick,
+ loading,
+ className,
+ buttonText,
+}: SearchInputProps) {
+ const c = useC();
+
+ return (
+ <div className={classNames("cru-search-input", className)}>
+ <input
+ type="search"
+ className="cru-search-input-input"
+ value={value}
+ onChange={(event) => {
+ const { value } = event.currentTarget;
+ onChange(value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ onButtonClick();
+ event.preventDefault();
+ }
+ }}
+ />
+
+ <LoadingButton loading={loading} onClick={onButtonClick}>
+ {c(buttonText ?? "search")}
+ </LoadingButton>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css
new file mode 100644
index 00000000..0f78d3b5
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.css
@@ -0,0 +1,20 @@
+.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: hsl(0 0% 90%);
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+@media (prefers-color-scheme: dark) {
+ .cru-skeleton-line {
+ background-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-skeleton-line:last-child {
+ width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx
new file mode 100644
index 00000000..03f80df5
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.tsx
@@ -0,0 +1,22 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { range } from "~src/utilities";
+
+import "./Skeleton.css";
+
+interface SkeletonProps extends ComponentPropsWithoutRef<"div"> {
+ lineNumber?: number;
+}
+
+export default function Skeleton(props: SkeletonProps) {
+ const { lineNumber, className, ...otherProps } = props;
+
+ return (
+ <div className={classNames(className, "cru-skeleton")} {...otherProps}>
+ {range(lineNumber ?? 3).map((i) => (
+ <div key={i} className="cru-skeleton-line" />
+ ))}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/views/common/Spinner.css b/FrontEnd/src/components/Spinner.css
index a1de68d2..a1de68d2 100644
--- a/FrontEnd/src/views/common/Spinner.css
+++ b/FrontEnd/src/components/Spinner.css
diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx
new file mode 100644
index 00000000..50ccf0b2
--- /dev/null
+++ b/FrontEnd/src/components/Spinner.tsx
@@ -0,0 +1,46 @@
+import { CSSProperties, ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import "./Spinner.css";
+
+const sizeMap: Record<string, string> = {
+ sm: "18px",
+ md: "30px",
+ lg: "42px",
+};
+
+function calculateSize(size: SpinnerProps["size"]) {
+ if (size == null) {
+ return "1em";
+ }
+ if (typeof size === "number") {
+ return size;
+ }
+ if (size in sizeMap) {
+ return sizeMap[size];
+ }
+ return size;
+}
+
+export interface SpinnerProps extends ComponentPropsWithoutRef<"span"> {
+ size?: number | string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export default function Spinner(props: SpinnerProps) {
+ const { size, className, style, ...otherProps } = props;
+ const calculatedSize = calculateSize(size);
+
+ return (
+ <span
+ className={classNames("cru-spinner", className)}
+ style={{
+ width: calculatedSize,
+ height: calculatedSize,
+ ...style,
+ }}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/components/TimelineLogo.tsx
index e06ed0f5..e06ed0f5 100644
--- a/FrontEnd/src/views/common/TimelineLogo.tsx
+++ b/FrontEnd/src/components/TimelineLogo.tsx
diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx
new file mode 100644
index 00000000..59f8f27c
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertHost.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useState } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, useC, Text } from "../common";
+import IconButton from "../button/IconButton";
+
+import { alertService, AlertInfoWithId } from "./AlertService";
+
+import "./alert.css";
+
+interface AutoCloseAlertProps {
+ color: ThemeColor;
+ message: Text;
+ onDismiss?: () => void;
+ onIn?: () => void;
+ onOut?: () => void;
+}
+
+function Alert({
+ color,
+ message,
+ onDismiss,
+ onIn,
+ onOut,
+}: AutoCloseAlertProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames("cru-alert", `cru-theme-${color}`)}
+ onPointerEnter={onIn}
+ onPointerLeave={onOut}
+ >
+ <div className="cru-alert-message">{c(message)}</div>
+ <IconButton
+ icon="x"
+ color="danger"
+ className="cru-alert-close-button"
+ onClick={onDismiss}
+ />
+ </div>
+ );
+}
+
+export default function AlertHost() {
+ const [alerts, setAlerts] = useState<AlertInfoWithId[]>([]);
+
+ useEffect(() => {
+ const listener = (alerts: AlertInfoWithId[]) => {
+ setAlerts(alerts);
+ };
+
+ alertService.registerListener(listener);
+
+ return () => {
+ alertService.unregisterListener(listener);
+ };
+ }, []);
+
+ return (
+ <div className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <Alert
+ key={alert.id}
+ message={alert.message}
+ color={alert.color ?? "primary"}
+ onIn={() => {
+ alertService.clearDismissTimer(alert.id);
+ }}
+ onOut={() => {
+ alertService.resetDismissTimer(alert.id);
+ }}
+ onDismiss={() => {
+ alertService.dismiss(alert.id);
+ }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/alert/AlertService.ts b/FrontEnd/src/components/alert/AlertService.ts
new file mode 100644
index 00000000..b9cda752
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertService.ts
@@ -0,0 +1,114 @@
+import { ThemeColor, Text } from "../common";
+
+const defaultDismissTime = 5000;
+
+export interface AlertInfo {
+ color?: ThemeColor;
+ message: Text;
+ dismissTime?: number | "never";
+}
+
+export interface AlertInfoWithId extends AlertInfo {
+ id: number;
+}
+
+interface AlertServiceAlert extends AlertInfoWithId {
+ timerId: number | null;
+}
+
+export type AlertsListener = (alerts: AlertInfoWithId[]) => void;
+
+export class AlertService {
+ private listeners: AlertsListener[] = [];
+ private alerts: AlertServiceAlert[] = [];
+ private currentId = 1;
+
+ getAlert(alertId?: number | null | undefined): AlertServiceAlert | null {
+ for (const alert of this.alerts) {
+ if (alert.id === alertId) return alert;
+ }
+ return null;
+ }
+
+ registerListener(listener: AlertsListener): void {
+ this.listeners.push(listener);
+ listener(this.alerts);
+ }
+
+ unregisterListener(listener: AlertsListener): void {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+
+ notify() {
+ for (const listener of this.listeners) {
+ listener(this.alerts);
+ }
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertServiceAlert = {
+ ...alert,
+ id: this.currentId++,
+ timerId: null,
+ };
+
+ this.alerts = [...this.alerts, newAlert];
+ this._resetDismissTimer(newAlert);
+
+ this.notify();
+ }
+
+ private _dismiss(alert: AlertServiceAlert) {
+ if (alert.timerId != null) {
+ window.clearTimeout(alert.timerId);
+ }
+ this.alerts = this.alerts.filter((a) => a !== alert);
+ this.notify();
+ }
+
+ dismiss(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._dismiss(alert);
+ }
+ }
+
+ private _clearDismissTimer(alert: AlertServiceAlert) {
+ if (alert.timerId != null) {
+ window.clearTimeout(alert.timerId);
+ alert.timerId = null;
+ }
+ }
+
+ clearDismissTimer(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._clearDismissTimer(alert);
+ }
+ }
+
+ private _resetDismissTimer(
+ alert: AlertServiceAlert,
+ dismissTime?: number | null | undefined,
+ ) {
+ this._clearDismissTimer(alert);
+
+ const realDismissTime =
+ dismissTime ?? alert.dismissTime ?? defaultDismissTime;
+
+ if (typeof realDismissTime === "number") {
+ alert.timerId = window.setTimeout(() => {
+ this._dismiss(alert);
+ }, realDismissTime);
+ }
+ }
+
+ resetDismissTimer(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._resetDismissTimer(alert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css
new file mode 100644
index 00000000..948256de
--- /dev/null
+++ b/FrontEnd/src/components/alert/alert.css
@@ -0,0 +1,21 @@
+.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-theme-color) 2px solid;
+ color: var(--cru-text-primary-color);
+ background-color: var(--cru-container-background-color);
+
+ margin: 1em;
+ padding: 0.5em 1em;
+
+ display: flex;
+ align-items: center;
+}
+
+.cru-alert-close-button {
+ margin-left: auto;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/alert/index.ts b/FrontEnd/src/components/alert/index.ts
new file mode 100644
index 00000000..1be0c2ec
--- /dev/null
+++ b/FrontEnd/src/components/alert/index.ts
@@ -0,0 +1,8 @@
+import { alertService, AlertInfo } from "./AlertService";
+import { default as AlertHost } from "./AlertHost";
+
+export { alertService, AlertHost };
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts
new file mode 100644
index 00000000..fb281610
--- /dev/null
+++ b/FrontEnd/src/components/breakpoints.ts
@@ -0,0 +1,3 @@
+export const breakpoints = {
+ sm: 576,
+} as const;
diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css
new file mode 100644
index 00000000..1da70f0e
--- /dev/null
+++ b/FrontEnd/src/components/button/Button.css
@@ -0,0 +1,64 @@
+.cru-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.3s;
+ border-radius: 0.2em;
+ border: 1px solid;
+ cursor: pointer;
+}
+
+.cru-button:not(.outline) {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+}
+
+.cru-button:not(.outline):hover {
+ background-color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button:not(.outline):focus {
+ background-color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button:not(.outline):active {
+ background-color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button:not(.outline):disabled {
+ color: var(--cru-push-button-disabled-text-color);
+ background-color: var(--cru-push-button-disabled-color);
+ border-color: var(--cru-push-button-disabled-color);
+ cursor: auto;
+}
+
+
+.cru-button.outline {
+ color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+ background-color: transparent;
+}
+
+.cru-button.outline:hover {
+ color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button.outline:focus {
+ color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button.outline:active {
+ color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button.outline:disabled {
+ color: var(--cru-clickable-disabled-color);
+ border-color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx
index be605328..30ea8c11 100644
--- a/FrontEnd/src/views/common/button/Button.tsx
+++ b/FrontEnd/src/components/button/Button.tsx
@@ -1,14 +1,13 @@
import { ComponentPropsWithoutRef, Ref } from "react";
import classNames from "classnames";
-import { I18nText, useC } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { Text, useC, ClickableColor } from "../common";
import "./Button.css";
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
- color?: PaletteColorType;
- text?: I18nText;
+ color?: ClickableColor;
+ text?: Text;
outline?: boolean;
buttonRef?: Ref<HTMLButtonElement> | null;
}
@@ -34,8 +33,8 @@ export default function Button(props: ButtonProps) {
<button
ref={buttonRef}
className={classNames(
- "cru-" + (color ?? "primary"),
"cru-button",
+ `cru-clickable-${color ?? "primary"}`,
outline && "outline",
className,
)}
diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.css
diff --git a/FrontEnd/src/components/button/ButtonRow.tsx b/FrontEnd/src/components/button/ButtonRow.tsx
new file mode 100644
index 00000000..eea60cc4
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.tsx
@@ -0,0 +1,62 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonRowButton = (
+ | {
+ type: "normal";
+ props: ComponentPropsWithoutRef<typeof Button>;
+ }
+ | {
+ type: "flat";
+ props: ComponentPropsWithoutRef<typeof FlatButton>;
+ }
+ | {
+ type: "icon";
+ props: ComponentPropsWithoutRef<typeof IconButton>;
+ }
+ | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> }
+) & { key: string | number };
+
+interface ButtonRowProps {
+ className?: string;
+ containerRef?: Ref<HTMLDivElement>;
+ buttons: ButtonRowButton[];
+ buttonsClassName?: string;
+}
+
+export default function ButtonRow({
+ className,
+ containerRef,
+ buttons,
+ buttonsClassName,
+}: ButtonRowProps) {
+ return (
+ <div ref={containerRef} className={classNames("cru-button-row", className)}>
+ {buttons.map((button) => {
+ const { type, key, props } = button;
+ const newClassName = classNames(props.className, buttonsClassName);
+ switch (type) {
+ case "normal":
+ return <Button key={key} {...props} className={newClassName} />;
+ case "flat":
+ return <FlatButton key={key} {...props} className={newClassName} />;
+ case "icon":
+ return <IconButton key={key} {...props} className={newClassName} />;
+ case "loading":
+ return (
+ <LoadingButton key={key} {...props} className={newClassName} />
+ );
+ default:
+ throw new Error();
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx
new file mode 100644
index 00000000..a54425cc
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -0,0 +1,146 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, ClickableColor } from "../common";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonAction = "major" | "minor";
+
+interface ButtonRowV2ButtonBase {
+ key: string | number;
+ action?: ButtonAction;
+ color?: ClickableColor;
+ disabled?: boolean;
+ onClick?: () => void;
+}
+
+interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase {
+ type?: undefined | null;
+ text: Text;
+ outline?: boolean;
+ props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase {
+ type: "normal";
+ text: Text;
+ outline?: boolean;
+ props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase {
+ type: "flat";
+ text: Text;
+ props?: ComponentPropsWithoutRef<typeof FlatButton>;
+}
+
+interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase {
+ type: "icon";
+ icon: string;
+ props?: ComponentPropsWithoutRef<typeof IconButton>;
+}
+
+interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase {
+ type: "loading";
+ text: Text;
+ loading?: boolean;
+ props?: ComponentPropsWithoutRef<typeof LoadingButton>;
+}
+
+type ButtonRowV2Button =
+ | ButtonRowV2ButtonWithNoType
+ | ButtonRowV2NormalButton
+ | ButtonRowV2FlatButton
+ | ButtonRowV2IconButton
+ | ButtonRowV2LoadingButton;
+
+interface ButtonRowV2Props {
+ className?: string;
+ containerRef?: Ref<HTMLDivElement>;
+ buttons: ButtonRowV2Button[];
+ buttonsClassName?: string;
+}
+
+export default function ButtonRowV2({
+ className,
+ containerRef,
+ buttons,
+ buttonsClassName,
+}: ButtonRowV2Props) {
+ return (
+ <div ref={containerRef} className={classNames("cru-button-row", className)}>
+ {buttons.map((button) => {
+ const { key, action, color, disabled, onClick } = button;
+
+ const realAction: ButtonAction = action ?? "minor";
+ const realColor =
+ color ?? (realAction === "major" ? "primary" : "minor");
+
+ const commonProps = { key, color: realColor, disabled, onClick };
+ const newClassName = classNames(
+ button.props?.className,
+ buttonsClassName,
+ );
+
+ switch (button.type) {
+ case null:
+ case undefined:
+ case "normal": {
+ const { text, outline, props } = button;
+ return (
+ <Button
+ {...commonProps}
+ text={text}
+ outline={outline ?? realAction !== "major"}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "flat": {
+ const { text, props } = button;
+ return (
+ <FlatButton
+ {...commonProps}
+ text={text}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "icon": {
+ const { icon, props } = button;
+ return (
+ <IconButton
+ {...commonProps}
+ icon={icon}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "loading": {
+ const { text, loading, props } = button;
+ return (
+ <LoadingButton
+ {...commonProps}
+ text={text}
+ loading={loading}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ default:
+ throw new Error();
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css
new file mode 100644
index 00000000..2050946c
--- /dev/null
+++ b/FrontEnd/src/components/button/FlatButton.css
@@ -0,0 +1,27 @@
+.cru-flat-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.5s;
+ border-radius: 0.2em;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: 1px none;
+ color: var(--cru-clickable-normal-color);
+ cursor: pointer;
+}
+
+.cru-flat-button:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-flat-button:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-flat-button:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+} \ No newline at end of file
diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx
index 49912b68..aad02e76 100644
--- a/FrontEnd/src/views/common/button/FlatButton.tsx
+++ b/FrontEnd/src/components/button/FlatButton.tsx
@@ -1,14 +1,13 @@
import { ComponentPropsWithoutRef, Ref } from "react";
import classNames from "classnames";
-import { I18nText, useC } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { Text, useC, ClickableColor } from "../common";
import "./FlatButton.css";
interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
- color?: PaletteColorType;
- text?: I18nText;
+ color?: ClickableColor;
+ text?: Text;
buttonRef?: Ref<HTMLButtonElement> | null;
}
@@ -25,8 +24,8 @@ export default function FlatButton(props: FlatButtonProps) {
<button
ref={buttonRef}
className={classNames(
- "cru-" + (color ?? "primary"),
"cru-flat-button",
+ `cru-clickable-${color ?? "primary"}`,
className,
)}
{...otherProps}
diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css
new file mode 100644
index 00000000..a3747201
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.css
@@ -0,0 +1,30 @@
+.cru-icon-button {
+ color: var(--cru-clickable-normal-color);
+ font-size: 1.4rem;
+ background: none;
+ border: none;
+ transition: all 0.5s;
+ cursor: pointer;
+ user-select: none;
+}
+
+.cru-icon-button:hover {
+ color: var(--cru-clickable-hover-color);
+}
+
+.cru-icon-button:focus {
+ color: var(--cru-clickable-focus-color);
+}
+
+.cru-icon-button:active {
+ color: var(--cru-clickable-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
+
+.cru-icon-button.large {
+ font-size: 1.6rem;
+}
diff --git a/FrontEnd/src/views/common/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx
index 652a8b09..e0862167 100644
--- a/FrontEnd/src/views/common/button/IconButton.tsx
+++ b/FrontEnd/src/components/button/IconButton.tsx
@@ -1,14 +1,15 @@
import { ComponentPropsWithoutRef } from "react";
import classNames from "classnames";
-import { PaletteColorType } from "@/palette";
+import { ClickableColor } from "../common";
import "./IconButton.css";
interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
icon: string;
- color?: PaletteColorType;
+ color?: ClickableColor;
large?: boolean;
+ disabled?: boolean; // TODO: Not implemented
}
export default function IconButton(props: IconButtonProps) {
@@ -18,9 +19,9 @@ export default function IconButton(props: IconButtonProps) {
<button
className={classNames(
"cru-icon-button",
+ `cru-clickable-${color ?? "grayscale"}`,
large && "large",
"bi-" + icon,
- color ? "cru-" + color : "cru-primary",
className,
)}
{...otherProps}
diff --git a/FrontEnd/src/components/button/LoadingButton.css b/FrontEnd/src/components/button/LoadingButton.css
new file mode 100644
index 00000000..23fadd3d
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.css
@@ -0,0 +1,13 @@
+.cru-loading-button {
+ display: flex;
+ align-items: center;
+}
+
+.cru-loading-button-spinner {
+ margin-left: 0.5em;
+}
+
+.cru-loading-button-loading {
+ color: var(--cru-clickable-normal-color) !important;
+ border-color: var(--cru-clickable-normal-color) !important;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx
new file mode 100644
index 00000000..9d65a2b3
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -0,0 +1,39 @@
+import classNames from "classnames";
+
+import { I18nText, ClickableColor, useC } from "../common";
+import Spinner from "../Spinner";
+
+import "./LoadingButton.css";
+
+interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
+ color?: ClickableColor;
+ text?: I18nText;
+ loading?: boolean;
+}
+
+export default function LoadingButton(props: LoadingButtonProps) {
+ const c = useC();
+
+ const { color, text, loading, disabled, className, children, ...otherProps } =
+ props;
+
+ if (text != null && children != null) {
+ console.warn("You can't set both text and children props.");
+ }
+
+ return (
+ <button
+ disabled={disabled || loading}
+ className={classNames(
+ "cru-button outline cru-loading-button",
+ `cru-clickable-${color ?? "primary"}`,
+ loading && "cru-loading-button-loading",
+ className,
+ )}
+ {...otherProps}
+ >
+ {text != null ? c(text) : children}
+ {loading && <Spinner className="cru-loading-button-spinner" />}
+ </button>
+ );
+}
diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx
new file mode 100644
index 00000000..b5aa5470
--- /dev/null
+++ b/FrontEnd/src/components/button/index.tsx
@@ -0,0 +1,15 @@
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+import ButtonRow from "./ButtonRow";
+import ButtonRowV2 from "./ButtonRowV2";
+
+export {
+ Button,
+ FlatButton,
+ IconButton,
+ LoadingButton,
+ ButtonRow,
+ ButtonRowV2,
+};
diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
new file mode 100644
index 00000000..a6c3e705
--- /dev/null
+++ b/FrontEnd/src/components/common.ts
@@ -0,0 +1,22 @@
+import "./index.css";
+
+export type { Text, I18nText } from "~src/common";
+export { UiLogicError, c, convertI18nText, useC } from "~src/common";
+
+export const themeColors = [
+ "primary",
+ "secondary",
+ "danger",
+ "create",
+] as const;
+
+export type ThemeColor = (typeof themeColors)[number];
+
+export type ClickableColor = ThemeColor | "grayscale" | "light" | "minor";
+
+export { breakpoints } from "./breakpoints";
+
+export * as geometry from "~src/utilities/geometry";
+
+export * as array from "~src/utilities/array"
+
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 00000000..8b0a4219
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,54 @@
+import { useC, Text, ThemeColor } from "../common";
+
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { useCloseDialog } from "./DialogProvider";
+
+export default function ConfirmDialog({
+ onConfirm,
+ title,
+ body,
+ color,
+}: {
+ onConfirm: () => void;
+ title: Text;
+ body: Text;
+ color?: ThemeColor;
+ bodyColor?: ThemeColor;
+}) {
+ const c = useC();
+
+ const closeDialog = useCloseDialog();
+
+ return (
+ <Dialog color={color ?? "danger"}>
+ <DialogContainer
+ title={title}
+ titleColor={color ?? "danger"}
+ buttonsV2={[
+ {
+ key: "cancel",
+ type: "normal",
+ action: "minor",
+
+ text: "operationDialog.cancel",
+ onClick: closeDialog,
+ },
+ {
+ key: "confirm",
+ type: "normal",
+ action: "major",
+ text: "operationDialog.confirm",
+ color: "danger",
+ onClick: () => {
+ onConfirm();
+ closeDialog();
+ },
+ },
+ ]}
+ >
+ <div>{c(body)}</div>
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
new file mode 100644
index 00000000..23b663db
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -0,0 +1,39 @@
+.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ padding: 20vh 1em;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-dialog-overlay-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-theme-color) 2px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-dialog-container-background-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
new file mode 100644
index 00000000..043a8eec
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -0,0 +1,55 @@
+import { ReactNode, useRef } from "react";
+import ReactDOM from "react-dom";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+
+import { useCloseDialog } from "./DialogProvider";
+
+import "./Dialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface DialogProps {
+ color?: ThemeColor;
+ children?: ReactNode;
+ disableCloseOnClickOnOverlay?: boolean;
+}
+
+export default function Dialog({
+ color,
+ children,
+ disableCloseOnClickOnOverlay,
+}: DialogProps) {
+ const closeDialog = useCloseDialog();
+
+ const lastPointerDownIdRef = useRef<number | null>(null);
+
+ return ReactDOM.createPortal(
+ <div
+ className={classNames(
+ `cru-theme-${color ?? "primary"}`,
+ "cru-dialog-overlay",
+ )}
+ >
+ <div
+ className="cru-dialog-background"
+ onPointerDown={(e) => {
+ lastPointerDownIdRef.current = e.pointerId;
+ }}
+ onPointerUp={(e) => {
+ if (lastPointerDownIdRef.current === e.pointerId) {
+ if (!disableCloseOnClickOnOverlay) closeDialog();
+ }
+ lastPointerDownIdRef.current = null;
+ }}
+ />
+ <div className="cru-dialog-container">{children}</div>
+ </div>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css
new file mode 100644
index 00000000..f0d27a66
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.css
@@ -0,0 +1,20 @@
+.cru-dialog-container-title {
+ font-size: 1.2em;
+ font-weight: bold;
+ color: var(--cru-theme-color);
+ margin-bottom: 0.5em;
+}
+
+.cru-dialog-container-hr {
+ margin: 1em 0;
+ border-color: var(--cru-text-minor-color);
+}
+
+.cru-dialog-container-button-row {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.cru-dialog-container-button {
+ margin-left: 1em;
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx
new file mode 100644
index 00000000..6ee4e134
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.tsx
@@ -0,0 +1,95 @@
+import { ComponentProps, Ref, ReactNode } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, Text, useC } from "../common";
+import { ButtonRow, ButtonRowV2 } from "../button";
+
+import "./DialogContainer.css";
+
+interface DialogContainerBaseProps {
+ className?: string;
+ title: Text;
+ titleColor?: ThemeColor;
+ titleClassName?: string;
+ titleRef?: Ref<HTMLDivElement>;
+ bodyContainerClassName?: string;
+ bodyContainerRef?: Ref<HTMLDivElement>;
+ buttonsClassName?: string;
+ buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"];
+ children: ReactNode;
+}
+
+interface DialogContainerWithButtonsProps extends DialogContainerBaseProps {
+ buttons: ComponentProps<typeof ButtonRow>["buttons"];
+}
+
+interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps {
+ buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"];
+}
+
+type DialogContainerProps =
+ | DialogContainerWithButtonsProps
+ | DialogContainerWithButtonsV2Props;
+
+export default function DialogContainer(props: DialogContainerProps) {
+ const {
+ className,
+ title,
+ titleColor,
+ titleClassName,
+ titleRef,
+ bodyContainerClassName,
+ bodyContainerRef,
+ buttonsClassName,
+ buttonsContainerRef,
+ children,
+ } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className)}>
+ <div
+ ref={titleRef}
+ className={classNames(
+ `cru-dialog-container-title cru-theme-${titleColor ?? "primary"}`,
+ titleClassName,
+ )}
+ >
+ {c(title)}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ <div
+ ref={bodyContainerRef}
+ className={classNames(
+ "cru-dialog-container-body",
+ bodyContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ {"buttons" in props ? (
+ <ButtonRow
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttons}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ ) : (
+ <ButtonRowV2
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttonsV2}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx
new file mode 100644
index 00000000..bb85e4cf
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogProvider.tsx
@@ -0,0 +1,95 @@
+import { useState, useContext, createContext, ReactNode } from "react";
+
+import { UiLogicError } from "../common";
+
+type DialogMap<D extends string> = {
+ [K in D]: ReactNode;
+};
+
+interface DialogController<D extends string> {
+ currentDialog: D | null;
+ currentDialogReactNode: ReactNode;
+ canSwitchDialog: boolean;
+ switchDialog: (newDialog: D | null) => void;
+ setCanSwitchDialog: (enable: boolean) => void;
+ closeDialog: () => void;
+ forceSwitchDialog: (newDialog: D | null) => void;
+ forceCloseDialog: () => void;
+}
+
+export function useDialog<D extends string>(
+ dialogs: DialogMap<D>,
+ options?: {
+ initDialog?: D | null;
+ onClose?: {
+ [K in D]?: () => void;
+ };
+ },
+): {
+ controller: DialogController<D>;
+ switchDialog: (newDialog: D | null) => void;
+ forceSwitchDialog: (newDialog: D | null) => void;
+ createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+ const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true);
+ const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
+
+ const forceSwitchDialog = (newDialog: D | null) => {
+ if (dialog != null) {
+ options?.onClose?.[dialog]?.();
+ }
+ setDialog(newDialog);
+ setCanSwitchDialog(true);
+ };
+
+ const switchDialog = (newDialog: D | null) => {
+ if (canSwitchDialog) {
+ forceSwitchDialog(newDialog);
+ }
+ };
+
+ const controller: DialogController<D> = {
+ currentDialog: dialog,
+ currentDialogReactNode: dialog == null ? null : dialogs[dialog],
+ canSwitchDialog,
+ switchDialog,
+ setCanSwitchDialog,
+ closeDialog: () => switchDialog(null),
+ forceSwitchDialog,
+ forceCloseDialog: () => forceSwitchDialog(null),
+ };
+
+ return {
+ controller,
+ switchDialog,
+ forceSwitchDialog,
+ createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+ };
+}
+
+const DialogControllerContext = createContext<DialogController<string> | null>(
+ null,
+);
+
+export function useDialogController(): DialogController<string> {
+ const controller = useContext(DialogControllerContext);
+ if (controller == null) throw new UiLogicError("not in dialog provider");
+ return controller;
+}
+
+export function useCloseDialog(): () => void {
+ const controller = useDialogController();
+ return controller.closeDialog;
+}
+
+export function DialogProvider<D extends string>({
+ controller,
+}: {
+ controller: DialogController<D>;
+}) {
+ return (
+ <DialogControllerContext.Provider value={controller as never}>
+ {controller.currentDialogReactNode}
+ </DialogControllerContext.Provider>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css
new file mode 100644
index 00000000..ce07c6ac
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.css
@@ -0,0 +1,30 @@
+.cru-dialog-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--cru-background-color);
+ padding-top: 56px;
+}
+
+.cru-dialog-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-theme-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-dialog-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-dialog-full-page-back-button {
+ margin-left: 0.5em;
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
new file mode 100644
index 00000000..575abf7f
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -0,0 +1,52 @@
+import { ReactNode } from "react";
+import { createPortal } from "react-dom";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+import { IconButton } from "../button";
+
+import { useCloseDialog } from "./DialogProvider";
+
+import "./FullPageDialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface FullPageDialogProps {
+ color?: ThemeColor;
+ contentContainerClassName?: string;
+ children: ReactNode;
+}
+
+export default function FullPageDialog({
+ color,
+ children,
+ contentContainerClassName,
+}: FullPageDialogProps) {
+ const closeDialog = useCloseDialog();
+
+ return createPortal(
+ <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}>
+ <div className="cru-dialog-full-page-top-bar">
+ <IconButton
+ icon="arrow-left"
+ color="light"
+ className="cru-dialog-full-page-back-button"
+ onClick={closeDialog}
+ />
+ </div>
+ <div
+ className={classNames(
+ "cru-dialog-full-page-content-container",
+ contentContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ </div>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css
new file mode 100644
index 00000000..28f73c9d
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.css
@@ -0,0 +1,4 @@
+.cru-operation-dialog-input-group {
+ display: block;
+ margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
new file mode 100644
index 00000000..6ca4d0a0
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -0,0 +1,221 @@
+import { useState, ReactNode, ComponentProps } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+import {
+ useInputs,
+ InputGroup,
+ Initializer as InputInitializer,
+ InputConfirmValueDict,
+} from "../input";
+import { ButtonRowV2 } from "../button";
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { useDialogController } from "./DialogProvider";
+
+import "./OperationDialog.css";
+
+interface OperationDialogPromptProps {
+ message?: Text;
+ customMessage?: Text;
+ customMessageNode?: ReactNode;
+ className?: string;
+}
+
+function OperationDialogPrompt(props: OperationDialogPromptProps) {
+ const { message, customMessage, customMessageNode, className } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className, "cru-operation-dialog-prompt")}>
+ {message && <p>{c(message)}</p>}
+ {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
+ </div>
+ );
+}
+
+export interface OperationDialogProps<TData> {
+ color?: ThemeColor;
+ inputColor?: ThemeColor;
+ title: Text;
+ inputPrompt?: Text;
+ inputPromptNode?: ReactNode;
+ successPrompt?: (data: TData) => Text;
+ successPromptNode?: (data: TData) => ReactNode;
+ failurePrompt?: (error: unknown) => Text;
+ failurePromptNode?: (error: unknown) => ReactNode;
+
+ inputs: InputInitializer;
+
+ onProcess: (inputs: InputConfirmValueDict) => Promise<TData>;
+ onSuccessAndClose?: (data: TData) => void;
+}
+
+function OperationDialog<TData>(props: OperationDialogProps<TData>) {
+ const {
+ color,
+ inputColor,
+ title,
+ inputPrompt,
+ inputPromptNode,
+ successPrompt,
+ successPromptNode,
+ failurePrompt,
+ failurePromptNode,
+ inputs,
+ onProcess,
+ onSuccessAndClose,
+ } = props;
+
+ if (process.env.NODE_ENV === "development") {
+ if (inputPrompt && inputPromptNode) {
+ console.log("InputPrompt and inputPromptNode are both set.");
+ }
+ if (successPrompt && successPromptNode) {
+ console.log("SuccessPrompt and successPromptNode are both set.");
+ }
+ if (failurePrompt && failurePromptNode) {
+ console.log("FailurePrompt and failurePromptNode are both set.");
+ }
+ }
+
+ type Step =
+ | { type: "input" }
+ | { type: "process" }
+ | {
+ type: "success";
+ data: TData;
+ }
+ | {
+ type: "failure";
+ data: unknown;
+ };
+
+ const dialogController = useDialogController();
+
+ const [step, setStep] = useState<Step>({ type: "input" });
+
+ const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+ useInputs({
+ init: inputs,
+ });
+
+ function close() {
+ if (step.type !== "process") {
+ dialogController.closeDialog();
+ if (step.type === "success" && onSuccessAndClose) {
+ onSuccessAndClose?.(step.data);
+ }
+ } else {
+ console.log("Attempt to close modal dialog when processing.");
+ }
+ }
+
+ function onConfirm() {
+ const result = confirm();
+ if (result.type === "ok") {
+ setStep({ type: "process" });
+ dialogController.setCanSwitchDialog(false);
+ setAllDisabled(true);
+ onProcess(result.values)
+ .then(
+ (d) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ },
+ )
+ .finally(() => {
+ dialogController.setCanSwitchDialog(true);
+ });
+ }
+ }
+
+ let body: ReactNode;
+ let buttons: ComponentProps<typeof ButtonRowV2>["buttons"];
+
+ if (step.type === "input" || step.type === "process") {
+ const isProcessing = step.type === "process";
+
+ body = (
+ <div>
+ <OperationDialogPrompt
+ customMessage={inputPrompt}
+ customMessageNode={inputPromptNode}
+ />
+ <InputGroup
+ containerClassName="cru-operation-dialog-input-group"
+ color={inputColor ?? "primary"}
+ {...inputGroupProps}
+ />
+ </div>
+ );
+ buttons = [
+ {
+ key: "cancel",
+ text: "operationDialog.cancel",
+ onClick: close,
+ disabled: isProcessing,
+ },
+ {
+ key: "confirm",
+ type: "loading",
+ action: "major",
+ text: "operationDialog.confirm",
+ color,
+ loading: isProcessing,
+ disabled: hasErrorAndDirty,
+ onClick: onConfirm,
+ },
+ ];
+ } else {
+ const result = step;
+
+ const promptProps: OperationDialogPromptProps =
+ result.type === "success"
+ ? {
+ message: "operationDialog.success",
+ customMessage: successPrompt?.(result.data),
+ customMessageNode: successPromptNode?.(result.data),
+ }
+ : {
+ message: "operationDialog.error",
+ customMessage: failurePrompt?.(result.data),
+ customMessageNode: failurePromptNode?.(result.data),
+ };
+ body = (
+ <div>
+ <OperationDialogPrompt {...promptProps} />
+ </div>
+ );
+
+ buttons = [
+ {
+ key: "ok",
+ type: "normal",
+ action: "major",
+ color: "create",
+ text: "operationDialog.ok",
+ onClick: close,
+ },
+ ];
+ }
+
+ return (
+ <Dialog color={color}>
+ <DialogContainer title={title} titleColor={color} buttonsV2={buttons}>
+ {body}
+ </DialogContainer>
+ </Dialog>
+ );
+}
+
+export default OperationDialog;
diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx
new file mode 100644
index 00000000..9ca06de2
--- /dev/null
+++ b/FrontEnd/src/components/dialog/index.tsx
@@ -0,0 +1,12 @@
+export { default as Dialog } from "./Dialog";
+export { default as FullPageDialog } from "./FullPageDialog";
+export { default as OperationDialog } from "./OperationDialog";
+export { default as ConfirmDialog } from "./ConfirmDialog";
+export { default as DialogContainer } from "./DialogContainer";
+
+export {
+ useDialog,
+ useDialogController,
+ useCloseDialog,
+ DialogProvider,
+} from "./DialogProvider";
diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts
new file mode 100644
index 00000000..98ce729e
--- /dev/null
+++ b/FrontEnd/src/components/hooks/index.ts
@@ -0,0 +1,5 @@
+export { useMobile } from "./responsive";
+export { default as useClickOutside } from "./useClickOutside";
+export { default as useScrollToBottom } from "./useScrollToBottom";
+export { default as useWindowLeave } from "./useWindowLeave";
+export { default as useAutoUnsubscribePromise } from "./useAutoUnsubscribePromise";
diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts
new file mode 100644
index 00000000..42c134ef
--- /dev/null
+++ b/FrontEnd/src/components/hooks/responsive.ts
@@ -0,0 +1,7 @@
+import { useMediaQuery } from "react-responsive";
+
+import { breakpoints } from "../breakpoints";
+
+export function useMobile(onChange?: (mobile: boolean) => void): boolean {
+ return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange);
+}
diff --git a/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts
new file mode 100644
index 00000000..01c5a1db
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts
@@ -0,0 +1,24 @@
+import { useEffect, DependencyList } from "react";
+
+export default function useAutoUnsubscribePromise<T>(
+ promiseGenerator: () => Promise<T> | null | undefined,
+ resultHandler: (data: T) => void,
+ dependencies?: DependencyList | undefined,
+) {
+ useEffect(() => {
+ let subscribe = true;
+ const promise = promiseGenerator();
+ if (promise) {
+ void promise.then((data) => {
+ if (subscribe) {
+ resultHandler(data);
+ }
+ });
+
+ return () => {
+ subscribe = false;
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [promiseGenerator, resultHandler, ...(dependencies ?? [])]);
+}
diff --git a/FrontEnd/src/utilities/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts
index 6dcbf7b3..828ce7e3 100644
--- a/FrontEnd/src/utilities/hooks/useClickOutside.ts
+++ b/FrontEnd/src/components/hooks/useClickOutside.ts
@@ -3,7 +3,7 @@ import { useRef, useEffect } from "react";
export default function useClickOutside(
element: HTMLElement | null | undefined,
onClickOutside: () => void,
- nextTick?: boolean
+ nextTick?: boolean,
): void {
const onClickOutsideRef = useRef<() => void>(onClickOutside);
diff --git a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts
index 216746f4..79fcda16 100644
--- a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts
+++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts
@@ -1,6 +1,5 @@
import { useRef, useEffect } from "react";
-import { fromEvent } from "rxjs";
-import { filter, throttleTime } from "rxjs/operators";
+import { fromEvent, filter, throttleTime } from "rxjs";
function useScrollToBottom(
handler: () => void,
@@ -8,7 +7,7 @@ function useScrollToBottom(
option = {
maxOffset: 5,
throttle: 1000,
- }
+ },
): void {
const handlerRef = useRef<(() => void) | null>(null);
@@ -26,9 +25,9 @@ function useScrollToBottom(
filter(
() =>
window.scrollY >=
- document.body.scrollHeight - window.innerHeight - option.maxOffset
+ document.body.scrollHeight - window.innerHeight - option.maxOffset,
),
- throttleTime(option.throttle)
+ throttleTime(option.throttle),
)
.subscribe(() => {
if (enable) {
diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts
new file mode 100644
index 00000000..ecd999d4
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useWindowLeave.ts
@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+
+import { useC, Text } from "../common";
+
+export default function useWindowLeave(
+ allow: boolean,
+ message: Text = "timeline.confirmLeave",
+) {
+ const c = useC();
+
+ useEffect(() => {
+ if (!allow) {
+ window.onbeforeunload = () => {
+ return c(message);
+ };
+
+ return () => {
+ window.onbeforeunload = null;
+ };
+ }
+ }, [c, allow, message]);
+}
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
new file mode 100644
index 00000000..83b48318
--- /dev/null
+++ b/FrontEnd/src/components/index.css
@@ -0,0 +1,49 @@
+@import "./theme.css";
+
+* {
+ box-sizing: border-box;
+ margin-inline: 0;
+ margin-block: 0;
+}
+
+body {
+ font-family: var(--cru-default-font-family);
+ background: var(--cru-body-background-color);
+ color: var(--cru-text-major-color);
+ line-height: 1.2;
+}
+
+textarea {
+ transition: border-color 0.3s;
+ border-color: var(--cru-text-minor-color);
+ background: var(--cru-background-color);
+}
+
+textarea:hover {
+ border-color: var(--cru-clickable-primary-hover-color);
+}
+
+textarea:focus {
+ border-color: var(--cru-clickable-primary-normal-color);
+}
+
+.alert-container {
+ position: fixed;
+ z-index: 1070;
+}
+
+@media (min-width: 576px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ left: 0;
+ text-align: center;
+ }
+}
diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css
new file mode 100644
index 00000000..7e905b1e
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.css
@@ -0,0 +1,54 @@
+.cru-input-group {
+ display: block;
+}
+
+.cru-input-container {
+ margin: 0.4em 0;
+}
+
+.cru-input-label {
+ display: block;
+ color: var(--cru-clickable-normal-color);
+ font-size: 0.9em;
+ margin-bottom: 0.3em;
+}
+
+.cru-input-label-inline {
+ margin-inline-start: 0.5em;
+}
+
+.cru-input-type-text input {
+ appearance: none;
+ display: block;
+ border: 1px solid;
+ /* color: var(--cru-surface-on-color); */
+ /* background-color: var(--cru-surface-color); */
+ margin: 0;
+ font-size: 1em;
+ padding: 0.2em;
+}
+
+.cru-input-type-text input:hover {
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-input-type-text input:focus {
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-input-type-text input:disabled {
+ border-color: var(--cru-clickable-disabled-color);
+}
+
+.cru-input-error {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-danger-color);
+ margin-top: 0.4em;
+}
+
+.cru-input-helper {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx
new file mode 100644
index 00000000..47a43b38
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.tsx
@@ -0,0 +1,463 @@
+/**
+ * Some notes for InputGroup:
+ * This is one of the most complicated components in this project.
+ * Probably because the feature is complex and involved user inputs.
+ *
+ * I hope it contains following features:
+ * - Input features
+ * - Supports a wide range of input types.
+ * - Validator to validate user inputs.
+ * - Can set initial values.
+ * - Dirty, aka, has user touched this input.
+ * - Developer friendly
+ * - Easy to use APIs.
+ * - Type check as much as possible.
+ * - UI
+ * - Configurable appearance.
+ * - Can display helper and error messages.
+ * - Easy to extend, like new input types.
+ *
+ * So here is some design decisions:
+ * Inputs are identified by its _key_.
+ * `InputGroup` component takes care of only UI and no logic.
+ * `useInputs` hook takes care of logic and generate props for `InputGroup`.
+ */
+
+import { useState, Ref, useId } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./InputGroup.css";
+
+export interface InputBase {
+ key: string;
+ label: Text;
+ helper?: Text;
+ disabled?: boolean;
+ error?: Text;
+}
+
+export interface TextInput extends InputBase {
+ type: "text";
+ value: string;
+ password?: boolean;
+}
+
+export interface BoolInput extends InputBase {
+ type: "bool";
+ value: boolean;
+}
+
+export interface SelectInputOption {
+ value: string;
+ label: Text;
+ icon?: string;
+}
+
+export interface SelectInput extends InputBase {
+ type: "select";
+ value: string;
+ options: SelectInputOption[];
+}
+
+export type Input = TextInput | BoolInput | SelectInput;
+
+export type InputValue = Input["value"];
+
+export type InputValueDict = Record<string, InputValue>;
+export type InputErrorDict = Record<string, Text>;
+export type InputDisabledDict = Record<string, boolean>;
+export type InputDirtyDict = Record<string, boolean>;
+// use never so you don't have to cast everywhere
+export type InputConfirmValueDict = Record<string, never>;
+
+export type GeneralInputErrorDict = {
+ [key: string]: Text | null | undefined;
+};
+
+type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
+
+export type InputInfo = {
+ [I in Input as I["type"]]: MakeInputInfo<I>;
+}[Input["type"]];
+
+export type Validator = (
+ values: InputValueDict,
+ errors: GeneralInputErrorDict,
+ inputs: InputInfo[],
+) => void;
+
+export type InputScheme = {
+ inputs: InputInfo[];
+ validator?: Validator;
+};
+
+export type InputData = {
+ values: InputValueDict;
+ errors: InputErrorDict;
+ disabled: InputDisabledDict;
+ dirties: InputDirtyDict;
+};
+
+export type State = {
+ scheme: InputScheme;
+ data: InputData;
+};
+
+export type DataInitialization = {
+ values?: InputValueDict;
+ errors?: GeneralInputErrorDict;
+ disabled?: InputDisabledDict;
+ dirties?: InputDirtyDict;
+};
+
+export type Initialization = {
+ scheme: InputScheme;
+ dataInit?: DataInitialization;
+};
+
+export type GeneralInitialization = Initialization | InputScheme | InputInfo[];
+
+export type Initializer = GeneralInitialization | (() => GeneralInitialization);
+
+export interface InputGroupProps {
+ color?: ThemeColor;
+ containerClassName?: string;
+ containerRef?: Ref<HTMLDivElement>;
+
+ inputs: Input[];
+ onChange: (index: number, value: Input["value"]) => void;
+}
+
+function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
+ const result = { ...o };
+ for (const key of Object.keys(result)) {
+ if (result[key] == null) {
+ delete result[key];
+ }
+ }
+ return result as never;
+}
+
+export type ConfirmResult =
+ | {
+ type: "ok";
+ values: InputConfirmValueDict;
+ }
+ | {
+ type: "error";
+ errors: InputErrorDict;
+ };
+
+function validate(
+ validator: Validator | null | undefined,
+ values: InputValueDict,
+ inputs: InputInfo[],
+): InputErrorDict {
+ const errors: GeneralInputErrorDict = {};
+ validator?.(values, errors, inputs);
+ return cleanObject(errors);
+}
+
+export function useInputs(options: { init: Initializer }): {
+ inputGroupProps: InputGroupProps;
+ hasError: boolean;
+ hasErrorAndDirty: boolean;
+ confirm: () => ConfirmResult;
+ setAllDisabled: (disabled: boolean) => void;
+} {
+ function initializeValue(
+ input: InputInfo,
+ value?: InputValue | null,
+ ): InputValue {
+ if (input.type === "text") {
+ return value ?? "";
+ } else if (input.type === "bool") {
+ return value ?? false;
+ } else if (input.type === "select") {
+ return value ?? input.options[0].value;
+ }
+ throw new Error("Unknown input type");
+ }
+
+ function initialize(generalInitialization: GeneralInitialization): State {
+ const initialization: Initialization = Array.isArray(generalInitialization)
+ ? { scheme: { inputs: generalInitialization } }
+ : "scheme" in generalInitialization
+ ? generalInitialization
+ : { scheme: generalInitialization };
+
+ const { scheme, dataInit } = initialization;
+ const { inputs, validator } = scheme;
+ const keys = inputs.map((input) => input.key);
+
+ if (process.env.NODE_ENV === "development") {
+ const checkKeys = (dict: Record<string, unknown> | undefined) => {
+ if (dict != null) {
+ for (const key of Object.keys(dict)) {
+ if (!keys.includes(key)) {
+ console.warn("");
+ }
+ }
+ }
+ };
+
+ checkKeys(dataInit?.values);
+ checkKeys(dataInit?.errors ?? {});
+ checkKeys(dataInit?.disabled);
+ checkKeys(dataInit?.dirties);
+ }
+
+ function clean<V>(
+ dict: Record<string, V> | null | undefined,
+ ): Record<string, NonNullable<V>> {
+ return dict != null ? cleanObject(dict) : {};
+ }
+
+ const values: InputValueDict = {};
+ const disabled: InputDisabledDict = clean(dataInit?.disabled);
+ const dirties: InputDirtyDict = clean(dataInit?.dirties);
+ const isErrorSet = dataInit?.errors != null;
+ let errors: InputErrorDict = clean(dataInit?.errors);
+
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i];
+ const { key } = input;
+
+ values[key] = initializeValue(input, dataInit?.values?.[key]);
+ }
+
+ if (isErrorSet) {
+ if (process.env.NODE_ENV === "development") {
+ console.log(
+ "You explicitly set errors (not undefined) in initializer, so validator won't run.",
+ );
+ }
+ } else {
+ errors = validate(validator, values, inputs);
+ }
+
+ return {
+ scheme,
+ data: {
+ values,
+ errors,
+ disabled,
+ dirties,
+ },
+ };
+ }
+
+ const { init } = options;
+ const initializer = typeof init === "function" ? init : () => init;
+
+ const [state, setState] = useState<State>(() => initialize(initializer()));
+
+ const { scheme, data } = state;
+ const { validator } = scheme;
+
+ function createAllBooleanDict(value: boolean): Record<string, boolean> {
+ const result: InputDirtyDict = {};
+ for (const key of scheme.inputs.map((input) => input.key)) {
+ result[key] = value;
+ }
+ return result;
+ }
+
+ const createAllDirties = () => createAllBooleanDict(true);
+
+ const componentInputs: Input[] = [];
+
+ for (let i = 0; i < scheme.inputs.length; i++) {
+ const input = scheme.inputs[i];
+ const value = data.values[input.key];
+ const error = data.errors[input.key];
+ const disabled = data.disabled[input.key] ?? false;
+ const dirty = data.dirties[input.key] ?? false;
+ const componentInput: Input = {
+ ...input,
+ value: value as never,
+ disabled,
+ error: dirty ? error : undefined,
+ };
+ componentInputs.push(componentInput);
+ }
+
+ const hasError = Object.keys(data.errors).length > 0;
+ const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]);
+
+ return {
+ inputGroupProps: {
+ inputs: componentInputs,
+ onChange: (index, value) => {
+ const input = scheme.inputs[index];
+ const { key } = input;
+ const newValues = { ...data.values, [key]: value };
+ const newDirties = { ...data.dirties, [key]: true };
+ const newErrors = validate(validator, newValues, scheme.inputs);
+ setState({
+ scheme,
+ data: {
+ ...data,
+ values: newValues,
+ errors: newErrors,
+ dirties: newDirties,
+ },
+ });
+ },
+ },
+ hasError,
+ hasErrorAndDirty: hasError && hasDirty,
+ confirm() {
+ const newDirties = createAllDirties();
+ const newErrors = validate(validator, data.values, scheme.inputs);
+
+ setState({
+ scheme,
+ data: {
+ ...data,
+ dirties: newDirties,
+ errors: newErrors,
+ },
+ });
+
+ if (Object.keys(newErrors).length !== 0) {
+ return {
+ type: "error",
+ errors: newErrors,
+ };
+ } else {
+ return {
+ type: "ok",
+ values: data.values as InputConfirmValueDict,
+ };
+ }
+ },
+ setAllDisabled(disabled: boolean) {
+ setState({
+ scheme,
+ data: {
+ ...data,
+ disabled: createAllBooleanDict(disabled),
+ },
+ });
+ },
+ };
+}
+
+export function InputGroup({
+ color,
+ inputs,
+ onChange,
+ containerRef,
+ containerClassName,
+}: InputGroupProps) {
+ const c = useC();
+
+ const id = useId();
+
+ return (
+ <div
+ ref={containerRef}
+ className={classNames(
+ "cru-input-group",
+ `cru-clickable-${color ?? "primary"}`,
+ containerClassName,
+ )}
+ >
+ {inputs.map((item, index) => {
+ const { key, type, value, label, error, helper, disabled } = item;
+
+ const getContainerClassName = (
+ ...additionalClassNames: classNames.ArgumentArray
+ ) =>
+ classNames(
+ `cru-input-container cru-input-type-${type}`,
+ error && "error",
+ ...additionalClassNames,
+ );
+
+ const changeValue = (value: InputValue) => {
+ onChange(index, value);
+ };
+
+ const inputId = `${id}-${key}`;
+
+ if (type === "text") {
+ const { password } = item;
+ return (
+ <div
+ key={key}
+ className={getContainerClassName(password && "password")}
+ >
+ {label && (
+ <label className="cru-input-label" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ )}
+ <input
+ id={inputId}
+ type={password ? "password" : "text"}
+ value={value}
+ onChange={(event) => {
+ const v = event.target.value;
+ changeValue(v);
+ }}
+ disabled={disabled}
+ />
+ {error && <div className="cru-input-error">{c(error)}</div>}
+ {helper && <div className="cru-input-helper">{c(helper)}</div>}
+ </div>
+ );
+ } else if (type === "bool") {
+ return (
+ <div key={key} className={getContainerClassName()}>
+ <input
+ id={inputId}
+ type="checkbox"
+ checked={value}
+ onChange={(event) => {
+ const v = event.currentTarget.checked;
+ changeValue(v);
+ }}
+ disabled={disabled}
+ />
+ <label className="cru-input-label-inline" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ {error && <div className="cru-input-error">{c(error)}</div>}
+ {helper && <div className="cru-input-helper">{c(helper)}</div>}
+ </div>
+ );
+ } else if (type === "select") {
+ return (
+ <div key={key} className={getContainerClassName()}>
+ <label className="cru-input-label" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ <select
+ id={inputId}
+ value={value}
+ onChange={(event) => {
+ const e = event.target.value;
+ changeValue(e);
+ }}
+ disabled={disabled}
+ >
+ {item.options.map((option) => {
+ return (
+ <option value={option.value} key={option.value}>
+ {option.icon}
+ {c(option.label)}
+ </option>
+ );
+ })}
+ </select>
+ </div>
+ );
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts
new file mode 100644
index 00000000..ca183089
--- /dev/null
+++ b/FrontEnd/src/components/input/index.ts
@@ -0,0 +1,11 @@
+export { useInputs, InputGroup } from "./InputGroup";
+
+export type {
+ InputValueDict,
+ InputErrorDict,
+ InputDirtyDict,
+ InputDisabledDict,
+ InputConfirmValueDict,
+ Validator,
+ Initializer,
+} from "./InputGroup";
diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css
new file mode 100644
index 00000000..53781834
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.css
@@ -0,0 +1,4 @@
+.cru-list-container {
+ border: 1px solid var(--cru-clickable-primary-normal-color);
+ border-radius: 5px;
+}
diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx
new file mode 100644
index 00000000..c27e67d4
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListContainer.css";
+
+function _ListContainer(
+ { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+ ref: Ref<HTMLDivElement>,
+) {
+ return (
+ <div
+ ref={ref}
+ className={classNames("cru-list-container", className)}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
+
+const ListContainer = forwardRef(_ListContainer);
+
+export default ListContainer;
diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css
new file mode 100644
index 00000000..49468bc2
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.css
@@ -0,0 +1,7 @@
+.cru-list-item-container {
+ border-bottom: 1px solid var(--cru-clickable-primary-normal-color);
+}
+
+.cru-list-item-container:last-child {
+ border-bottom: none;
+}
diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx
new file mode 100644
index 00000000..315cbd6e
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListItemContainer.css";
+
+function _ListItemContainer(
+ { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+ ref: Ref<HTMLDivElement>,
+) {
+ return (
+ <div
+ ref={ref}
+ className={classNames("cru-list-item-container", className)}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
+
+const ListItemContainer = forwardRef(_ListItemContainer);
+
+export default ListItemContainer;
diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts
new file mode 100644
index 00000000..e183f7da
--- /dev/null
+++ b/FrontEnd/src/components/list/index.ts
@@ -0,0 +1,4 @@
+import ListContainer from "./ListContainer";
+import ListItemContainer from "./ListItemContainer";
+
+export { ListContainer, ListItemContainer };
diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css
new file mode 100644
index 00000000..75734533
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.css
@@ -0,0 +1,36 @@
+.cru-menu {
+ min-width: 200px;
+}
+
+.cru-menu-item {
+ display: block;
+ font-size: 1em;
+ width: 100%;
+ padding: 0.5em 1.5em;
+ transition: all 0.5s;
+ color: var(--cru-clickable-normal-color);
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: none;
+ cursor: pointer;
+}
+
+.cru-menu-item:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-menu-item:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-menu-item:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-menu-item-icon {
+ margin-right: 1em;
+}
+
+.cru-menu-divider {
+ border-width: 0;
+ border-top: 1px solid var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx
new file mode 100644
index 00000000..1a196a69
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -0,0 +1,62 @@
+import { MouseEvent, CSSProperties } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+import Icon from "../Icon";
+
+import "./Menu.css";
+
+export type MenuItem =
+ | {
+ type: "divider";
+ }
+ | {
+ type: "button";
+ text: Text;
+ icon?: string;
+ color?: ThemeColor;
+ onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
+ };
+
+export type MenuItems = MenuItem[];
+
+export type MenuProps = {
+ items: MenuItems;
+ onItemClick?: (e: MouseEvent<HTMLButtonElement>) => void;
+ className?: string;
+ style?: CSSProperties;
+};
+
+export default function Menu({
+ items,
+ onItemClick,
+ className,
+ style,
+}: MenuProps) {
+ const c = useC();
+
+ return (
+ <div className={classNames("cru-menu", className)} style={style}>
+ {items.map((item, index) => {
+ if (item.type === "divider") {
+ return <hr key={index} className="cru-menu-divider" />;
+ } else {
+ const { text, color, icon, onClick } = item;
+ return (
+ <button
+ key={index}
+ className={`cru-menu-item cru-clickable-${color ?? "primary"}`}
+ onClick={(e) => {
+ onClick?.(e);
+ onItemClick?.(e);
+ }}
+ >
+ {icon != null && <Icon color={color} icon={icon} />}
+ {c(text)}
+ </button>
+ );
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css
new file mode 100644
index 00000000..149e0699
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.css
@@ -0,0 +1,7 @@
+.cru-popup-menu-menu-container {
+ z-index: 1040;
+ border-radius: 3px;
+ border: var(--cru-clickable-normal-color) 1.5px solid;
+ background-color: var(--cru-background-color);
+ overflow: hidden;
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx
new file mode 100644
index 00000000..7ac2abfe
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -0,0 +1,72 @@
+import { useState, CSSProperties, ReactNode } from "react";
+import classNames from "classnames";
+import { createPortal } from "react-dom";
+import { usePopper } from "react-popper";
+
+import { ThemeColor } from "../common";
+import { useClickOutside } from "../hooks";
+import Menu, { MenuItems } from "./Menu";
+
+import "./PopupMenu.css";
+
+export interface PopupMenuProps {
+ color?: ThemeColor;
+ items: MenuItems;
+ children?: ReactNode;
+ containerClassName?: string;
+ containerStyle?: CSSProperties;
+}
+
+export default function PopupMenu({
+ color,
+ items,
+ children,
+ containerClassName,
+ containerStyle,
+}: PopupMenuProps) {
+ const [show, setShow] = useState<boolean>(false);
+
+ const [referenceElement, setReferenceElement] =
+ useState<HTMLDivElement | null>(null);
+ const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
+ null,
+ );
+ const { styles, attributes } = usePopper(referenceElement, popperElement);
+
+ useClickOutside(popperElement, () => setShow(false), true);
+
+ return (
+ <div
+ ref={setReferenceElement}
+ className={classNames(
+ "cru-popup-menu-trigger-container",
+ containerClassName,
+ )}
+ style={containerStyle}
+ onClick={() => setShow(true)}
+ >
+ {children}
+ {show &&
+ createPortal(
+ <div
+ ref={setPopperElement}
+ className={`cru-popup-menu-menu-container cru-clickable-${
+ color ?? "primary"
+ }`}
+ style={styles.popper}
+ {...attributes.popper}
+ >
+ <Menu
+ items={items}
+ onItemClick={(e) => {
+ setShow(false);
+ e.stopPropagation();
+ }}
+ />
+ </div>,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ document.getElementById("portal")!,
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabBar.css b/FrontEnd/src/components/tab/TabBar.css
new file mode 100644
index 00000000..dc6970c7
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabBar.css
@@ -0,0 +1,32 @@
+.cru-tab-bar {
+ display: flex;
+}
+
+.cru-tab-bar-tab-area {
+ display: flex;
+ align-items: center;
+ border: var(--cru-clickable-normal-color) 1.6px solid;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.cru-tab-bar-item {
+ color: var(--cru-text-minor-color);
+ transition: all 0.2s;
+ cursor: pointer;
+ padding: 0.3em 1em;
+}
+
+.cru-tab-bar-item:hover {
+ color: var(--cru-clickable-normal-color);
+}
+
+.cru-tab-bar-item.active {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-primary-color);
+}
+
+.cru-tab-bar-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/TabBar.tsx b/FrontEnd/src/components/tab/TabBar.tsx
new file mode 100644
index 00000000..601f664d
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabBar.tsx
@@ -0,0 +1,69 @@
+import { ReactNode } from "react";
+import { Link } from "react-router-dom";
+import classNames from "classnames";
+
+import { Text, ThemeColor, useC } from "../common";
+
+import "./TabBar.css";
+
+export interface Tab {
+ name: string;
+ text: Text;
+ link?: string;
+ onClick?: () => void;
+}
+
+export interface TabsProps {
+ activeTabName?: string;
+ tabs: Tab[];
+ color?: ThemeColor;
+ actions?: ReactNode;
+ dense?: boolean;
+ className?: string;
+}
+
+export default function TabBar(props: TabsProps) {
+ const { tabs, color, activeTabName, className, dense, actions } = props;
+
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ "cru-tab-bar",
+ dense && "dense",
+ `cru-clickable-${color ?? "primary"}`,
+ className,
+ )}
+ >
+ <div className="cru-tab-bar-tab-area">
+ {tabs.map((tab) => {
+ const { name, text, link, onClick } = tab;
+
+ const active = activeTabName === name;
+ const className = classNames("cru-tab-bar-item", active && "active");
+
+ if (link != null) {
+ return (
+ <Link
+ key={name}
+ to={link}
+ onClick={onClick}
+ className={className}
+ >
+ {c(text)}
+ </Link>
+ );
+ } else {
+ return (
+ <span key={name} onClick={onClick} className={className}>
+ {c(text)}
+ </span>
+ );
+ }
+ })}
+ </div>
+ <div className="cru-tab-bar-action-area">{actions}</div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabPages.css b/FrontEnd/src/components/tab/TabPages.css
new file mode 100644
index 00000000..c07d042e
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.css
@@ -0,0 +1,3 @@
+.cru-tab-page-container {
+ padding-top: 0.5em;
+}
diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx
new file mode 100644
index 00000000..ab45ffdf
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.tsx
@@ -0,0 +1,61 @@
+import { ReactNode, useState } from "react";
+import classNames from "classnames";
+
+import { Text, UiLogicError } from "../common";
+
+import Tabs from "./TabBar";
+
+import "./TabPages.css";
+
+interface TabPage {
+ name: string;
+ text: Text;
+ page: ReactNode;
+}
+
+interface TabPagesProps {
+ pages: TabPage[];
+ actions?: ReactNode;
+ dense?: boolean;
+ className?: string;
+ tabBarClassName?: string;
+ pageContainerClassName?: string;
+}
+
+export default function TabPages({
+ pages,
+ actions,
+ dense,
+ className,
+ tabBarClassName,
+ pageContainerClassName,
+}: TabPagesProps) {
+ const [tab, setTab] = useState<string>(pages[0].name);
+
+ const currentPage = pages.find((p) => p.name === tab);
+
+ if (currentPage == null) throw new UiLogicError();
+
+ return (
+ <div className={className}>
+ <Tabs
+ tabs={pages.map((page) => ({
+ name: page.name,
+ text: page.text,
+ onClick: () => {
+ setTab(page.name);
+ },
+ }))}
+ dense={dense}
+ activeTabName={tab}
+ className={tabBarClassName}
+ actions={actions}
+ />
+ <div
+ className={classNames("cru-tab-page-container", pageContainerClassName)}
+ >
+ {currentPage.page}
+ </div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/index.ts b/FrontEnd/src/components/tab/index.ts
new file mode 100644
index 00000000..43d545cc
--- /dev/null
+++ b/FrontEnd/src/components/tab/index.ts
@@ -0,0 +1,2 @@
+export { default as TabBar } from "./TabBar";
+export { default as TabPages } from "./TabPages";
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
new file mode 100644
index 00000000..68dd780f
--- /dev/null
+++ b/FrontEnd/src/components/theme.css
@@ -0,0 +1,201 @@
+:root {
+ --cru-default-font-family: 'Segoe UI', 'DengXian', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ --cru-page-padding: 1em 2em;
+
+ --cru-border-radius: 4px;
+ --cru-card-border-radius: 4px;
+}
+
+/* theme colors */
+:root {
+ --cru-primary-color: hsl(210 100% 50%);
+ --cru-secondary-color: hsl(30 100% 50%);
+ --cru-create-color: hsl(120 100% 25%);
+ --cru-danger-color: hsl(0 100% 50%);
+ --cru-warn-color: #e4a700;
+}
+
+.cru-theme-primary {
+ --cru-theme-color: var(--cru-primary-color);
+}
+
+.cru-theme-secondary {
+ --cru-theme-color: var(--cru-secondary-color);
+}
+
+.cru-theme-create {
+ --cru-theme-color: var(--cru-create-color);
+}
+
+.cru-theme-danger {
+ --cru-theme-color: var(--cru-danger-color);
+}
+
+/* common colors */
+:root {
+ --cru-background-color: hsl(0 0% 100%);
+ --cru-container-background-color: hsl(0 0% 97%);
+ --cru-text-major-color: hsl(0 0% 0%);
+ --cru-text-minor-color: hsl(0 0% 38%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-background-color: hsl(0 0% 0%);
+ --cru-container-background-color: hsl(0 0% 2%);
+ --cru-text-major-color: hsl(0 0% 100%);
+ --cru-text-minor-color: hsl(0 0% 85%);
+ }
+}
+
+:root {
+ --cru-body-background-color: var(--cru-background-color);
+}
+
+/* dialog color */
+
+:root {
+ --cru-dialog-overlay-color: hsl(0 0% 100%);
+ --cru-dialog-container-background-color: hsl(0 0% 100%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-dialog-overlay-color: hsl(0 0% 0%);
+ --cru-dialog-container-background-color: hsl(0 0% 0%);
+ }
+}
+
+/* clickable color */
+:root {
+ --cru-clickable-primary-normal-color: var(--cru-primary-color);
+ --cru-clickable-primary-hover-color: hsl(210 100% 60%);
+ --cru-clickable-primary-focus-color: hsl(210 100% 60%);
+ --cru-clickable-primary-active-color: hsl(210 100% 70%);
+ --cru-clickable-secondary-normal-color: var(--cru-secondary-color);
+ --cru-clickable-secondary-hover-color: hsl(30 100% 60%);
+ --cru-clickable-secondary-focus-color: hsl(30 100% 60%);
+ --cru-clickable-secondary-active-color: hsl(30 100% 70%);
+ --cru-clickable-create-normal-color: var(--cru-create-color);
+ --cru-clickable-create-hover-color: hsl(120 100% 35%);
+ --cru-clickable-create-focus-color: hsl(120 100% 35%);
+ --cru-clickable-create-active-color: hsl(120 100% 35%);
+ --cru-clickable-danger-normal-color: var(--cru-danger-color);
+ --cru-clickable-danger-hover-color: hsl(0 100% 60%);
+ --cru-clickable-danger-focus-color: hsl(0 100% 60%);
+ --cru-clickable-danger-active-color: hsl(0 100% 70%);
+ --cru-clickable-grayscale-normal-color: hsl(0 0% 100%);
+ --cru-clickable-grayscale-hover-color: hsl(0 0% 92%);
+ --cru-clickable-grayscale-focus-color: hsl(0 0% 92%);
+ --cru-clickable-grayscale-active-color: hsl(0 0% 88%);
+ --cru-clickable-light-normal-color: hsl(0 0% 100%);
+ --cru-clickable-light-hover-color: hsl(0 0% 92%);
+ --cru-clickable-light-focus-color: hsl(0 0% 92%);
+ --cru-clickable-light-active-color: hsl(0 0% 88%);
+ --cru-clickable-minor-normal-color: hsl(0 0% 30%);
+ --cru-clickable-minor-hover-color: hsl(0 0% 40%);
+ --cru-clickable-minor-focus-color: hsl(0 0% 40%);
+ --cru-clickable-minor-active-color: hsl(0 0% 45%);
+ --cru-clickable-disabled-color: hsl(0 0% 50%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-clickable-minor-normal-color: hsl(0 0% 74%);
+ --cru-clickable-minor-hover-color: hsl(0 0% 82%);
+ --cru-clickable-minor-focus-color: hsl(0 0% 82%);
+ --cru-clickable-minor-active-color: hsl(0 0% 90%);
+ --cru-clickable-grayscale-normal-color: hsl(0 0% 0%);
+ --cru-clickable-grayscale-hover-color: hsl(0 0% 10%);
+ --cru-clickable-grayscale-focus-color: hsl(0 0% 10%);
+ --cru-clickable-grayscale-active-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-clickable-primary {
+ --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-primary-active-color);
+}
+
+.cru-clickable-secondary {
+ --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-secondary-active-color);
+}
+
+.cru-clickable-create {
+ --cru-clickable-normal-color: var(--cru-clickable-create-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-create-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-create-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-create-active-color);
+}
+
+.cru-clickable-danger {
+ --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-danger-active-color);
+}
+
+.cru-clickable-grayscale {
+ --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-clickable-light {
+ --cru-clickable-normal-color: var(--cru-clickable-light-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-light-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-light-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-light-active-color);
+}
+
+.cru-clickable-minor {
+ --cru-clickable-normal-color: var(--cru-clickable-minor-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-minor-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-minor-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-minor-active-color);
+}
+
+/* button colors */
+:root {
+ /* push button colors */
+ --cru-push-button-text-color: #ffffff;
+ --cru-push-button-disabled-text-color: hsl(0 0% 80%);
+}
+
+/* Card colors */
+:root {
+ --cru-card-background-primary-color: hsl(210 100% 50%);
+ --cru-card-border-primary-color: hsl(210 100% 50%);
+ --cru-card-background-secondary-color: hsl(30 100% 50%);
+ --cru-card-border-secondary-color: hsl(30 100% 50%);
+ --cru-card-background-create-color: hsl(120 100% 25%);
+ --cru-card-border-create-color: hsl(120 100% 25%);
+ --cru-card-background-danger-color: hsl(0 100% 50%);
+ --cru-card-border-danger-color: hsl(0 100% 50%);
+}
+
+.cru-card-primary {
+ --cru-card-background-color: var(--cru-card-background-primary-color);
+ --cru-card-border-color: var(--cru-card-border-primary-color)
+}
+
+.cru-card-secondary {
+ --cru-card-background-color: var(--cru-card-background-secondary-color);
+ --cru-card-border-color: var(--cru-card-border-secondary-color)
+}
+
+.cru-card-create {
+ --cru-card-background-color: var(--cru-card-background-create-color);
+ --cru-card-border-color: var(--cru-card-border-create-color)
+}
+
+.cru-card-danger {
+ --cru-card-background-color: var(--cru-card-background-danger-color);
+ --cru-card-border-color: var(--cru-card-border-danger-color)
+}
diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx
new file mode 100644
index 00000000..8671f2d8
--- /dev/null
+++ b/FrontEnd/src/components/user/UserAvatar.tsx
@@ -0,0 +1,22 @@
+import { Ref, ComponentPropsWithoutRef } from "react";
+
+import { getHttpUserClient } from "~src/http/user";
+
+export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> {
+ username: string;
+ imgRef?: Ref<HTMLImageElement> | null;
+}
+
+export default function UserAvatar({
+ username,
+ imgRef,
+ ...otherProps
+}: UserAvatarProps) {
+ return (
+ <img
+ ref={imgRef}
+ src={getHttpUserClient().generateAvatarUrl(username)}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/http/bookmark.ts b/FrontEnd/src/http/bookmark.ts
index 40e121cc..311f9a0f 100644
--- a/FrontEnd/src/http/bookmark.ts
+++ b/FrontEnd/src/http/bookmark.ts
@@ -1,4 +1,4 @@
-import { withQuery } from "@/utilities/url";
+import { withQuery } from "~src/utilities/url";
import { axios, apiBaseUrl, extractResponseData, Page } from "./common";
diff --git a/FrontEnd/src/http/timeline.ts b/FrontEnd/src/http/timeline.ts
index 401ae116..255c786e 100644
--- a/FrontEnd/src/http/timeline.ts
+++ b/FrontEnd/src/http/timeline.ts
@@ -1,4 +1,4 @@
-import { withQuery } from "@/utilities/url";
+import { withQuery } from "~src/utilities/url";
import {
axios,
diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css
index 419ccb8c..f779297b 100644
--- a/FrontEnd/src/index.css
+++ b/FrontEnd/src/index.css
@@ -1,13 +1,5 @@
-@import "npm:bootstrap/dist/css/bootstrap-reboot.css";
-@import "npm:bootstrap/dist/css/bootstrap-grid.css";
@import "npm:bootstrap-icons/font/bootstrap-icons.css";
-@import "./views/common/index.css";
-
-body {
- background: var(--cru-background-color);
-}
-
small {
line-height: 1.2;
}
@@ -24,7 +16,7 @@ small {
textarea {
resize: none;
outline: none;
- border-color: var(--cru-background-2-color);
+ border-color: var(--cru-bg-2-color);
}
textarea:hover {
@@ -35,22 +27,6 @@ textarea:focus {
border-color: var(--cru-primary-color);
}
-input:not([type="checkbox"]):not([type="radio"]) {
- resize: none;
- outline: none;
- border: 1px solid;
- transition: all 0.5s;
- border-color: var(--cru-background-2-color);
-}
-
-input:hover:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-r2-color);
-}
-
-input:focus:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-color);
-}
-
.white-space-no-wrap {
white-space: nowrap;
}
@@ -75,6 +51,7 @@ i {
.markdown-container {
white-space: initial;
}
+
.markdown-container img {
max-height: 200px;
max-width: 100%;
@@ -82,4 +59,4 @@ i {
a {
text-decoration: none;
-}
+} \ No newline at end of file
diff --git a/FrontEnd/src/index.tsx b/FrontEnd/src/index.tsx
index ba61d357..64d39cd5 100644
--- a/FrontEnd/src/index.tsx
+++ b/FrontEnd/src/index.tsx
@@ -1,14 +1,9 @@
-import "regenerator-runtime";
-import "core-js/modules/es.promise";
-import "core-js/modules/es.array.iterator";
-
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "./i18n";
-import "./palette";
import App from "./App";
@@ -18,5 +13,5 @@ const root = createRoot(container!);
root.render(
<StrictMode>
<App />
- </StrictMode>
+ </StrictMode>,
);
diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json
index 21c826bd..a7e4efe5 100644
--- a/FrontEnd/src/locales/en/translation.json
+++ b/FrontEnd/src/locales/en/translation.json
@@ -86,7 +86,7 @@
"ok": "OK!",
"processing": "Processing...",
"success": "Success!",
- "error": "An error occured."
+ "error": "An error occurred."
},
"timeline": {
"messageCantSee": "Sorry, you are not allowed to see this timeline.😅",
@@ -176,7 +176,7 @@
"noAccount": "If you don't have an account and know a register code, then click <1>here</1> to register."
},
"settings": {
- "subheaders": {
+ "subheader": {
"account": "Account",
"customization": "Customization"
},
@@ -186,7 +186,6 @@
"logout": "Log out this account",
"changeAvatar": "Change avatar",
"changeNickname": "Change nickname",
- "changeBookmarkVisibility": "Change bookmark visibility",
"myRegisterCode": "My register code:",
"myRegisterCodeDesc": "Click to create a new register code.",
"renewRegisterCode": "Renew Register Code",
@@ -224,23 +223,11 @@
}
},
"about": {
- "author": {
- "title": "Site Developer",
- "name": "Name: ",
- "introduction": "Introduction: ",
- "introductionContent": "A programmer coding based on coincidence",
- "links": "Links: "
- },
- "site": {
- "title": "Site Information",
- "content": "The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.",
- "repo": "GitHub Repo"
- },
"credits": {
"title": "Credits",
- "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.",
- "frontend": "Frontend: ",
- "backend": "Backend: "
+ "content": "Timeline stands on shoulders of giants. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.",
+ "frontend": "Frontend",
+ "backend": "Backend"
}
},
"admin": {
diff --git a/FrontEnd/src/locales/zh/translation.json b/FrontEnd/src/locales/zh/translation.json
index b7212128..8a2f628f 100644
--- a/FrontEnd/src/locales/zh/translation.json
+++ b/FrontEnd/src/locales/zh/translation.json
@@ -176,7 +176,7 @@
"noAccount": "如果你没有账号但有一个注册码,请点击<1>这里</1>注册账号。"
},
"settings": {
- "subheaders": {
+ "subheader": {
"account": "账户",
"customization": "个性化"
},
diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/migrating/admin/Admin.tsx
index 986c36b4..986c36b4 100644
--- a/FrontEnd/src/views/admin/Admin.tsx
+++ b/FrontEnd/src/migrating/admin/Admin.tsx
diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/migrating/admin/AdminNav.tsx
index b7385e5c..b7385e5c 100644
--- a/FrontEnd/src/views/admin/AdminNav.tsx
+++ b/FrontEnd/src/migrating/admin/AdminNav.tsx
diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/migrating/admin/MoreAdmin.tsx
index d49d211f..d49d211f 100644
--- a/FrontEnd/src/views/admin/MoreAdmin.tsx
+++ b/FrontEnd/src/migrating/admin/MoreAdmin.tsx
diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/migrating/admin/UserAdmin.tsx
index d5179bf5..08560c87 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/migrating/admin/UserAdmin.tsx
@@ -1,3 +1,6 @@
+// eslint-disable
+// @ts-nocheck
+
import { useState, useEffect } from "react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -5,9 +8,7 @@ import classnames from "classnames";
import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user";
-import OperationDialog, {
- OperationDialogBoolInput,
-} from "../common/dialog/OperationDialog";
+import OperationDialog from "../common/dialog/OperationDialog";
import Button from "../common/button/Button";
import Spinner from "../common/Spinner";
import FlatButton from "../common/button/FlatButton";
@@ -21,18 +22,15 @@ const CreateUserDialog: React.FC<{
return (
<OperationDialog
title="admin:user.dialog.create.title"
- themeColor="success"
inputPrompt="admin:user.dialog.create.prompt"
- inputScheme={
- [
- { type: "text", label: "admin:user.username" },
- { type: "text", label: "admin:user.password" },
- ] as const
- }
- onProcess={([username, password]) =>
+ inputs={[
+ { key: "username", type: "text", label: "admin:user.username" },
+ { key: "password", type: "text", label: "admin:user.password" },
+ ]}
+ onProcess={({ username, password }) =>
getHttpUserClient().post({
- username,
- password,
+ username: username as string,
+ password: password as string,
})
}
onClose={close}
@@ -80,13 +78,12 @@ const UserModifyDialog: React.FC<{
open={open}
onClose={close}
title="admin:user.dialog.modify.title"
- themeColor="danger"
- inputPrompt={() => (
+ inputPromptNode={
<Trans i18nKey="admin:user.dialog.modify.prompt">
0<UsernameLabel>{user.username}</UsernameLabel>2
</Trans>
- )}
- inputScheme={
+ }
+ inputs={
[
{
type: "text",
@@ -120,7 +117,7 @@ const UserPermissionModifyDialog: React.FC<{
onSuccess: () => void;
}> = ({ open, close, user, onSuccess }) => {
const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
- (permission) => user.permissions.includes(permission)
+ (permission) => user.permissions.includes(permission),
);
return (
@@ -139,7 +136,7 @@ const UserPermissionModifyDialog: React.FC<{
type: "bool",
label: { type: "custom", value: permission },
initValue: oldPermissionBoolList[index],
- })
+ }),
)}
onProcess={async (newPermissionBoolList): Promise<boolean[]> => {
for (let index = 0; index < kUserPermissionList.length; index++) {
@@ -150,12 +147,12 @@ const UserPermissionModifyDialog: React.FC<{
if (newValue) {
await getHttpUserClient().putUserPermission(
user.username,
- permission
+ permission,
);
} else {
await getHttpUserClient().deleteUserPermission(
user.username,
- permission
+ permission,
);
}
}
diff --git a/FrontEnd/src/views/admin/index.css b/FrontEnd/src/migrating/admin/index.css
index 17e24586..17e24586 100644
--- a/FrontEnd/src/views/admin/index.css
+++ b/FrontEnd/src/migrating/admin/index.css
diff --git a/FrontEnd/src/views/admin/index.tsx b/FrontEnd/src/migrating/admin/index.tsx
index 0467711d..0467711d 100644
--- a/FrontEnd/src/views/admin/index.tsx
+++ b/FrontEnd/src/migrating/admin/index.tsx
diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx
index a8be2c29..f1c3fc6a 100644
--- a/FrontEnd/src/views/center/CenterBoards.tsx
+++ b/FrontEnd/src/migrating/center/CenterBoards.tsx
@@ -1,13 +1,13 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
-import { highlightTimelineUsername } from "@/common";
+import { highlightTimelineUsername } from "~src/common";
-import { pushAlert } from "@/services/alert";
-import { useUserLoggedIn } from "@/services/user";
+import { pushAlert } from "~src/services/alert";
+import { useUserLoggedIn } from "~src/services/user";
-import { getHttpTimelineClient } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
+import { getHttpTimelineClient } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
import TimelineBoard from "./TimelineBoard";
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx
index b3ccdf8c..8f4401bc 100644
--- a/FrontEnd/src/views/center/TimelineBoard.tsx
+++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx
@@ -2,7 +2,7 @@ import * as React from "react";
import classnames from "classnames";
import { Link } from "react-router-dom";
-import { TimelineBookmark } from "@/http/bookmark";
+import { TimelineBookmark } from "~src/http/bookmark";
import TimelineLogo from "../common/TimelineLogo";
import LoadFailReload from "../common/LoadFailReload";
diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
index 63742936..340a08fe 100644
--- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
@@ -1,11 +1,11 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
-import { validateTimelineName } from "@/services/timeline";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { validateTimelineName } from "~src/services/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
import OperationDialog from "../common/dialog/OperationDialog";
-import { useUserLoggedIn } from "@/services/user";
+import { useUserLoggedIn } from "~src/services/user";
interface TimelineCreateDialogProps {
open: boolean;
diff --git a/FrontEnd/src/views/center/index.css b/FrontEnd/src/migrating/center/index.css
index a779ff90..a779ff90 100644
--- a/FrontEnd/src/views/center/index.css
+++ b/FrontEnd/src/migrating/center/index.css
diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx
index 77af2c20..11502517 100644
--- a/FrontEnd/src/views/center/index.tsx
+++ b/FrontEnd/src/migrating/center/index.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
-import { useUserLoggedIn } from "@/services/user";
+import { useUserLoggedIn } from "~src/services/user";
import SearchInput from "../common/SearchInput";
import Button from "../common/button/Button";
diff --git a/FrontEnd/src/pages/404/index.css b/FrontEnd/src/pages/404/index.css
new file mode 100644
index 00000000..cf5efbe7
--- /dev/null
+++ b/FrontEnd/src/pages/404/index.css
@@ -0,0 +1,7 @@
+.page-404 {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-danger-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/404/index.tsx b/FrontEnd/src/pages/404/index.tsx
new file mode 100644
index 00000000..751a450b
--- /dev/null
+++ b/FrontEnd/src/pages/404/index.tsx
@@ -0,0 +1,5 @@
+import "./index.css";
+
+export default function NotFoundPage() {
+ return <div className="page-404">Ah-oh, 404!</div>;
+}
diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css
new file mode 100644
index 00000000..1ce7a7c8
--- /dev/null
+++ b/FrontEnd/src/pages/about/index.css
@@ -0,0 +1,7 @@
+.about-page {
+ line-height: 1.5;
+}
+
+.about-page a {
+ color: var(--cru-surface-on-color);
+}
diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx
new file mode 100644
index 00000000..bce64322
--- /dev/null
+++ b/FrontEnd/src/pages/about/index.tsx
@@ -0,0 +1,87 @@
+import "./index.css";
+
+import { useC } from "~src/common";
+import Page from "~src/components/Page";
+
+interface Credit {
+ name: string;
+ url: string;
+}
+
+type Credits = Credit[];
+
+const frontendCredits: Credits = [
+ {
+ name: "react.js",
+ url: "https://reactjs.org",
+ },
+ {
+ name: "typescript",
+ url: "https://www.typescriptlang.org",
+ },
+ {
+ name: "bootstrap",
+ url: "https://getbootstrap.com",
+ },
+ {
+ name: "parcel.js",
+ url: "https://parceljs.org",
+ },
+ {
+ name: "eslint",
+ url: "https://eslint.org",
+ },
+ {
+ name: "prettier",
+ url: "https://prettier.io",
+ },
+];
+
+const backendCredits: Credits = [
+ {
+ name: "ASP.NET Core",
+ url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core",
+ },
+ { name: "sqlite", url: "https://sqlite.org" },
+ {
+ name: "ImageSharp",
+ url: "https://github.com/SixLabors/ImageSharp",
+ },
+];
+
+export default function AboutPage() {
+ const c = useC();
+
+ return (
+ <Page className="about-page">
+ <h2>{c("about.credits.title")}</h2>
+ <p>{c("about.credits.content")}</p>
+ <h3>{c("about.credits.frontend")}</h3>
+ <ul>
+ {frontendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ <h3>{c("about.credits.backend")}</h3>
+ <ul>
+ {backendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css
new file mode 100644
index 00000000..16601d8a
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.css
@@ -0,0 +1,13 @@
+.home-page {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-primary-color);
+}
+
+.home-page-2 {
+ width: 100%;
+ text-align: center;
+ margin-top: 2em;
+}
diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx
new file mode 100644
index 00000000..c29a1ca5
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.tsx
@@ -0,0 +1,12 @@
+import "./index.css";
+
+export default function HomePage() {
+ return (
+ <>
+ <div className="home-page">Be patient! I&apos;m working on this...</div>
+ <div className="home-page-2">
+ Have a look at <a href="/crupest">here</a>!
+ </div>
+ </>
+ );
+}
diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css
new file mode 100644
index 00000000..08e43c22
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.css
@@ -0,0 +1,7 @@
+.loading-page {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx
new file mode 100644
index 00000000..29d27adc
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.tsx
@@ -0,0 +1,11 @@
+import Spinner from "~src/components/Spinner";
+
+import "./index.css";
+
+export default function LoadingPage() {
+ return (
+ <div className="loading-page">
+ <Spinner />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css
new file mode 100644
index 00000000..ef97359c
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.css
@@ -0,0 +1,14 @@
+.login-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.login-page-welcome {
+ text-align: center;
+ font-size: 2em;
+}
+
+.login-page-error {
+ color: var(--cru-danger-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
new file mode 100644
index 00000000..39ea3831
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -0,0 +1,127 @@
+import { useState, useEffect } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { useUser, userService } from "~src/services/user";
+
+import { useC } from "~src/components/common";
+import LoadingButton from "~src/components/button/LoadingButton";
+import { InputGroup, useInputs } from "~src/components/input/InputGroup";
+import Page from "~src/components/Page";
+
+import "./index.css";
+
+export default function LoginPage() {
+ const c = useC();
+
+ const user = useUser();
+
+ const navigate = useNavigate();
+
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+ useInputs({
+ init: {
+ scheme: {
+ inputs: [
+ {
+ key: "username",
+ type: "text",
+ label: "user.username",
+ },
+ {
+ key: "password",
+ type: "text",
+ label: "user.password",
+ password: true,
+ },
+ {
+ key: "rememberMe",
+ type: "bool",
+ label: "user.rememberMe",
+ },
+ ],
+ validator: ({ username, password }, errors) => {
+ if (username === "") {
+ errors["username"] = "login.emptyUsername";
+ }
+ if (password === "") {
+ errors["password"] = "login.emptyPassword";
+ }
+ },
+ },
+ dataInit: {},
+ },
+ });
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => navigate("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [navigate, user]);
+
+ if (user != null) {
+ return <p>{c("login.alreadyLogin")}</p>;
+ }
+
+ const submit = (): void => {
+ const confirmResult = confirm();
+ if (confirmResult.type === "ok") {
+ const { username, password, rememberMe } = confirmResult.values;
+ setAllDisabled(true);
+ setProcess(true);
+ userService
+ .login(
+ {
+ username: username as string,
+ password: password as string,
+ },
+ rememberMe as boolean,
+ )
+ .then(
+ () => {
+ if (history.length === 0) {
+ navigate("/");
+ } else {
+ navigate(-1);
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setAllDisabled(false);
+ setError(e.message);
+ },
+ );
+ }
+ };
+
+ return (
+ <Page className="login-page">
+ <div className="login-page-container">
+ <div className="login-page-welcome">{c("welcome")}</div>
+ <InputGroup {...inputGroupProps} />
+ {error ? <p className="login-page-error">{c(error)}</p> : null}
+ <div className="login-page-button-row">
+ <LoadingButton
+ loading={process}
+ onClick={(e) => {
+ submit();
+ e.preventDefault();
+ }}
+ disabled={hasErrorAndDirty}
+ >
+ {c("user.login")}
+ </LoadingButton>
+ </div>
+ <Trans i18nKey="login.noAccount">
+ 0<Link to="/register">1</Link>2
+ </Trans>
+ </div>
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/views/register/index.css b/FrontEnd/src/pages/register/index.css
index c0078b28..c0078b28 100644
--- a/FrontEnd/src/views/register/index.css
+++ b/FrontEnd/src/pages/register/index.css
diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx
new file mode 100644
index 00000000..fa25c2c2
--- /dev/null
+++ b/FrontEnd/src/pages/register/index.tsx
@@ -0,0 +1,130 @@
+import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { userService, useUser } from "~src/services/user";
+
+import { LoadingButton } from "~src/components/button";
+import { useInputs, InputGroup } from "~src/components/input/InputGroup";
+
+import "./index.css";
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+
+ const { t } = useTranslation();
+
+ const user = useUser();
+
+ const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+ useInputs({
+ init: {
+ scheme: {
+ inputs: [
+ {
+ key: "username",
+ type: "text",
+ label: "register.username",
+ },
+ {
+ key: "password",
+ type: "text",
+ label: "register.password",
+ password: true,
+ },
+ {
+ key: "confirmPassword",
+ type: "text",
+ label: "register.confirmPassword",
+ password: true,
+ },
+ {
+ key: "registerCode",
+
+ type: "text",
+ label: "register.registerCode",
+ },
+ ],
+ validator: (
+ { username, password, confirmPassword, registerCode },
+ errors,
+ ) => {
+ if (username === "") {
+ errors["username"] = "register.error.usernameEmpty";
+ }
+ if (password === "") {
+ errors["password"] = "register.error.passwordEmpty";
+ }
+ if (confirmPassword !== password) {
+ errors["confirmPassword"] = "register.error.confirmPasswordWrong";
+ }
+ if (registerCode === "") {
+ errors["registerCode"] = "register.error.registerCodeEmpty";
+ }
+ },
+ },
+ dataInit: {},
+ },
+ });
+
+ const [process, setProcess] = useState<boolean>(false);
+ const [resultError, setResultError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (user != null) {
+ navigate("/");
+ }
+ }, [navigate, user]);
+
+ return (
+ <div className="container register-page">
+ <InputGroup {...inputGroupProps} />
+ {resultError && <div className="cru-color-danger">{t(resultError)}</div>}
+ <LoadingButton
+ text="register.register"
+ loading={process}
+ disabled={hasErrorAndDirty}
+ onClick={() => {
+ const confirmResult = confirm();
+ if (confirmResult.type === "ok") {
+ const { username, password, registerCode } = confirmResult.values;
+ setProcess(true);
+ setAllDisabled(true);
+ void getHttpTokenClient()
+ .register({
+ username: username as string,
+ password: password as string,
+ registerCode: registerCode as string,
+ })
+ .then(
+ () => {
+ void userService
+ .login(
+ {
+ username: username as string,
+ password: password as string,
+ },
+ true,
+ )
+ .then(() => {
+ navigate("/");
+ });
+ },
+ (error) => {
+ if (error instanceof HttpBadRequestError) {
+ setResultError("register.error.registerCodeInvalid");
+ } else {
+ setResultError("error.network");
+ }
+ setProcess(false);
+ setAllDisabled(false);
+ },
+ );
+ }
+ }}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
new file mode 100644
index 00000000..c9eb8011
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
@@ -0,0 +1,22 @@
+.change-avatar-dialog-prompt {
+ margin: 0.5em 0;
+}
+
+.change-avatar-dialog-prompt.success {
+ color: var(--cru-create-color);
+}
+
+.change-avatar-dialog-prompt.error {
+ color: var(--cru-danger-color);
+}
+
+.change-avatar-cropper {
+ max-width: 400px;
+ max-height: 400px;
+}
+
+.change-avatar-preview-image {
+ min-width: 50%;
+ max-width: 100%;
+ max-height: 300px;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..0df10411
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -0,0 +1,276 @@
+import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react";
+
+import { useC, Text, UiLogicError } from "~src/common";
+
+import { useUser } from "~src/services/user";
+
+import { getHttpUserClient } from "~src/http/user";
+
+import { ImageCropper, useImageCrop } from "~src/components/ImageCropper";
+import BlobImage from "~src/components/BlobImage";
+import { ButtonRowV2 } from "~src/components/button";
+import {
+ Dialog,
+ DialogContainer,
+ useDialogController,
+} from "~src/components/dialog";
+
+import "./ChangeAvatarDialog.css";
+
+export default function ChangeAvatarDialog() {
+ const c = useC();
+
+ const user = useUser();
+
+ const controller = useDialogController();
+
+ type State =
+ | "select"
+ | "crop"
+ | "process-crop"
+ | "preview"
+ | "uploading"
+ | "success"
+ | "error";
+ const [state, setState] = useState<State>("select");
+
+ const [file, setFile] = useState<File | null>(null);
+
+ const { canCrop, crop, imageCropperProps } = useImageCrop(file, {
+ constraint: {
+ ratio: 1,
+ },
+ });
+
+ const [resultBlob, setResultBlob] = useState<Blob | null>(null);
+ const [message, setMessage] = useState<Text>(
+ "settings.dialogChangeAvatar.prompt.select",
+ );
+
+ const close = controller.closeDialog;
+
+ const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ };
+
+ const onCropNext = () => {
+ if (!canCrop) {
+ throw new UiLogicError();
+ }
+
+ setState("process-crop");
+
+ void crop().then((b) => {
+ setState("preview");
+ setResultBlob(b);
+ });
+ };
+
+ const onCropPrevious = () => {
+ setFile(null);
+ setState("select");
+ };
+
+ const onPreviewPrevious = () => {
+ setState("crop");
+ };
+
+ const upload = () => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ if (user == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ controller.setCanSwitchDialog(false);
+ getHttpUserClient()
+ .putAvatar(user.username, resultBlob)
+ .then(
+ () => {
+ setState("success");
+ },
+ () => {
+ setState("error");
+ setMessage("operationDialog.error");
+ },
+ )
+ .finally(() => {
+ controller.setCanSwitchDialog(true);
+ });
+ };
+
+ const cancelButton = {
+ key: "cancel",
+ text: "operationDialog.cancel",
+ onClick: close,
+ } as const;
+
+ const createPreviousButton = (onClick: () => void) =>
+ ({
+ key: "previous",
+ text: "operationDialog.previousStep",
+ onClick,
+ }) as const;
+
+ const buttonsMap: Record<
+ State,
+ ComponentPropsWithoutRef<typeof ButtonRowV2>["buttons"]
+ > = {
+ select: [
+ cancelButton,
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: () => setState("crop"),
+ disabled: file == null,
+ },
+ ],
+ crop: [
+ cancelButton,
+ createPreviousButton(onCropPrevious),
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: onCropNext,
+ disabled: !canCrop,
+ },
+ ],
+ "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)],
+ preview: [
+ cancelButton,
+ createPreviousButton(onPreviewPrevious),
+ {
+ key: "upload",
+ action: "major",
+ text: "settings.dialogChangeAvatar.upload",
+ onClick: upload,
+ },
+ ],
+ uploading: [],
+ success: [
+ {
+ key: "ok",
+ text: "operationDialog.ok",
+ color: "create",
+ onClick: close,
+ },
+ ],
+ error: [
+ cancelButton,
+ {
+ key: "retry",
+ action: "major",
+ text: "operationDialog.retry",
+ onClick: upload,
+ },
+ ],
+ };
+
+ return (
+ <Dialog>
+ <DialogContainer
+ title="settings.dialogChangeAvatar.title"
+ titleColor="primary"
+ buttonsV2={buttonsMap[state]}
+ >
+ {(() => {
+ if (state === "select") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.select")}
+ </div>
+ <input
+ className="change-avatar-select-input"
+ type="file"
+ accept="image/*"
+ onChange={onSelectFile}
+ />
+ </div>
+ );
+ } else if (state === "crop") {
+ if (file == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <div className="change-avatar-dialog-container">
+ <ImageCropper
+ {...imageCropperProps}
+ containerClassName="change-avatar-cropper"
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.crop")}
+ </div>
+ </div>
+ );
+ } else if (state === "process-crop") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.processingCrop")}
+ </div>
+ </div>
+ );
+ } else if (state === "preview") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ alt={
+ c("settings.dialogChangeAvatar.previewImgAlt") ?? undefined
+ }
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.preview")}
+ </div>
+ </div>
+ );
+ } else if (state === "uploading") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.uploading")}
+ </div>
+ </div>
+ );
+ } else if (state === "success") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt success">
+ {c("operationDialog.success")}
+ </div>
+ </div>
+ );
+ } else {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt error">
+ {c(message)}
+ </div>
+ </div>
+ );
+ }
+ })()}
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..912f554f
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -0,0 +1,26 @@
+import { getHttpUserClient } from "~src/http/user";
+import { useUserLoggedIn } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export default function ChangeNicknameDialog() {
+ const user = useUserLoggedIn();
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangeNickname.title"
+ inputs={[
+ {
+ key: "newNickname",
+ type: "text",
+ label: "settings.dialogChangeNickname.inputLabel",
+ },
+ ]}
+ onProcess={({ newNickname }) => {
+ return getHttpUserClient().patch(user.username, {
+ nickname: newNickname,
+ });
+ }}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
new file mode 100644
index 00000000..c3111ac8
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { userService } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export function ChangePasswordDialog() {
+ const navigate = useNavigate();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangePassword.title"
+ color="danger"
+ inputPrompt="settings.dialogChangePassword.prompt"
+ inputs={{
+ inputs: [
+ {
+ key: "oldPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputOldPassword",
+ password: true,
+ },
+ {
+ key: "newPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputNewPassword",
+ password: true,
+ },
+ {
+ key: "retypedNewPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputRetypeNewPassword",
+ password: true,
+ },
+ ],
+ validator: (
+ { oldPassword, newPassword, retypedNewPassword },
+ errors,
+ ) => {
+ if (oldPassword === "") {
+ errors["oldPassword"] =
+ "settings.dialogChangePassword.errorEmptyOldPassword";
+ }
+ if (newPassword === "") {
+ errors["newPassword"] =
+ "settings.dialogChangePassword.errorEmptyNewPassword";
+ }
+ if (retypedNewPassword !== newPassword) {
+ errors["retypedNewPassword"] =
+ "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ },
+ }}
+ onProcess={async ({ oldPassword, newPassword }) => {
+ await userService.changePassword(oldPassword, newPassword);
+ setRedirect(true);
+ }}
+ onSuccessAndClose={() => {
+ if (redirect) {
+ navigate("/login");
+ }
+ }}
+ />
+ );
+}
+
+export default ChangePasswordDialog;
diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css
new file mode 100644
index 00000000..19e7cff4
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.css
@@ -0,0 +1,76 @@
+.setting-section {
+ padding: 1em 0;
+ margin: 1em 0;
+}
+
+.setting-section-title {
+ padding: 0 1em;
+}
+
+.setting-section-item-area {
+ margin-top: 1em;
+ border-top: 1px solid var(--cru-primary-color);
+}
+
+.setting-item-container {
+ padding: 0.5em 1em;
+ transition: background-color 0.3s;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border-bottom: 1px solid var(--cru-clickable-grayscale-active-color);
+ display: flex;
+ align-items: center;
+}
+
+.setting-item-container:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.setting-item-container:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.setting-item-container:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.setting-item-container.danger {
+ color: var(--cru-danger-color);
+}
+
+.setting-item-label-sub {
+ color: var(--cru-text-minor-color);
+}
+
+.setting-item-value-area {
+ margin-left: auto;
+}
+
+.setting-item-container.setting-type-button {
+ cursor: pointer;
+}
+
+.register-code {
+ background: var(--cru-text-major-color);
+ color: var(--cru-background-color);
+ border-radius: 3px;
+ padding: 0.2em;
+ cursor: pointer;
+}
+
+@media (max-width: 576) {
+ .setting-item-container.setting-type-select {
+ flex-direction: column;
+ }
+
+ .setting-item-container.setting-type-select .setting-item-value-area {
+ margin-top: 1em;
+ }
+
+ .register-code-setting-item {
+ flex-direction: column;
+ }
+
+ .register-code-setting-item .register-code {
+ margin-top: 1em;
+ }
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
new file mode 100644
index 00000000..88ab5cb2
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -0,0 +1,297 @@
+import {
+ useState,
+ useEffect,
+ ReactNode,
+ ComponentPropsWithoutRef,
+} from "react";
+import { useTranslation } from "react-i18next"; // For change language.
+import { useNavigate } from "react-router-dom";
+import classNames from "classnames";
+
+import { useUser, userService } from "~src/services/user";
+import { getHttpUserClient } from "~src/http/user";
+
+import { useC, Text } from "~src/common";
+
+import { pushAlert } from "~src/components/alert";
+import {
+ useDialog,
+ DialogProvider,
+ ConfirmDialog,
+} from "~src/components/dialog";
+import Card from "~src/components/Card";
+import Spinner from "~src/components/Spinner";
+import Page from "~src/components/Page";
+
+import ChangePasswordDialog from "./ChangePasswordDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+
+import "./index.css";
+
+interface SettingSectionProps
+ extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
+ title: Text;
+ children?: ReactNode;
+}
+
+function SettingSection({
+ title,
+ className,
+ children,
+ ...otherProps
+}: SettingSectionProps) {
+ const c = useC();
+
+ return (
+ <Card className={classNames(className, "setting-section")} {...otherProps}>
+ <h2 className="setting-section-title">{c(title)}</h2>
+ <div className="setting-section-item-area">{children}</div>
+ </Card>
+ );
+}
+
+interface SettingItemContainerProps
+ extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
+ title: Text;
+ description?: Text;
+ danger?: boolean;
+ extraClassName?: string;
+}
+
+function SettingItemContainer({
+ title,
+ description,
+ danger,
+ extraClassName,
+ className,
+ children,
+ ...otherProps
+}: SettingItemContainerProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ className,
+ "setting-item-container",
+ danger && "danger",
+ extraClassName,
+ )}
+ {...otherProps}
+ >
+ <div className="setting-item-label-area">
+ <div className="setting-item-label-title">{c(title)}</div>
+ <small className="setting-item-label-sub">{c(description)}</small>
+ </div>
+ <div className="setting-item-value-area">{children}</div>
+ </div>
+ );
+}
+
+type ButtonSettingItemProps = Omit<SettingItemContainerProps, "extraClassName">;
+
+function ButtonSettingItem(props: ButtonSettingItemProps) {
+ return (
+ <SettingItemContainer extraClassName="setting-type-button" {...props} />
+ );
+}
+
+interface SelectSettingItemProps
+ extends Omit<SettingItemContainerProps, "onSelect" | "extraClassName"> {
+ options: {
+ value: string;
+ label: Text;
+ }[];
+ value?: string | null;
+ onSelect: (value: string) => void;
+}
+
+function SelectSettingsItem({
+ options,
+ value,
+ onSelect,
+ ...extraProps
+}: SelectSettingItemProps) {
+ const c = useC();
+
+ return (
+ <SettingItemContainer extraClassName="setting-type-select" {...extraProps}>
+ {value == null ? (
+ <Spinner />
+ ) : (
+ <select
+ className="select-setting-item-select"
+ value={value}
+ onChange={(e) => {
+ onSelect(e.target.value);
+ }}
+ >
+ {options.map(({ value, label }) => (
+ <option key={value} value={value}>
+ {c(label)}
+ </option>
+ ))}
+ </select>
+ )}
+ </SettingItemContainer>
+ );
+}
+
+function RegisterCodeSettingItem() {
+ const user = useUser();
+
+ // undefined: loading
+ const [registerCode, setRegisterCode] = useState<undefined | null | string>();
+
+ const { controller, createDialogSwitch } = useDialog({
+ confirm: (
+ <ConfirmDialog
+ title="settings.renewRegisterCode"
+ body="settings.renewRegisterCodeDesc"
+ onConfirm={() => {
+ if (user == null) throw new Error();
+ void getHttpUserClient()
+ .renewRegisterCode(user.username)
+ .then(() => {
+ setRegisterCode(undefined);
+ });
+ }}
+ />
+ ),
+ });
+
+ useEffect(() => {
+ setRegisterCode(undefined);
+ }, [user]);
+
+ useEffect(() => {
+ if (user != null && registerCode === undefined) {
+ void getHttpUserClient()
+ .getRegisterCode(user.username)
+ .then((code) => {
+ setRegisterCode(code.registerCode ?? null);
+ });
+ }
+ }, [user, registerCode]);
+
+ return (
+ <>
+ <SettingItemContainer
+ title="settings.myRegisterCode"
+ description="settings.myRegisterCodeDesc"
+ className="register-code-setting-item"
+ onClick={createDialogSwitch("confirm")}
+ >
+ {registerCode === undefined ? (
+ <Spinner />
+ ) : registerCode === null ? (
+ <span>Noop</span>
+ ) : (
+ <code
+ className="register-code"
+ onClick={(event) => {
+ void navigator.clipboard.writeText(registerCode).then(() => {
+ pushAlert({
+ color: "create",
+ message: "settings.myRegisterCodeCopied",
+ });
+ });
+ event.stopPropagation();
+ }}
+ >
+ {registerCode}
+ </code>
+ )}
+ </SettingItemContainer>
+ <DialogProvider controller={controller} />
+ </>
+ );
+}
+
+function LanguageChangeSettingItem() {
+ const { i18n } = useTranslation();
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <SelectSettingsItem
+ title="settings.languagePrimary"
+ description="settings.languageSecondary"
+ options={[
+ {
+ value: "zh",
+ label: {
+ type: "custom",
+ value: "中文",
+ },
+ },
+ {
+ value: "en",
+ label: {
+ type: "custom",
+ value: "English",
+ },
+ },
+ ]}
+ value={language}
+ onSelect={(value) => {
+ void i18n.changeLanguage(value);
+ }}
+ />
+ );
+}
+
+export default function SettingPage() {
+ const user = useUser();
+ const navigate = useNavigate();
+
+ const { controller, createDialogSwitch } = useDialog({
+ "change-nickname": <ChangeNicknameDialog />,
+ "change-avatar": <ChangeAvatarDialog />,
+ "change-password": <ChangePasswordDialog />,
+ logout: (
+ <ConfirmDialog
+ title="settings.dialogConfirmLogout.title"
+ body="settings.dialogConfirmLogout.prompt"
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ navigate("/");
+ });
+ }}
+ />
+ ),
+ });
+
+ return (
+ <Page noTopPadding>
+ {user ? (
+ <SettingSection title="settings.subheader.account">
+ <RegisterCodeSettingItem />
+ <ButtonSettingItem
+ title="settings.changeAvatar"
+ onClick={createDialogSwitch("change-avatar")}
+ />
+ <ButtonSettingItem
+ title="settings.changeNickname"
+ onClick={createDialogSwitch("change-nickname")}
+ />
+ <ButtonSettingItem
+ title="settings.changePassword"
+ onClick={createDialogSwitch("change-password")}
+ danger
+ />
+ <ButtonSettingItem
+ title="settings.logout"
+ onClick={createDialogSwitch("logout")}
+ danger
+ />
+ </SettingSection>
+ ) : null}
+ <SettingSection title="settings.subheader.customization">
+ <LanguageChangeSettingItem />
+ </SettingSection>
+ <DialogProvider controller={controller} />
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
index 7fe83b9b..0a6979cb 100644
--- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
@@ -2,8 +2,8 @@
font-size: 0.8em;
border-radius: 5px;
padding: 0.1em 1em;
- background-color: #eaf2ff;
}
+
.connection-status-badge::before {
width: 10px;
height: 10px;
@@ -12,25 +12,27 @@
content: "";
margin-right: 0.6em;
}
+
.connection-status-badge.success {
- color: #006100;
+ color: var(--cru-create-color);
}
+
.connection-status-badge.success::before {
- background-color: #006100;
+ background-color: var(--cru-create-color);
}
.connection-status-badge.warning {
- color: #e4a700;
+ color: var(--cru-warn-color);
}
.connection-status-badge.warning::before {
- background-color: #e4a700;
+ background-color: var(--cru-warn-color);
}
.connection-status-badge.danger {
- color: #fd1616;
+ color: var(--cru-danger-color);
}
.connection-status-badge.danger::before {
- background-color: #fd1616;
+ background-color: var(--cru-danger-color);
}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
index 2b820454..63990878 100644
--- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -1,14 +1,13 @@
-import * as React from "react";
-import classnames from "classnames";
+import classNames from "classnames";
import { HubConnectionState } from "@microsoft/signalr";
-import { useTranslation } from "react-i18next";
+
+import { useC }from '~/src/components/common';
import "./ConnectionStatusBadge.css";
-export interface ConnectionStatusBadgeProps {
+interface ConnectionStatusBadgeProps {
status: HubConnectionState;
className?: string;
- style?: React.CSSProperties;
}
const classNameMap: Record<HubConnectionState, string> = {
@@ -19,23 +18,19 @@ const classNameMap: Record<HubConnectionState, string> = {
Reconnecting: "warning",
};
-const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
- const { status, className, style } = props;
-
- const { t } = useTranslation();
+export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) {
+ const c = useC();
return (
<div
- className={classnames(
+ className={classNames(
"connection-status-badge",
classNameMap[status],
className
)}
- style={style}
>
- {t(`connectionState.${status}`)}
+ {c(`connectionState.${status}`)}
</div>
);
};
-export default ConnectionStatusBadge;
diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css
new file mode 100644
index 00000000..db25eda0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.css
@@ -0,0 +1,42 @@
+.timeline-container {
+ --timeline-background-color: hsl(0, 0%, 95%);
+ --timeline-shadow-color: hsla(0, 0%, 0%, 0.5);
+ --timeline-card-shadow: 2px 1px 10px -2px var(--timeline-shadow-color);
+ --timeline-post-card-background-color: hsl(0, 0%, 100%);
+ --timeline-post-card-shadow: 0px 0px 11px -2px var(--timeline-shadow-color);
+ --timeline-post-card-border-radius: 10px;
+ --timeline-post-text-color: hsl(0, 0%, 0%);
+ --timeline-datetime-label-background-color: hsl(0, 0%, 30%);
+}
+
+@media (prefers-color-scheme: dark) {
+ .timeline-container {
+ --timeline-background-color: hsl(0, 0%, 0%);
+ --timeline-post-card-background-color: hsl(0, 0%, 15%);
+ --timeline-post-card-shadow: none;
+ }
+}
+
+.timeline {
+ z-index: 0;
+ position: relative;
+ width: 100%;
+ padding-top: 10px;
+ background: var(--timeline-background-color);
+}
+
+.timeline-sync-state-badge {
+ font-size: 0.8em;
+ padding: 3px 8px;
+ border-radius: 5px;
+ background: #e8fbff;
+}
+
+.timeline-sync-state-badge-pin {
+ display: inline-block;
+ width: 0.4em;
+ height: 0.4em;
+ border-radius: 50%;
+ vertical-align: middle;
+ margin-right: 0.6em;
+}
diff --git a/FrontEnd/src/views/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index 3a7fbd00..32cbf8c8 100644
--- a/FrontEnd/src/views/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -1,69 +1,63 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useScrollToBottom } from "@/utilities/hooks";
+import { useState, useEffect } from "react";
+import classNames from "classnames";
import { HubConnectionState } from "@microsoft/signalr";
import {
HttpForbiddenError,
HttpNetworkError,
HttpNotFoundError,
-} from "@/http/common";
+} from "~src/http/common";
import {
getHttpTimelineClient,
HttpTimelineInfo,
HttpTimelinePostInfo,
-} from "@/http/timeline";
+} from "~src/http/timeline";
-import { useUser } from "@/services/user";
-import { getTimelinePostUpdate$ } from "@/services/timeline";
+import { getTimelinePostUpdate$ } from "~src/services/timeline";
-import TimelinePostListView from "./TimelinePostListView";
-import TimelineEmptyItem from "./TimelineEmptyItem";
-import TimelineLoading from "./TimelineLoading";
-import TimelinePostEdit from "./TimelinePostEdit";
-import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin";
-import TimelineCard from "./TimelineCard";
+import { useScrollToBottom } from "~src/components/hooks";
+
+import TimelinePostList from "./TimelinePostList";
+import TimelineInfoCard from "./TimelineInfoCard";
+import TimelinePostEdit from "./edit/TimelinePostCreateView";
import "./Timeline.css";
export interface TimelineProps {
className?: string;
- style?: React.CSSProperties;
timelineOwner: string;
timelineName: string;
}
-const Timeline: React.FC<TimelineProps> = (props) => {
- const { timelineOwner, timelineName, className, style } = props;
-
- const user = useUser();
+export function Timeline(props: TimelineProps) {
+ const { timelineOwner, timelineName, className } = props;
- const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
- const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null);
- const [signalrState, setSignalrState] = React.useState<HubConnectionState>(
- HubConnectionState.Connecting
+ const [timeline, setTimeline] = useState<HttpTimelineInfo | null>(null);
+ const [posts, setPosts] = useState<HttpTimelinePostInfo[] | null>(null);
+ const [signalrState, setSignalrState] = useState<HubConnectionState>(
+ HubConnectionState.Connecting,
);
- const [error, setError] = React.useState<
+ const [error, setError] = useState<
"offline" | "forbid" | "notfound" | "error" | null
>(null);
- const [currentPage, setCurrentPage] = React.useState(1);
- const [totalPage, setTotalPage] = React.useState(0);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPage, setTotalPage] = useState(0);
- const [timelineReloadKey, setTimelineReloadKey] = React.useState(0);
- const [postsReloadKey, setPostsReloadKey] = React.useState(0);
+ const [timelineReloadKey, setTimelineReloadKey] = useState(0);
+ const [postsReloadKey, setPostsReloadKey] = useState(0);
const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1);
const updatePosts = (): void => setPostsReloadKey((o) => o + 1);
- React.useEffect(() => {
+ useEffect(() => {
setTimeline(null);
setPosts(null);
setError(null);
setSignalrState(HubConnectionState.Connecting);
}, [timelineOwner, timelineName]);
- React.useEffect(() => {
+ useEffect(() => {
getHttpTimelineClient()
.getTimeline(timelineOwner, timelineName)
.then(
@@ -81,17 +75,17 @@ const Timeline: React.FC<TimelineProps> = (props) => {
console.error(error);
setError("error");
}
- }
+ },
);
}, [timelineOwner, timelineName, timelineReloadKey]);
- React.useEffect(() => {
+ useEffect(() => {
getHttpTimelineClient()
.listPost(timelineOwner, timelineName, 1)
.then(
(page) => {
setPosts(
- page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted)
+ page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted),
);
setTotalPage(page.totalPageCount);
},
@@ -106,14 +100,14 @@ const Timeline: React.FC<TimelineProps> = (props) => {
console.error(error);
setError("error");
}
- }
+ },
);
}, [timelineOwner, timelineName, postsReloadKey]);
- React.useEffect(() => {
+ useEffect(() => {
const timelinePostUpdate$ = getTimelinePostUpdate$(
timelineOwner,
- timelineName
+ timelineName,
);
const subscription = timelinePostUpdate$.subscribe(({ update, state }) => {
if (update) {
@@ -134,7 +128,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
.then(
(page) => {
const ps = page.items.filter(
- (p): p is HttpTimelinePostInfo => !p.deleted
+ (p): p is HttpTimelinePostInfo => !p.deleted,
);
setPosts((old) => [...(old ?? []), ...ps]);
},
@@ -149,59 +143,38 @@ const Timeline: React.FC<TimelineProps> = (props) => {
console.error(error);
setError("error");
}
- }
+ },
);
}, currentPage < totalPage);
if (error === "offline") {
- return (
- <div className={className} style={style}>
- Offline.
- </div>
- );
+ return <div className={className}>Offline.</div>;
} else if (error === "notfound") {
- return (
- <div className={className} style={style}>
- Not exist.
- </div>
- );
+ return <div className={className}>Not exist.</div>;
} else if (error === "forbid") {
- return (
- <div className={className} style={style}>
- Forbid.
- </div>
- );
+ return <div className={className}>Forbid.</div>;
} else if (error === "error") {
- return (
- <div className={className} style={style}>
- Error.
- </div>
- );
+ return <div className={className}>Error.</div>;
}
return (
- <>
- {timeline == null && posts == null && <TimelineLoading />}
+ <div className="timeline-container">
{timeline && (
- <TimelineCard
- className="timeline-card"
+ <TimelineInfoCard
timeline={timeline}
connectionStatus={signalrState}
onReload={updateTimeline}
/>
)}
{posts && (
- <div style={style} className={classnames("timeline", className)}>
- <TimelineEmptyItem className="timeline-top" height={50} />
- {timeline?.postable ? (
+ <div className={classNames("timeline", className)}>
+ {timeline?.postable && (
<TimelinePostEdit timeline={timeline} onPosted={updatePosts} />
- ) : user == null ? (
- <TimelinePostEditNoLogin />
- ) : null}
- <TimelinePostListView posts={posts} onReload={updatePosts} />
+ )}
+ <TimelinePostList posts={posts} onReload={updatePosts} />
</div>
)}
- </>
+ </div>
);
-};
+}
export default Timeline;
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.css b/FrontEnd/src/pages/timeline/TimelineDateLabel.css
new file mode 100644
index 00000000..47a4cb44
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.css
@@ -0,0 +1,9 @@
+.timeline-post-date-badge {
+ display: inline-block;
+ padding: 0.2em 0.5em;
+ border-radius: 0.4em;
+ background: var(--timeline-datetime-label-background-color);
+ color: white;
+ font-size: 0.8em;
+ margin-left: 5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
new file mode 100644
index 00000000..eaadcc1a
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
@@ -0,0 +1,13 @@
+import TimelinePostContainer from "./TimelinePostContainer";
+
+import "./TimelineDateLabel.css";
+
+export default function TimelineDateLabel({ date }: { date: Date }) {
+ return (
+ <TimelinePostContainer>
+ <div className="timeline-post-date-badge">
+ {date.toLocaleDateString()}
+ </div>
+ </TimelinePostContainer>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..d1af364b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,54 @@
+import { useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
+
+import { OperationDialog } from "~src/components/dialog";
+
+interface TimelineDeleteDialog {
+ timeline: HttpTimelineInfo;
+}
+
+export default function TimelineDeleteDialog({ timeline }: TimelineDeleteDialog) {
+ const navigate = useNavigate();
+
+ return (
+ <OperationDialog
+ title="timeline.deleteDialog.title"
+ color="danger"
+ inputPromptNode={
+ <Trans
+ i18nKey="timeline.deleteDialog.inputPrompt"
+ values={{ name: timeline.nameV2 }}
+ >
+ 0<code>1</code>2
+ </Trans>
+ }
+ inputs={{
+ inputs: [
+ {
+ key: "name",
+ type: "text",
+ label: "",
+ },
+ ],
+ validator: ({ name }, errors) => {
+ if (name !== timeline.nameV2) {
+ errors.name = "timeline.deleteDialog.notMatch";
+ }
+ },
+ }}
+ onProcess={() => {
+ return getHttpTimelineClient().deleteTimeline(
+ timeline.owner.username,
+ timeline.nameV2,
+ );
+ }}
+ onSuccessAndClose={() => {
+ navigate("/", { replace: true });
+ }}
+ />
+ );
+};
+
+TimelineDeleteDialog;
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
new file mode 100644
index 00000000..afcb6409
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
@@ -0,0 +1,63 @@
+.timeline-card {
+ position: fixed;
+ z-index: 1029;
+ top: 56px;
+ right: 0;
+ margin: 0.5em;
+ padding: 0.5em;
+ box-shadow: var(--timeline-card-shadow);
+}
+
+@media (min-width: 576px) {
+ .timeline-card-expand {
+ min-width: 400px;
+ }
+}
+
+.timeline-card-title {
+ display: inline-block;
+ vertical-align: middle;
+ color: var(--cru-text-major-color);
+ margin: 0.5em 1em;
+}
+
+.timeline-card-title-name {
+ margin-inline-start: 1em;
+ color: var(--cru-text-minor-color);
+}
+
+.timeline-card-user {
+ display: flex;
+ align-items: center;
+ margin: 0 1em 0.5em;
+}
+
+.timeline-card-user-avatar {
+ width: 2em;
+ height: 2em;
+ border-radius: 50%;
+}
+
+.timeline-card-user-nickname {
+ margin-inline: 0.6em;
+}
+
+.timeline-card-description {
+ margin: 0 1em 0.5em;
+}
+
+.timeline-card-top-right-area {
+ float: right;
+ display: flex;
+ align-items: center;
+ margin: 0 1em;
+}
+
+.timeline-card-buttons {
+ display: flex;
+ justify-content: end;
+}
+
+.timeline-card-button {
+ margin: 0 0.2em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..2bc40877
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,208 @@
+import { useState } from "react";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { useUser } from "~src/services/user";
+
+import { HttpTimelineInfo } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
+
+import { pushAlert } from "~src/components/alert";
+import { useMobile } from "~src/components/hooks";
+import { IconButton } from "~src/components/button";
+import {
+ Dialog,
+ FullPageDialog,
+ DialogProvider,
+ useDialog,
+} from "~src/components/dialog";
+import UserAvatar from "~src/components/user/UserAvatar";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import Card from "~src/components/Card";
+
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import TimelineMember from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+
+import "./TimelineInfoCard.css";
+
+function CollapseButton({
+ collapse,
+ onClick,
+ className,
+}: {
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+}) {
+ return (
+ <IconButton
+ color="primary"
+ icon={collapse ? "info-circle" : "x-circle"}
+ onClick={onClick}
+ className={className}
+ />
+ );
+}
+
+interface TimelineInfoCardProps {
+ timeline: HttpTimelineInfo;
+ connectionStatus: HubConnectionState;
+ onReload: () => void;
+}
+
+function TimelineInfoContent({
+ timeline,
+ onReload,
+}: Omit<TimelineInfoCardProps, "connectionStatus">) {
+ const user = useUser();
+
+ const { controller, createDialogSwitch } = useDialog({
+ member: (
+ <Dialog>
+ <TimelineMember timeline={timeline} onChange={onReload} />
+ </Dialog>
+ ),
+ property: (
+ <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} />
+ ),
+ delete: <TimelineDeleteDialog timeline={timeline} />,
+ });
+
+ return (
+ <div>
+ <h3 className="timeline-card-title">
+ {timeline.title}
+ <small className="timeline-card-title-name">{timeline.nameV2}</small>
+ </h3>
+ <div className="timeline-card-user">
+ <UserAvatar
+ username={timeline.owner.username}
+ className="timeline-card-user-avatar"
+ />
+ <span className="timeline-card-user-nickname">
+ {timeline.owner.nickname}
+ </span>
+ <small className="timeline-card-user-username">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="timeline-card-description">{timeline.description}</p>
+ <div className="timeline-card-buttons">
+ {user && (
+ <IconButton
+ icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
+ color="primary"
+ className="timeline-card-button"
+ onClick={() => {
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "post"](
+ user.username,
+ timeline.owner.username,
+ timeline.nameV2,
+ )
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ color: "danger",
+ });
+ });
+ }}
+ />
+ )}
+ <IconButton
+ icon="people"
+ color="primary"
+ className="timeline-card-button"
+ onClick={createDialogSwitch("member")}
+ />
+ {timeline.manageable && (
+ <PopupMenu
+ items={[
+ {
+ type: "button",
+ text: "timeline.manageItem.property",
+ onClick: createDialogSwitch("property"),
+ },
+ { type: "divider" },
+ {
+ type: "button",
+ onClick: createDialogSwitch("delete"),
+ color: "danger",
+ text: "timeline.manageItem.delete",
+ },
+ ]}
+ containerClassName="d-inline"
+ >
+ <IconButton
+ color="primary"
+ className="timeline-card-button"
+ icon="three-dots-vertical"
+ />
+ </PopupMenu>
+ )}
+ </div>
+ <DialogProvider controller={controller} />
+ </div>
+ );
+}
+
+export default function TimelineInfoCard(props: TimelineInfoCardProps) {
+ const { timeline, connectionStatus, onReload } = props;
+
+ const [collapse, setCollapse] = useState(true);
+
+ const isMobile = useMobile((mobile) => {
+ if (!mobile) {
+ switchDialog(null);
+ } else {
+ setCollapse(true);
+ }
+ });
+
+ const { controller, switchDialog } = useDialog(
+ {
+ "full-page": (
+ <FullPageDialog>
+ <TimelineInfoContent timeline={timeline} onReload={onReload} />
+ </FullPageDialog>
+ ),
+ },
+ {
+ onClose: {
+ "full-page": () => {
+ setCollapse(true);
+ },
+ },
+ },
+ );
+
+ return (
+ <Card
+ color="secondary"
+ className={`timeline-card timeline-card-${
+ collapse ? "collapse" : "expand"
+ }`}
+ >
+ <div className="timeline-card-top-right-area">
+ <ConnectionStatusBadge status={connectionStatus} />
+ <CollapseButton
+ collapse={collapse}
+ onClick={() => {
+ const open = collapse;
+ setCollapse(!open);
+ if (isMobile && open) {
+ switchDialog("full-page");
+ }
+ }}
+ />
+ </div>
+ {!collapse && !isMobile && (
+ <TimelineInfoContent timeline={timeline} onReload={onReload} />
+ )}
+ <DialogProvider controller={controller} />
+ </Card>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css
new file mode 100644
index 00000000..3ad74c57
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.css
@@ -0,0 +1,20 @@
+.timeline-member-item {
+ align-items: center;
+ display: flex;
+ padding: 0.6em;
+}
+
+.timeline-member-avatar {
+ height: 50px;
+ width: 50px;
+ border-radius: 50%;
+}
+
+.timeline-member-info {
+ margin-left: 1em;
+ margin-right: auto;
+}
+
+.timeline-member-user-search {
+ margin-top: 1em;
+}
diff --git a/FrontEnd/src/views/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx
index aaafd173..0812016f 100644
--- a/FrontEnd/src/views/timeline/TimelineMember.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx
@@ -1,55 +1,59 @@
import { useState } from "react";
-import * as React from "react";
import { useTranslation } from "react-i18next";
-import { convertI18nText, I18nText } from "@/common";
+import { convertI18nText, I18nText } from "~src/common";
-import { HttpUser } from "@/http/user";
-import { getHttpSearchClient } from "@/http/search";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { HttpUser } from "~src/http/user";
+import { getHttpSearchClient } from "~src/http/search";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
-import SearchInput from "../common/SearchInput";
-import UserAvatar from "../common/user/UserAvatar";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
+import SearchInput from "~src/components/SearchInput";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { IconButton } from "~src/components/button";
+import { ListContainer, ListItemContainer } from "~src/components/list";
import "./TimelineMember.css";
-const TimelineMemberItem: React.FC<{
+function TimelineMemberItem({
+ user,
+ add,
+ onAction,
+}: {
user: HttpUser;
add?: boolean;
onAction?: (username: string) => void;
-}> = ({ user, add, onAction }) => {
+}) {
return (
- <div className="container timeline-member-item">
- <div className="row">
- <div className="col col-auto">
- <UserAvatar username={user.username} className="cru-avatar small" />
- </div>
- <div className="col">
- <div className="row">{user.nickname}</div>
- <small className="row">{"@" + user.username}</small>
- </div>
- {onAction ? (
- <div className="col col-auto">
- <Button
- text={`timeline.member.${add ? "add" : "remove"}`}
- color={add ? "success" : "danger"}
- onClick={() => {
- onAction(user.username);
- }}
- />
- </div>
- ) : null}
+ <ListItemContainer className="timeline-member-item">
+ <UserAvatar username={user.username} className="timeline-member-avatar" />
+ <div className="timeline-member-info">
+ <div className="timeline-member-nickname">{user.nickname}</div>
+ <small className="timeline-member-username">
+ {"@" + user.username}
+ </small>
</div>
- </div>
+ {onAction ? (
+ <div className="timeline-member-action">
+ <IconButton
+ icon={add ? "plus-lg" : "trash"}
+ color={add ? "create" : "danger"}
+ onClick={() => {
+ onAction(user.username);
+ }}
+ />
+ </div>
+ ) : null}
+ </ListItemContainer>
);
-};
+}
-const TimelineMemberUserSearch: React.FC<{
+function TimelineMemberUserSearch({
+ timeline,
+ onChange,
+}: {
timeline: HttpTimelineInfo;
onChange: () => void;
-}> = ({ timeline, onChange }) => {
+}) {
const { t } = useTranslation();
const [userSearchText, setUserSearchText] = useState<string>("");
@@ -64,9 +68,9 @@ const TimelineMemberUserSearch: React.FC<{
>({ type: "init" });
return (
- <>
+ <div className="timeline-member-user-search">
<SearchInput
- className="mt-3"
+ className=""
value={userSearchText}
onChange={(v) => {
setUserSearchText(v);
@@ -88,8 +92,8 @@ const TimelineMemberUserSearch: React.FC<{
users = users.filter(
(user) =>
timeline.members.findIndex(
- (m) => m.username === user.username
- ) === -1 && timeline.owner.username !== user.username
+ (m) => m.username === user.username,
+ ) === -1 && timeline.owner.username !== user.username,
);
setUserSearchState({ type: "users", data: users });
},
@@ -98,7 +102,7 @@ const TimelineMemberUserSearch: React.FC<{
type: "error",
data: { type: "custom", value: String(e) },
});
- }
+ },
);
}}
/>
@@ -109,7 +113,7 @@ const TimelineMemberUserSearch: React.FC<{
return <div>{t("timeline.member.noUserAvailableToAdd")}</div>;
} else {
return (
- <div className="mt-2">
+ <div className="">
{users.map((user) => (
<TimelineMemberItem
key={user.username}
@@ -120,7 +124,7 @@ const TimelineMemberUserSearch: React.FC<{
.memberPut(
timeline.owner.username,
timeline.nameV2,
- user.username
+ user.username,
)
.then(() => {
setUserSearchText("");
@@ -141,22 +145,22 @@ const TimelineMemberUserSearch: React.FC<{
);
}
})()}
- </>
+ </div>
);
-};
+}
-export interface TimelineMemberProps {
+interface TimelineMemberProps {
timeline: HttpTimelineInfo;
onChange: () => void;
}
-const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
+export default function TimelineMember(props: TimelineMemberProps) {
const { timeline, onChange } = props;
const members = [timeline.owner, ...timeline.members];
return (
<div className="container px-4 py-3">
- <div>
+ <ListContainer>
{members.map((member, index) => (
<TimelineMemberItem
key={member.username}
@@ -168,7 +172,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
.memberDelete(
timeline.owner.username,
timeline.nameV2,
- member.username
+ member.username,
)
.then(onChange);
}
@@ -176,27 +180,10 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
}
/>
))}
- </div>
+ </ListContainer>
{timeline.manageable ? (
<TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
) : null}
</div>
);
-};
-
-export default TimelineMember;
-
-export interface TimelineMemberDialogProps extends TimelineMemberProps {
- open: boolean;
- onClose: () => void;
}
-
-export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
- props
-) => {
- return (
- <Dialog open={props.open} onClose={props.onClose}>
- <TimelineMember {...props} />
- </Dialog>
- );
-};
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.css b/FrontEnd/src/pages/timeline/TimelinePostCard.css
new file mode 100644
index 00000000..f60610c0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.css
@@ -0,0 +1,9 @@
+.timeline-post-card {
+ padding: 1em 1em 1em 3em;
+ background-color: var(--timeline-post-card-background-color);
+ box-shadow: var(--timeline-post-card-shadow);
+ border-radius: var(--timeline-post-card-border-radius);
+ border: none;
+ position: relative;
+ z-index: 1;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
new file mode 100644
index 00000000..d3fd3215
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from "react";
+import classNames from "classnames";
+
+import Card from "~src/components/Card";
+
+import "./TimelinePostCard.css";
+
+interface TimelinePostCardProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+export default function TimelinePostCard({
+ className,
+ children,
+}: TimelinePostCardProps) {
+ return (
+ <Card color="primary" className={classNames("timeline-post-card", className)}>
+ {children}
+ </Card>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.css b/FrontEnd/src/pages/timeline/TimelinePostContainer.css
new file mode 100644
index 00000000..a12f70b1
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.css
@@ -0,0 +1,3 @@
+.timeline-post-container {
+ padding: 0.5em 1em;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx
new file mode 100644
index 00000000..9dc211b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from "react";
+import classNames from "classnames";
+
+import "./TimelinePostContainer.css";
+
+interface TimelinePostContainerProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+export default function TimelinePostContainer({
+ className,
+ children,
+}: TimelinePostContainerProps) {
+ return (
+ <div className={classNames("timeline-post-container", className)}>
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.css b/FrontEnd/src/pages/timeline/TimelinePostList.css
new file mode 100644
index 00000000..bd575554
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.css
@@ -0,0 +1,10 @@
+.timeline-post-timeline {
+ position: absolute;
+ left: 2.5em;
+ width: 1em;
+ top: 0;
+ bottom: 0;
+ background-color: var(--timeline-post-line-color);
+ box-shadow: var(--timeline-post-line-shadow);
+ z-index: -1;
+} \ No newline at end of file
diff --git a/FrontEnd/src/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
index f878b004..66262ccd 100644
--- a/FrontEnd/src/views/timeline/TimelinePostListView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
@@ -1,11 +1,12 @@
-import { Fragment } from "react";
-import * as React from "react";
+import { useMemo, Fragment } from "react";
-import { HttpTimelinePostInfo } from "@/http/timeline";
+import { HttpTimelinePostInfo } from "~src/http/timeline";
import TimelinePostView from "./TimelinePostView";
import TimelineDateLabel from "./TimelineDateLabel";
+import "./TimelinePostList.css";
+
function dateEqual(left: Date, right: Date): boolean {
return (
left.getDate() == right.getDate() &&
@@ -14,15 +15,15 @@ function dateEqual(left: Date, right: Date): boolean {
);
}
-export interface TimelinePostListViewProps {
+interface TimelinePostListViewProps {
posts: HttpTimelinePostInfo[];
onReload: () => void;
}
-const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
+export default function TimelinePostList(props: TimelinePostListViewProps) {
const { posts, onReload } = props;
- const groupedPosts = React.useMemo<
+ const groupedPosts = useMemo<
{
date: Date;
posts: (HttpTimelinePostInfo & { index: number })[];
@@ -51,7 +52,7 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
}, [posts]);
return (
- <>
+ <div>
{groupedPosts.map((group) => {
return (
<Fragment key={group.date.toDateString()}>
@@ -69,8 +70,6 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
</Fragment>
);
})}
- </>
+ </div>
);
-};
-
-export default TimelinePostListView;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.css b/FrontEnd/src/pages/timeline/TimelinePostView.css
new file mode 100644
index 00000000..a8db46bf
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.css
@@ -0,0 +1,37 @@
+.timeline-post-header {
+ display: flex;
+ align-items: center;
+}
+
+.timeline-post-author-avatar {
+ border-radius: 50%;
+ width: 2em;
+ height: 2em;
+}
+
+.timeline-post-author-nickname {
+ margin: 0 1em;
+}
+
+.timeline-post-edit-button {
+ float: right;
+}
+
+.timeline-post-options-mask {
+ position: absolute;
+ inset: 0;
+ background-color: hsla(0, 0%, 100%, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+}
+
+@media (prefers-color-scheme: dark) {
+ .timeline-post-options-mask {
+ background-color: hsla(0, 0%, 0%, 0.8);
+ }
+}
+
+.timeline-post-content {
+ margin-top: 0.5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
new file mode 100644
index 00000000..4f0460ff
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelinePostInfo,
+} from "~src/http/timeline";
+
+import { pushAlert } from "~src/components/alert";
+import { useClickOutside } from "~src/components/hooks";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { DialogProvider, useDialog } from "~src/components/dialog";
+import FlatButton from "~src/components/button/FlatButton";
+import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
+import TimelinePostContentView from "./view/TimelinePostContentView";
+import IconButton from "~src/components/button/IconButton";
+
+import TimelinePostContainer from "./TimelinePostContainer";
+import TimelinePostCard from "./TimelinePostCard";
+
+import "./TimelinePostView.css";
+
+interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ onChanged: (post: HttpTimelinePostInfo) => void;
+ onDeleted: () => void;
+}
+
+export default function TimelinePostView(props: TimelinePostViewProps) {
+ const { post, onDeleted } = props;
+
+ const [operationMaskVisible, setOperationMaskVisible] =
+ useState<boolean>(false);
+
+ const { controller, switchDialog } = useDialog(
+ {
+ delete: (
+ <ConfirmDialog
+ title="timeline.post.deleteDialog.title"
+ body="timeline.post.deleteDialog.prompt"
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.deletePostFailed",
+ });
+ });
+ }}
+ />
+ ),
+ },
+ {
+ onClose: {
+ delete: () => {
+ setOperationMaskVisible(false);
+ },
+ },
+ },
+ );
+
+ const [maskElement, setMaskElement] = useState<HTMLElement | null>(null);
+ useClickOutside(maskElement, () => setOperationMaskVisible(false));
+
+ return (
+ <TimelinePostContainer>
+ <TimelinePostCard className="cru-primary">
+ {post.editable && (
+ <IconButton
+ color="primary"
+ icon="chevron-down"
+ className="timeline-post-edit-button"
+ onClick={(e) => {
+ setOperationMaskVisible(true);
+ e.stopPropagation();
+ }}
+ />
+ )}
+ <div className="timeline-post-header">
+ <UserAvatar
+ username={post.author.username}
+ className="timeline-post-author-avatar"
+ />
+ <small className="timeline-post-author-nickname">
+ {post.author.nickname}
+ </small>
+ <small className="timeline-post-time">
+ {new Date(post.time).toLocaleTimeString()}
+ </small>
+ </div>
+ <div className="timeline-post-content">
+ <TimelinePostContentView post={post} />
+ </div>
+ {operationMaskVisible ? (
+ <div
+ ref={setMaskElement}
+ className="timeline-post-options-mask"
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
+ >
+ <FlatButton
+ text="changeProperty"
+ onClick={(e) => {
+ e.stopPropagation();
+ }}
+ />
+ <FlatButton
+ text="delete"
+ color="danger"
+ onClick={(e) => {
+ switchDialog("delete");
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ ) : null}
+ </TimelinePostCard>
+ <DialogProvider controller={controller} />
+ </TimelinePostContainer>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..79838d58
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,79 @@
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ kTimelineVisibilities,
+ TimelineVisibility,
+} from "~src/http/timeline";
+
+import OperationDialog from "~src/components/dialog/OperationDialog";
+
+interface TimelinePropertyChangeDialogProps {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: "timeline.visibility.private",
+ Public: "timeline.visibility.public",
+ Register: "timeline.visibility.register",
+};
+
+export default function TimelinePropertyChangeDialog({
+ timeline,
+ onChange,
+}: TimelinePropertyChangeDialogProps) {
+ return (
+ <OperationDialog
+ title={"timeline.dialogChangeProperty.title"}
+ inputs={{
+ scheme: {
+ inputs: [
+ {
+ key: "title",
+ type: "text",
+ label: "timeline.dialogChangeProperty.titleField",
+ },
+ {
+ key: "visibility",
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
+ },
+ {
+ key: "description",
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ },
+ ],
+ },
+ dataInit: {
+ values: {
+ title: timeline.title,
+ visibility: timeline.visibility,
+ description: timeline.description,
+ },
+ },
+ }}
+ onProcess={({ title, visibility, description }) => {
+ const req: HttpTimelinePatchRequest = {};
+ if (title !== timeline.title) {
+ req.title = title;
+ }
+ if (visibility !== timeline.visibility) {
+ req.visibility = visibility;
+ }
+ if (description !== timeline.description) {
+ req.description = description;
+ }
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.owner.username, timeline.nameV2, req)
+ .then(onChange);
+ }}
+ />
+ );
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
new file mode 100644
index 00000000..232681c8
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -0,0 +1,5 @@
+.timeline-edit-image-image {
+ max-width: 100px;
+ max-height: 100px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
new file mode 100644
index 00000000..c62c8ee5
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -0,0 +1,36 @@
+import classNames from "classnames";
+
+import BlobImage from "~/src/components/BlobImage";
+
+import "./ImagePostEdit.css";
+
+interface TimelinePostEditImageProps {
+ file: File | null;
+ onChange: (file: File | null) => void;
+ disabled: boolean;
+ className?: string;
+}
+
+export default function ImagePostEdit(props: TimelinePostEditImageProps) {
+ const { file, onChange, disabled, className } = props;
+
+ return (
+ <div className={classNames("timeline-edit-image-container", className)}>
+ <input
+ type="file"
+ accept="image/*"
+ disabled={disabled}
+ onChange={(e) => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ onChange(null);
+ } else {
+ onChange(files[0]);
+ }
+ }}
+ className="timeline-edit-image-input"
+ />
+ {file && <BlobImage src={file} className="timeline-edit-image-image" />}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
new file mode 100644
index 00000000..c5b41b40
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
@@ -0,0 +1,24 @@
+.timeline-edit-markdown-tab-page {
+ min-height: 8em;
+ display: flex;
+}
+
+.timeline-edit-markdown-text {
+ width: 100%;
+}
+
+.timeline-edit-markdown-images {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.timeline-edit-markdown-images img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-edit-markdown-preview img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..36a5572b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from "react";
+import classnames from "classnames";
+import { marked } from "marked";
+
+import { HttpTimelinePostPostRequestData } from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { array } from "~src/components/common";
+import { TabPages } from "~src/components/tab";
+import { IconButton } from "~src/components/button";
+import BlobImage from "~src/components/BlobImage";
+
+import "./MarkdownPostEdit.css";
+
+class MarkedRenderer extends marked.Renderer {
+ constructor(public images: string[]) {
+ super();
+ }
+
+ // Custom image parser for indexed image link.
+ image(href: string, title: string | null, text: string): string {
+ const i = parseInt(href);
+ if (!isNaN(i) && i > 0 && i <= this.images.length) {
+ href = this.images[i - 1];
+ }
+
+ return super.image(href, title, text);
+ }
+}
+
+function generateMarkedOptions(imageUrls: string[]) {
+ return {
+ renderer: new MarkedRenderer(imageUrls),
+ async: false,
+ } as const;
+}
+
+function renderHtml(text: string, imageUrls: string[]): string {
+ return marked.parse(text, generateMarkedOptions(imageUrls));
+}
+
+async function build(
+ text: string,
+ images: File[],
+): Promise<HttpTimelinePostPostRequestData[]> {
+ return [
+ {
+ contentType: "text/markdown",
+ data: await base64(text),
+ },
+ ...(await Promise.all(
+ images.map(async (image) => {
+ const data = await base64(image);
+ return { contentType: image.type, data };
+ }),
+ )),
+ ];
+}
+
+export function useMarkdownEdit(disabled: boolean): {
+ hasContent: boolean;
+ clear: () => void;
+ build: () => Promise<HttpTimelinePostPostRequestData[]>;
+ markdownEditProps: Omit<MarkdownPostEditProps, "className">;
+} {
+ const [text, setText] = useState<string>("");
+ const [images, setImages] = useState<File[]>([]);
+
+ return {
+ hasContent: text !== "" || images.length !== 0,
+ clear: () => {
+ setText("");
+ setImages([]);
+ },
+ build: () => {
+ return build(text, images);
+ },
+ markdownEditProps: {
+ disabled,
+ text,
+ images,
+ onTextChange: setText,
+ onImageAppend: (image) => setImages(array.copy_push(images, image)),
+ onImageMove: (o, n) => setImages(array.copy_move(images, o, n)),
+ onImageDelete: (i) => setImages(array.copy_delete(images, i)),
+ },
+ };
+}
+
+function MarkdownPreview({ text, images }: { text: string; images: File[] }) {
+ const [html, setHtml] = useState("");
+
+ useEffect(() => {
+ const imageUrls = images.map((image) => URL.createObjectURL(image));
+
+ setHtml(renderHtml(text, imageUrls));
+
+ return () => {
+ imageUrls.forEach((url) => URL.revokeObjectURL(url));
+ };
+ }, [text, images]);
+
+ return (
+ <div
+ className="timeline-edit-markdown-preview"
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ );
+}
+
+interface MarkdownPostEditProps {
+ disabled: boolean;
+ text: string;
+ images: File[];
+ onTextChange: (text: string) => void;
+ onImageAppend: (image: File) => void;
+ onImageMove: (oldIndex: number, newIndex: number) => void;
+ onImageDelete: (index: number) => void;
+ className?: string;
+}
+
+export function MarkdownPostEdit({
+ disabled,
+ text,
+ images,
+ onTextChange,
+ onImageAppend,
+ // onImageMove,
+ onImageDelete,
+ className,
+}: MarkdownPostEditProps) {
+ return (
+ <TabPages
+ className={className}
+ pageContainerClassName="timeline-edit-markdown-tab-page"
+ dense
+ pages={[
+ {
+ name: "text",
+ text: "edit",
+ page: (
+ <textarea
+ value={text}
+ disabled={disabled}
+ className="timeline-edit-markdown-text"
+ onChange={(event) => {
+ onTextChange(event.currentTarget.value);
+ }}
+ />
+ ),
+ },
+ {
+ name: "images",
+ text: "image",
+ page: (
+ <div className="timeline-edit-markdown-images">
+ {images.map((image, index) => (
+ <div
+ key={image.name}
+ className="timeline-edit-markdown-image-container"
+ >
+ <BlobImage src={image} />
+ <IconButton
+ icon="trash"
+ color="danger"
+ className={classnames(
+ "timeline-edit-markdown-image-delete",
+ process && "d-none",
+ )}
+ onClick={() => {
+ onImageDelete(index);
+ }}
+ />
+ </div>
+ ))}
+ <input
+ type="file"
+ accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+ const { files } = event.currentTarget;
+ if (files != null && files.length !== 0) {
+ onImageAppend(files[0]);
+ }
+ }}
+ disabled={disabled}
+ />
+ </div>
+ ),
+ },
+ {
+ name: "preview",
+ text: "preview",
+ page: <MarkdownPreview text={text} images={images} />,
+ },
+ ]}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
new file mode 100644
index 00000000..d1a61793
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -0,0 +1,12 @@
+.timeline-edit-plain-text-container {
+ width: 100%;
+ height: 100%;
+}
+
+.timeline-edit-plain-text-input {
+ width: 100%;
+ height: 100%;
+ padding: 0.5em;
+ border-radius: 4px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
new file mode 100644
index 00000000..7f3663b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
@@ -0,0 +1,29 @@
+import classNames from "classnames";
+
+import "./PlainTextPostEdit.css";
+
+interface TimelinePostEditTextProps {
+ text: string;
+ disabled: boolean;
+ onChange: (text: string) => void;
+ className?: string;
+}
+
+export default function TimelinePostEditText(props: TimelinePostEditTextProps) {
+ const { text, disabled, onChange, className } = props;
+
+ return (
+ <div
+ className={classNames("timeline-edit-plain-text-container", className)}
+ >
+ <textarea
+ value={text}
+ disabled={disabled}
+ onChange={(event) => {
+ onChange(event.target.value);
+ }}
+ className={classNames("timeline-edit-plain-text-input")}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
new file mode 100644
index 00000000..6efe93e9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
@@ -0,0 +1,35 @@
+.timeline-post-create-card {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+ margin-right: 200px;
+}
+
+@media (max-width: 576px) {
+ .timeline-post-create-container {
+ padding-top: 60px;
+ }
+
+ .timeline-post-create-card {
+ margin-right: 0;
+ }
+}
+
+.timeline-post-create {
+ display: flex;
+}
+
+.timeline-post-create-edit-area {
+ flex-grow: 1;
+}
+
+.timeline-post-create-right-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-left: 1em;
+}
+
+.timeline-post-create-send {
+ margin-top: auto;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
new file mode 100644
index 00000000..c0a80ad0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -0,0 +1,193 @@
+import { useState } from "react";
+import classNames from "classnames";
+
+import { UiLogicError } from "~src/common";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+ HttpTimelinePostPostRequestData,
+} from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { useC } from "~/src/components/common";
+import { pushAlert } from "~src/components/alert";
+import { IconButton, LoadingButton } from "~src/components/button";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import { useWindowLeave } from "~src/components/hooks";
+
+import TimelinePostCard from "../TimelinePostCard";
+import TimelinePostContainer from "../TimelinePostContainer";
+import PlainTextPostEdit from "./PlainTextPostEdit";
+import ImagePostEdit from "./ImagePostEdit";
+import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit";
+
+import "./TimelinePostCreateView.css";
+
+type PostKind = "text" | "markdown" | "image";
+
+const postKindIconMap: Record<PostKind, string> = {
+ text: "fonts",
+ markdown: "markdown",
+ image: "image",
+};
+
+export interface TimelinePostEditProps {
+ className?: string;
+ timeline: HttpTimelineInfo;
+ onPosted: (newPost: HttpTimelinePostInfo) => void;
+}
+
+function TimelinePostEdit(props: TimelinePostEditProps) {
+ const { timeline, className, onPosted } = props;
+
+ const c = useC();
+
+ const [process, setProcess] = useState<boolean>(false);
+
+ const [kind, setKind] = useState<PostKind>("text");
+
+ const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
+ const [text, setText] = useState<string>(
+ () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "",
+ );
+ const [image, setImage] = useState<File | null>(null);
+ const {
+ hasContent: mdHasContent,
+ build: mdBuild,
+ clear: mdClear,
+ markdownEditProps,
+ } = useMarkdownEdit(process);
+
+ useWindowLeave(!mdHasContent && !image);
+
+ const canSend =
+ (kind === "text" && text.length !== 0) ||
+ (kind === "image" && image != null) ||
+ (kind === "markdown" && mdHasContent);
+
+ const onPostError = (): void => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.sendPostFailed",
+ });
+ };
+
+ const onSend = async (): Promise<void> => {
+ setProcess(true);
+
+ let requestDataList: HttpTimelinePostPostRequestData[];
+ switch (kind) {
+ case "text":
+ requestDataList = [
+ {
+ contentType: "text/plain",
+ data: await base64(text),
+ },
+ ];
+ break;
+ case "image":
+ if (image == null) {
+ throw new UiLogicError();
+ }
+ requestDataList = [
+ {
+ contentType: image.type,
+ data: await base64(image),
+ },
+ ];
+ break;
+ case "markdown":
+ if (!mdHasContent) {
+ throw new UiLogicError();
+ }
+ requestDataList = await mdBuild();
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
+
+ try {
+ const res = await getHttpTimelineClient().postPost(
+ timeline.owner.username,
+ timeline.nameV2,
+ {
+ dataList: requestDataList,
+ },
+ );
+
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftTextLocalStorageKey);
+ } else if (kind === "image") {
+ setImage(null);
+ } else if (kind === "markdown") {
+ mdClear();
+ }
+ onPosted(res);
+ } catch (e) {
+ onPostError();
+ } finally {
+ setProcess(false);
+ }
+ };
+
+ return (
+ <TimelinePostContainer
+ className={classNames(className, "timeline-post-create-container")}
+ >
+ <TimelinePostCard className="timeline-post-create-card">
+ <div className="timeline-post-create">
+ <div className="timeline-post-create-edit-area">
+ {kind === "text" && (
+ <PlainTextPostEdit
+ text={text}
+ disabled={process}
+ onChange={(text) => {
+ setText(text);
+ window.localStorage.setItem(draftTextLocalStorageKey, text);
+ }}
+ />
+ )}
+ {kind === "image" && (
+ <ImagePostEdit
+ file={image}
+ onChange={setImage}
+ disabled={process}
+ />
+ )}
+ {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />}
+ </div>
+ <div className="timeline-post-create-right-area">
+ <PopupMenu
+ containerClassName="timeline-post-create-kind-select"
+ items={(["text", "image", "markdown"] as const).map((kind) => ({
+ type: "button",
+ text: `timeline.post.type.${kind}`,
+ iconClassName: postKindIconMap[kind],
+ onClick: () => {
+ setKind(kind);
+ },
+ }))}
+ >
+ <IconButton color="primary" icon={postKindIconMap[kind]} />
+ </PopupMenu>
+ <LoadingButton
+ className="timeline-post-create-send"
+ onClick={() => void onSend()}
+ color="primary"
+ disabled={!canSend}
+ loading={process}
+ >
+ {c("timeline.send")}
+ </LoadingButton>
+ </div>
+ </div>
+ </TimelinePostCard>
+ </TimelinePostContainer>
+ );
+}
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx
index 1dffdcc1..6cd1ded0 100644
--- a/FrontEnd/src/views/timeline/index.tsx
+++ b/FrontEnd/src/pages/timeline/index.tsx
@@ -1,11 +1,10 @@
-import * as React from "react";
import { useParams } from "react-router-dom";
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
import Timeline from "./Timeline";
-const TimelinePage: React.FC = () => {
+export default function TimelinePage() {
const { owner, timeline: timelineNameParam } = useParams();
if (owner == null || owner == "")
@@ -13,11 +12,5 @@ const TimelinePage: React.FC = () => {
const timeline = timelineNameParam || "self";
- return (
- <div className="container">
- <Timeline timelineOwner={owner} timelineName={timeline} />
- </div>
- );
+ return <Timeline timelineOwner={owner} timelineName={timeline} />;
};
-
-export default TimelinePage;
diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.css b/FrontEnd/src/pages/timeline/view/ImagePostView.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/ImagePostView.css
diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.tsx b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx
new file mode 100644
index 00000000..85179475
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from "react";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import "./ImagePostView.css";
+
+interface ImagePostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function ImagePostView({ post, className }: ImagePostViewProps) {
+ const [url, setUrl] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (post) {
+ setUrl(
+ getHttpTimelineClient().generatePostDataUrl(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ ),
+ );
+ } else {
+ setUrl(null);
+ }
+ }, [post]);
+
+ return (
+ <div className={classNames("timeline-view-image-container", className)}>
+ <img src={url ?? undefined} className="timeline-view-image" />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.css b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css
new file mode 100644
index 00000000..48a893eb
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css
@@ -0,0 +1,4 @@
+.timeline-view-markdown img {
+ max-width: 100%;
+ max-height: 200px;
+}
diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx
new file mode 100644
index 00000000..9bb9f980
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx
@@ -0,0 +1,59 @@
+import { useMemo, useState } from "react";
+import { marked } from "marked";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import { useAutoUnsubscribePromise } from "~src/components/hooks";
+import Skeleton from "~src/components/Skeleton";
+
+import "./MarkdownPostView.css";
+
+interface MarkdownPostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function MarkdownPostView({
+ post,
+ className,
+}: MarkdownPostViewProps) {
+ const [markdown, setMarkdown] = useState<string | null>(null);
+
+ useAutoUnsubscribePromise(
+ () => {
+ if (post) {
+ return getHttpTimelineClient().getPostDataAsString(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ );
+ }
+ },
+ setMarkdown,
+ [post],
+ );
+
+ const markdownHtml = useMemo<string | null>(() => {
+ if (markdown == null) return null;
+ return marked.parse(markdown, {
+ async: false,
+ });
+ }, [markdown]);
+
+ return (
+ <div className={classNames("timeline-view-markdown-container", className)}>
+ {markdownHtml == null ? (
+ <Skeleton />
+ ) : (
+ <div
+ className="timeline-view-markdown"
+ dangerouslySetInnerHTML={{ __html: markdownHtml }}
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.css b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css
diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx
new file mode 100644
index 00000000..b964187d
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx
@@ -0,0 +1,50 @@
+import { useState } from "react";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import Skeleton from "~src/components/Skeleton";
+import { useAutoUnsubscribePromise } from "~src/components/hooks";
+
+import "./PlainTextPostView.css";
+
+interface PlainTextPostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function PlainTextPostView({
+ post,
+ className,
+}: PlainTextPostViewProps) {
+ const [text, setText] = useState<string | null>(null);
+
+ useAutoUnsubscribePromise(
+ () => {
+ if (post) {
+ return getHttpTimelineClient().getPostDataAsString(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ );
+ }
+ },
+ setText,
+ [post],
+ );
+
+ return (
+ <div
+ className={classNames("timeline-view-plain-text-container", className)}
+ >
+ {text == null ? (
+ <Skeleton />
+ ) : (
+ <div className="timeline-view-plain-text">{text}</div>
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx
new file mode 100644
index 00000000..851a9a33
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx
@@ -0,0 +1,37 @@
+import ImagePostView from "./ImagePostView";
+import MarkdownPostView from "./MarkdownPostView";
+import PlainTextPostView from "./PlainTextPostView";
+
+import type { HttpTimelinePostInfo } from "~src/http/timeline";
+
+interface TimelinePostContentViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
+ "text/plain": PlainTextPostView,
+ "text/markdown": MarkdownPostView,
+ "image/png": ImagePostView,
+ "image/jpeg": ImagePostView,
+ "image/gif": ImagePostView,
+ "image/webp": ImagePostView,
+};
+
+export default function TimelinePostContentView({
+ post,
+ className,
+}: TimelinePostContentViewProps) {
+ if (post == null) {
+ return <div />;
+ }
+
+ const type = post.dataList[0].kind;
+
+ if (type in viewMap) {
+ const View = viewMap[type];
+ return <View post={post} className={className} />;
+ }
+
+ return <div>Unknown post type.</div>;
+}
diff --git a/FrontEnd/src/palette.ts b/FrontEnd/src/palette.ts
deleted file mode 100644
index d06f9b19..00000000
--- a/FrontEnd/src/palette.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import Color from "color";
-import { BehaviorSubject, Observable } from "rxjs";
-
-import refreshAnimation from "./utilities/refreshAnimation";
-
-function lightenBy(color: Color, ratio: number): Color {
- const lightness = color.lightness();
- return color.lightness(lightness + (100 - lightness) * ratio);
-}
-
-function darkenBy(color: Color, ratio: number): Color {
- const lightness = color.lightness();
- return color.lightness(lightness - lightness * ratio);
-}
-
-export interface PaletteColor {
- color: string;
- l1: string;
- l2: string;
- l3: string;
- d1: string;
- d2: string;
- d3: string;
- f1: string;
- f2: string;
- f3: string;
- r1: string;
- r2: string;
- r3: string;
- t: string;
- t1: string;
- t2: string;
- t3: string;
- [key: string]: string;
-}
-
-const paletteColorList = [
- "primary",
- "primary-enhance",
- "secondary",
- "danger",
- "success",
-] as const;
-
-export type PaletteColorType = (typeof paletteColorList)[number];
-
-export type Palette = Record<PaletteColorType, PaletteColor>;
-
-export function generatePaletteColor(color: string): PaletteColor {
- const c = Color(color);
- const light = c.lightness() > 60;
- const l1 = lightenBy(c, 0.1).rgb().toString();
- const l2 = lightenBy(c, 0.2).rgb().toString();
- const l3 = lightenBy(c, 0.3).rgb().toString();
- const d1 = darkenBy(c, 0.1).rgb().toString();
- const d2 = darkenBy(c, 0.2).rgb().toString();
- const d3 = darkenBy(c, 0.3).rgb().toString();
- const f1 = light ? l1 : d1;
- const f2 = light ? l2 : d2;
- const f3 = light ? l3 : d3;
- const r1 = light ? d1 : l1;
- const r2 = light ? d2 : l2;
- const r3 = light ? d3 : l3;
- const _t = light ? Color("black") : Color("white");
- const t = _t.rgb().toString();
- const _b = light ? lightenBy : darkenBy;
- const t1 = _b(_t, 0.1).rgb().toString();
- const t2 = _b(_t, 0.2).rgb().toString();
- const t3 = _b(_t, 0.3).rgb().toString();
-
- return {
- color: c.rgb().toString(),
- l1,
- l2,
- l3,
- d1,
- d2,
- d3,
- f1,
- f2,
- f3,
- r1,
- r2,
- r3,
- t,
- t1,
- t2,
- t3,
- };
-}
-
-export function generatePalette(options: {
- primary: string;
- primaryEnhance?: string;
- secondary?: string;
-}): Palette {
- const { primary, primaryEnhance, secondary } = options;
- const p = Color(primary);
- const pe =
- primaryEnhance == null
- ? lightenBy(p, 0.3).saturate(0.3)
- : Color(primaryEnhance);
- const s = secondary == null ? Color("gray") : Color(secondary);
-
- return {
- primary: generatePaletteColor(p.toString()),
- "primary-enhance": generatePaletteColor(pe.toString()),
- secondary: generatePaletteColor(s.toString()),
- danger: generatePaletteColor("red"),
- success: generatePaletteColor("green"),
- };
-}
-
-export function generatePaletteCSS(palette: Palette): string {
- const colors: [string, string][] = [];
- for (const colorType of paletteColorList) {
- const paletteColor = palette[colorType];
- for (const variant in paletteColor) {
- let key = `--cru-${colorType}`;
- if (variant !== "color") key += `-${variant}`;
- key += "-color";
- colors.push([key, paletteColor[variant]]);
- }
- }
-
- return `:root {${colors
- .map(([key, color]) => `${key} : ${color};`)
- .join("")}}`;
-}
-
-const paletteSubject: BehaviorSubject<Palette | null> =
- new BehaviorSubject<Palette | null>(
- generatePalette({ primary: "rgb(0, 123, 255)" })
- );
-
-export const palette$: Observable<Palette | null> =
- paletteSubject.asObservable();
-
-palette$.subscribe((palette) => {
- const styleTagId = "timeline-palette-css";
- if (palette != null) {
- let styleTag = document.getElementById(styleTagId);
- if (styleTag == null) {
- styleTag = document.createElement("style");
- styleTag.id = styleTagId;
- document.head.append(styleTag);
- }
- styleTag.innerHTML = generatePaletteCSS(palette);
- } else {
- const styleTag = document.getElementById(styleTagId);
- if (styleTag != null) {
- styleTag.parentElement?.removeChild(styleTag);
- }
- }
-
- refreshAnimation();
-});
-
-export function setPalette(palette: Palette): () => void {
- const old = paletteSubject.value;
-
- paletteSubject.next(palette);
-
- return () => {
- paletteSubject.next(old);
- };
-}
diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts
deleted file mode 100644
index 83d63abe..00000000
--- a/FrontEnd/src/services/TimelinePostBuilder.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { marked } from "marked";
-
-import { UiLogicError } from "@/common";
-
-import base64 from "@/utilities/base64";
-
-import { HttpTimelinePostPostRequest } from "@/http/timeline";
-
-class TimelinePostMarkedRenderer extends marked.Renderer {
- constructor(private _images: { file: File; url: string }[]) {
- super();
- }
-
- image(href: string | null, title: string | null, text: string): string {
- if (href != null) {
- const i = parseInt(href);
- if (!isNaN(i) && i > 0 && i <= this._images.length) {
- href = this._images[i - 1].url;
- }
- }
- return this.image(href, title, text);
- }
-}
-
-export default class TimelinePostBuilder {
- private _onChange: () => void;
- private _text = "";
- private _images: { file: File; url: string }[] = [];
- private _markedOptions: marked.MarkedOptions;
-
- constructor(onChange: () => void) {
- this._onChange = onChange;
- this._markedOptions = {
- renderer: new TimelinePostMarkedRenderer(this._images),
- };
- }
-
- setMarkdownText(text: string): void {
- this._text = text;
- this._onChange();
- }
-
- appendImage(file: File): void {
- this._images = this._images.slice();
- this._images.push({
- file,
- url: URL.createObjectURL(file),
- });
- this._onChange();
- }
-
- moveImage(oldIndex: number, newIndex: number): void {
- if (oldIndex < 0 || oldIndex >= this._images.length) {
- throw new UiLogicError("Old index out of range.");
- }
-
- if (newIndex < 0) {
- newIndex = 0;
- }
-
- if (newIndex >= this._images.length) {
- newIndex = this._images.length - 1;
- }
-
- this._images = this._images.slice();
-
- const [old] = this._images.splice(oldIndex, 1);
- this._images.splice(newIndex, 0, old);
-
- this._onChange();
- }
-
- deleteImage(index: number): void {
- if (index < 0 || index >= this._images.length) {
- throw new UiLogicError("Old index out of range.");
- }
-
- this._images = this._images.slice();
-
- URL.revokeObjectURL(this._images[index].url);
- this._images.splice(index, 1);
-
- this._onChange();
- }
-
- get text(): string {
- return this._text;
- }
-
- get images(): { file: File; url: string }[] {
- return this._images;
- }
-
- get isEmpty(): boolean {
- return this._text.length === 0 && this._images.length === 0;
- }
-
- renderHtml(): string {
- return marked.parse(this._text);
- }
-
- dispose(): void {
- for (const image of this._images) {
- URL.revokeObjectURL(image.url);
- }
- this._images = [];
- }
-
- async build(): Promise<HttpTimelinePostPostRequest["dataList"]> {
- return [
- {
- contentType: "text/markdown",
- data: await base64(this._text),
- },
- ...(await Promise.all(
- this._images.map((image) =>
- base64(image.file).then((data) => ({
- contentType: image.file.type,
- data,
- })),
- ),
- )),
- ];
- }
-}
diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts
index 42b14451..0fa37848 100644
--- a/FrontEnd/src/services/alert.ts
+++ b/FrontEnd/src/services/alert.ts
@@ -1,10 +1,10 @@
import pull from "lodash/pull";
-import { I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { I18nText } from "~src/common";
+import { ThemeColor } from "~src/components/common";
export interface AlertInfo {
- type?: PaletteColorType;
+ type?: ThemeColor;
message?: I18nText;
customMessage?: React.ReactElement;
dismissTime?: number | "never";
diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts
index 707c956f..41a7bff0 100644
--- a/FrontEnd/src/services/timeline.ts
+++ b/FrontEnd/src/services/timeline.ts
@@ -1,9 +1,22 @@
-import { TimelineVisibility } from "@/http/timeline";
import XRegExp from "xregexp";
-import { Observable } from "rxjs";
-import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr";
-
-import { getHttpToken } from "@/http/common";
+import {
+ Observable,
+ BehaviorSubject,
+ switchMap,
+ filter,
+ first,
+ distinctUntilChanged,
+} from "rxjs";
+import {
+ HubConnection,
+ HubConnectionBuilder,
+ HubConnectionState,
+} from "@microsoft/signalr";
+
+import { TimelineVisibility } from "~src/http/timeline";
+import { token$ } from "~src/http/common";
+
+// cSpell:ignore onreconnected onreconnecting
const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
@@ -20,17 +33,23 @@ export const timelineVisibilityTooltipTranslationMap: Record<
Private: "timeline.visibilityTooltip.private",
};
-export function getTimelinePostUpdate$(
- owner: string,
- timeline: string,
-): Observable<{ update: boolean; state: HubConnectionState }> {
- return new Observable((subscriber) => {
- subscriber.next({
- update: false,
- state: HubConnectionState.Connecting,
- });
+type ConnectionState =
+ | "Connecting"
+ | "Reconnecting"
+ | "Disconnected"
+ | "Connected";
+
+type Connection = {
+ connection: HubConnection;
+ state$: Observable<ConnectionState>;
+};
+
+function createConnection$(token: string | null): Observable<Connection> {
+ return new Observable<Connection>((subscriber) => {
+ const connectionStateSubject = new BehaviorSubject<ConnectionState>(
+ "Connecting",
+ );
- const token = getHttpToken();
const connection = new HubConnectionBuilder()
.withUrl("/api/hub/timeline", {
accessTokenFactory: token == null ? undefined : () => token,
@@ -38,56 +57,138 @@ export function getTimelinePostUpdate$(
.withAutomaticReconnect()
.build();
- const o = owner;
- const t = timeline;
+ connection.onclose = () => {
+ connectionStateSubject.next("Disconnected");
+ };
+
+ connection.onreconnecting = () => {
+ connectionStateSubject.next("Reconnecting");
+ };
+
+ connection.onreconnected = () => {
+ connectionStateSubject.next("Connected");
+ };
+
+ let requestStopped = false;
+
+ void connection.start().then(
+ () => {
+ connectionStateSubject.next("Connected");
+ },
+ (e) => {
+ if (!requestStopped) {
+ throw e;
+ }
+ },
+ );
+
+ subscriber.next({
+ connection,
+ state$: connectionStateSubject.asObservable(),
+ });
+
+ return () => {
+ void connection.stop();
+ requestStopped = true;
+ };
+ });
+}
+
+const connectionSubject = new BehaviorSubject<Connection | null>(null);
+
+token$
+ .pipe(distinctUntilChanged(), switchMap(createConnection$))
+ .subscribe(connectionSubject);
+
+const connection$ = connectionSubject
+ .asObservable()
+ .pipe(filter((c): c is Connection => c != null));
+
+function createTimelinePostUpdateCount$(
+ connection: Connection,
+ owner: string,
+ timeline: string,
+): Observable<number> {
+ const [o, t] = [owner, timeline];
+ return new Observable<number>((subscriber) => {
+ const hubConnection = connection.connection;
+ let count = 0;
const handler = (owner: string, timeline: string): void => {
if (owner === o && timeline === t) {
- subscriber.next({ update: true, state: connection.state });
+ subscriber.next(count++);
}
};
- connection.onclose(() => {
- subscriber.next({
- update: false,
- state: HubConnectionState.Disconnected,
+ let hubOn = false;
+
+ const subscription = connection.state$
+ .pipe(first((state) => state === "Connected"))
+ .subscribe(() => {
+ hubConnection.on("OnTimelinePostChangedV2", handler);
+ void hubConnection.invoke(
+ "SubscribeTimelinePostChangeV2",
+ owner,
+ timeline,
+ );
+ hubOn = true;
});
- });
- connection.onreconnecting(() => {
- subscriber.next({
- update: false,
- state: HubConnectionState.Reconnecting,
- });
- });
+ return () => {
+ if (hubOn) {
+ void hubConnection.invoke(
+ "UnsubscribeTimelinePostChangeV2",
+ owner,
+ timeline,
+ );
+ hubConnection.off("OnTimelinePostChangedV2", handler);
+ }
+
+ subscription.unsubscribe();
+ };
+ });
+}
+
+type OldUpdateInfo = { update: boolean; state: HubConnectionState };
- connection.onreconnected(() => {
+function createTimelinePostOldUpdateInfo$(
+ connection: Connection,
+ owner: string,
+ timeline: string,
+): Observable<OldUpdateInfo> {
+ return new Observable<OldUpdateInfo>((subscriber) => {
+ let savedState: ConnectionState = "Connecting";
+
+ const postUpdateSubscription = createTimelinePostUpdateCount$(
+ connection,
+ owner,
+ timeline,
+ ).subscribe(() => {
subscriber.next({
- update: false,
- state: HubConnectionState.Connected,
+ update: true,
+ state: savedState as HubConnectionState,
});
});
- connection.on("OnTimelinePostChangedV2", handler);
-
- void connection.start().then(() => {
- subscriber.next({ update: false, state: HubConnectionState.Connected });
-
- return connection.invoke(
- "SubscribeTimelinePostChangeV2",
- owner,
- timeline,
- );
+ const stateSubscription = connection.state$.subscribe((state) => {
+ savedState = state;
+ subscriber.next({ update: false, state: state as HubConnectionState });
});
return () => {
- connection.off("OnTimelinePostChangedV2", handler);
-
- if (connection.state === HubConnectionState.Connected) {
- void connection
- .invoke("UnsubscribeTimelinePostChangeV2", owner, timeline)
- .then(() => connection.stop());
- }
+ stateSubscription.unsubscribe();
+ postUpdateSubscription.unsubscribe();
};
});
}
+
+export function getTimelinePostUpdate$(
+ owner: string,
+ timeline: string,
+): Observable<OldUpdateInfo> {
+ return connection$.pipe(
+ switchMap((connection) =>
+ createTimelinePostOldUpdateInfo$(connection, owner, timeline),
+ ),
+ );
+}
diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts
index c89ca893..5f682a36 100644
--- a/FrontEnd/src/services/user.ts
+++ b/FrontEnd/src/services/user.ts
@@ -2,20 +2,23 @@ import { useState, useEffect } from "react";
import { BehaviorSubject, Observable } from "rxjs";
import { AxiosError } from "axios";
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
-import { setHttpToken, axios, HttpBadRequestError } from "@/http/common";
-import { getHttpTokenClient } from "@/http/token";
-import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user";
+import { setHttpToken, axios, HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { getHttpUserClient, HttpUser, UserPermission } from "~src/http/user";
-import { pushAlert } from "./alert";
+import { pushAlert } from "~src/components/alert";
interface IAuthUser extends HttpUser {
token: string;
}
export class AuthUser implements IAuthUser {
- constructor(user: HttpUser, public token: string) {
+ constructor(
+ user: HttpUser,
+ public token: string,
+ ) {
this.uniqueId = user.uniqueId;
this.username = user.username;
this.permissions = user.permissions;
@@ -61,7 +64,7 @@ export class UserService {
if (e.isAxiosError && e.response && e.response.status === 401) {
this.userSubject.next(null);
pushAlert({
- type: "danger",
+ color: "danger",
message: "user.tokenInvalid",
});
} else {
@@ -97,11 +100,11 @@ export class UserService {
localStorage.removeItem(USER_STORAGE_KEY);
this.userSubject.next(null);
pushAlert({
- type: "danger",
+ color: "danger",
message: "user.tokenInvalid",
});
}
- }
+ },
);
}
}
@@ -118,7 +121,7 @@ export class UserService {
async login(
credentials: LoginCredentials,
- rememberMe: boolean
+ rememberMe: boolean,
): Promise<void> {
if (this.currentUser) {
throw new UiLogicError("Already login.");
diff --git a/FrontEnd/src/utilities/array.ts b/FrontEnd/src/utilities/array.ts
new file mode 100644
index 00000000..838e8744
--- /dev/null
+++ b/FrontEnd/src/utilities/array.ts
@@ -0,0 +1,41 @@
+export function copy_move<T>(
+ array: T[],
+ oldIndex: number,
+ newIndex: number,
+): T[] {
+ if (oldIndex < 0 || oldIndex >= array.length) {
+ throw new Error("Old index out of range.");
+ }
+
+ if (newIndex < 0) {
+ newIndex = 0;
+ }
+
+ if (newIndex >= array.length) {
+ newIndex = array.length - 1;
+ }
+
+ const result = array.slice();
+ const [element] = result.splice(oldIndex, 1);
+ result.splice(newIndex, 0, element);
+
+ return result;
+}
+
+export function copy_insert<T>(array: T[], index: number, element: T): T[] {
+ const result = array.slice();
+ result.splice(index, 0, element);
+ return result;
+}
+
+export function copy_push<T>(array: T[], element: T): T[] {
+ const result = array.slice();
+ result.push(element);
+ return result;
+}
+
+export function copy_delete<T>(array: T[], index: number): T[] {
+ const result = array.slice();
+ result.splice(index, 1);
+ return array;
+}
diff --git a/FrontEnd/src/utilities/base64.ts b/FrontEnd/src/utilities/base64.ts
index 59de7512..6eece979 100644
--- a/FrontEnd/src/utilities/base64.ts
+++ b/FrontEnd/src/utilities/base64.ts
@@ -1,8 +1,19 @@
-import { Base64 } from "js-base64";
+function bytesToBase64(bytes: Uint8Array): string {
+ const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join("");
+ return btoa(binString);
+}
+
+export default function base64(
+ data: Blob | Uint8Array | string,
+): Promise<string> {
+ if (typeof data === "string") {
+ // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
+ const binString = new TextEncoder().encode(data);
+ return Promise.resolve(bytesToBase64(binString));
+ }
-export default function base64(blob: Blob | string): Promise<string> {
- if (typeof blob === "string") {
- return Promise.resolve(Base64.encode(blob));
+ if (data instanceof Uint8Array) {
+ return Promise.resolve(bytesToBase64(data));
}
return new Promise<string>((resolve) => {
@@ -10,6 +21,6 @@ export default function base64(blob: Blob | string): Promise<string> {
reader.onload = function () {
resolve((reader.result as string).replace(/^data:.*;base64,/, ""));
};
- reader.readAsDataURL(blob);
+ reader.readAsDataURL(data);
});
}
diff --git a/FrontEnd/src/utilities/geometry.ts b/FrontEnd/src/utilities/geometry.ts
new file mode 100644
index 00000000..60a8d3d4
--- /dev/null
+++ b/FrontEnd/src/utilities/geometry.ts
@@ -0,0 +1,292 @@
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export type Movement = Point;
+
+export interface Size {
+ width: number;
+ height: number;
+}
+
+export class Rect {
+ static empty = new Rect(0, 0, 0, 0);
+ static max = new Rect(
+ Number.MIN_VALUE,
+ Number.MIN_VALUE,
+ Number.MAX_VALUE,
+ Number.MAX_VALUE,
+ );
+
+ static from({
+ left,
+ top,
+ width,
+ height,
+ }: {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ }): Rect {
+ return new Rect(left, top, width, height);
+ }
+
+ constructor(
+ public left: number,
+ public top: number,
+ public width: number,
+ public height: number,
+ ) {}
+
+ get right(): number {
+ return this.left + this.width;
+ }
+
+ set right(value: number) {
+ this.width = value - this.left;
+ }
+
+ get bottom(): number {
+ return this.top + this.height;
+ }
+
+ set bottom(value: number) {
+ this.height = value - this.top;
+ }
+
+ get ratio(): number {
+ return this.height / this.width;
+ }
+
+ get position(): Point {
+ return {
+ x: this.left,
+ y: this.top,
+ };
+ }
+
+ set position(value: Point) {
+ this.left = value.x;
+ this.top = value.y;
+ }
+
+ get size(): Size {
+ return {
+ width: this.width,
+ height: this.height,
+ };
+ }
+
+ set size(value: Size) {
+ this.width = value.width;
+ this.height = value.height;
+ }
+
+ get normalizedLeft(): number {
+ return this.width >= 0 ? this.left : this.right;
+ }
+
+ get normalizedTop(): number {
+ return this.height >= 0 ? this.top : this.bottom;
+ }
+
+ get normalizedRight(): number {
+ return this.width >= 0 ? this.right : this.left;
+ }
+
+ get normalizedBottom(): number {
+ return this.height >= 0 ? this.bottom : this.top;
+ }
+
+ get normalizedWidth(): number {
+ return Math.abs(this.width);
+ }
+
+ get normalizedHeight(): number {
+ return Math.abs(this.height);
+ }
+
+ get normalizedSize(): Size {
+ return {
+ width: this.normalizedWidth,
+ height: this.normalizedHeight,
+ };
+ }
+
+ get normalizedRatio(): number {
+ return Math.abs(this.ratio);
+ }
+
+ normalize(): Rect {
+ if (this.width < 0) {
+ this.width = -this.width;
+ this.left -= this.width;
+ }
+ if (this.height < 0) {
+ this.height = -this.height;
+ this.top -= this.height;
+ }
+ return this;
+ }
+
+ move(movement: Movement): Rect {
+ this.left += movement.x;
+ this.top += movement.y;
+ return this;
+ }
+
+ expand(size: Size | Point): Rect {
+ if ("x" in size) {
+ this.width += size.x;
+ this.height += size.y;
+ } else {
+ this.width += size.width;
+ this.height += size.height;
+ }
+ return this;
+ }
+
+ copy(): Rect {
+ return new Rect(this.left, this.top, this.width, this.height);
+ }
+}
+
+export function adjustRectToContainer(
+ rect: Rect,
+ container: Rect,
+ mode: "move" | "resize" | "both",
+ options?: {
+ targetRatio?: number;
+ resizeNoFlip?: boolean;
+ ratioCorrectBasedOn?: "bigger" | "smaller" | "width" | "height";
+ },
+): Rect {
+ rect = rect.copy();
+ container = container.copy().normalize();
+
+ if (process.env.NODE_ENV === "development") {
+ if (mode === "move") {
+ if (rect.normalizedWidth > container.width) {
+ console.warn(
+ "adjust rect (move): rect.normalizedWidth > container.normalizedWidth",
+ );
+ }
+ if (rect.normalizedHeight > container.height) {
+ console.warn(
+ "adjust rect (move): rect.normalizedHeight > container.normalizedHeight",
+ );
+ }
+ }
+ if (mode === "resize") {
+ if (rect.left < container.left) {
+ console.warn(
+ "adjust rect (resize): rect.left < container.normalizedLeft",
+ );
+ }
+ if (rect.left > container.right) {
+ console.warn(
+ "adjust rect (resize): rect.left > container.normalizedRight",
+ );
+ }
+ if (rect.top < container.top) {
+ console.warn(
+ "adjust rect (resize): rect.top < container.normalizedTop",
+ );
+ }
+ if (rect.top > container.bottom) {
+ console.warn(
+ "adjust rect (resize): rect.top > container.normalizedBottom",
+ );
+ }
+ }
+ }
+
+ if (mode === "move") {
+ rect.left =
+ rect.width >= 0
+ ? clamp(rect.left, container.left, container.right - rect.width)
+ : clamp(rect.left, container.left - rect.width, container.right);
+ rect.top =
+ rect.height >= 0
+ ? clamp(rect.top, container.top, container.bottom - rect.height)
+ : clamp(rect.top, container.top - rect.height, container.bottom);
+ } else if (mode === "resize") {
+ const noFlip = options?.resizeNoFlip;
+ const newRight = clamp(
+ rect.right,
+ rect.width > 0 && noFlip ? rect.left : container.left,
+ rect.width < 0 && noFlip ? rect.left : container.right,
+ );
+ rect.right = newRight;
+ rect.bottom = clamp(
+ rect.bottom,
+ rect.height > 0 && noFlip ? rect.top : container.top,
+ rect.height < 0 && noFlip ? rect.top : container.bottom,
+ );
+ } else {
+ rect.left = clamp(rect.left, container.left, container.right);
+ rect.top = clamp(rect.top, container.top, container.bottom);
+ rect.right = clamp(rect.right, container.left, container.right);
+ rect.bottom = clamp(rect.bottom, container.top, container.bottom);
+ }
+
+ // Now correct ratio
+ const currentRatio = rect.normalizedRatio;
+ let targetRatio = options?.targetRatio;
+ if (targetRatio != null) targetRatio = Math.abs(targetRatio);
+ if (targetRatio != null && currentRatio !== targetRatio) {
+ const { ratioCorrectBasedOn } = options ?? {};
+
+ const newWidth =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ const newHeight =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+
+ const newBottom = rect.top + newHeight;
+ const newRight = rect.left + newWidth;
+
+ if (ratioCorrectBasedOn === "width") {
+ if (newBottom >= container.top && newBottom <= container.bottom) {
+ rect.height = newHeight;
+ } else {
+ rect.bottom = clamp(newBottom, container.top, container.bottom);
+ rect.width =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ }
+ } else if (ratioCorrectBasedOn === "height") {
+ if (newRight >= container.left && newRight <= container.right) {
+ rect.width = newWidth;
+ } else {
+ rect.right = clamp(newRight, container.left, container.right);
+ rect.height =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+ }
+ } else if (ratioCorrectBasedOn === "smaller") {
+ if (currentRatio > targetRatio) {
+ // too tall
+ rect.width =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ } else {
+ rect.height =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+ }
+ } else {
+ if (currentRatio < targetRatio) {
+ // too wide
+ rect.width =
+ (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio;
+ } else {
+ rect.height =
+ Math.sign(rect.height) * rect.normalizedWidth * targetRatio;
+ }
+ }
+ }
+
+ return rect;
+}
diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts
deleted file mode 100644
index a59f7167..00000000
--- a/FrontEnd/src/utilities/hooks.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import useClickOutside from "./hooks/useClickOutside";
-import useScrollToBottom from "./hooks/useScrollToBottom";
-import { useIsSmallScreen } from "./hooks/mediaQuery";
-
-export { useClickOutside, useScrollToBottom, useIsSmallScreen };
diff --git a/FrontEnd/src/utilities/hooks/mediaQuery.ts b/FrontEnd/src/utilities/hooks/mediaQuery.ts
deleted file mode 100644
index ad55c3c0..00000000
--- a/FrontEnd/src/utilities/hooks/mediaQuery.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { useMediaQuery } from "react-responsive";
-
-export function useIsSmallScreen(): boolean {
- return useMediaQuery({ maxWidth: 576 });
-}
diff --git a/FrontEnd/src/utilities/index.ts b/FrontEnd/src/utilities/index.ts
new file mode 100644
index 00000000..7659a8aa
--- /dev/null
+++ b/FrontEnd/src/utilities/index.ts
@@ -0,0 +1,27 @@
+export { default as base64 } from "./base64";
+export { withQuery } from "./url";
+
+export function delay(milliseconds: number): Promise<void> {
+ return new Promise<void>((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, milliseconds);
+ });
+}
+
+export function range(stop: number): number[];
+export function range(start: number, stop: number, step?: number): number[];
+export function range(start: number, stop?: number, step?: number): number[] {
+ if (stop == undefined) {
+ stop = start;
+ start = 0;
+ }
+ if (step == undefined) {
+ step = 1;
+ }
+ const result: number[] = [];
+ for (let i = start; i < stop; i += step) {
+ result.push(i);
+ }
+ return result;
+}
diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png
deleted file mode 100644
index d890d8d0..00000000
--- a/FrontEnd/src/views/about/author-avatar.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png
deleted file mode 100644
index ea6ff545..00000000
--- a/FrontEnd/src/views/about/github.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/views/about/index.css b/FrontEnd/src/views/about/index.css
deleted file mode 100644
index 2574f4b7..00000000
--- a/FrontEnd/src/views/about/index.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.about-link-icon {
- width: 1.2em;
- height: 1.2em;
-}
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx
deleted file mode 100644
index 093da894..00000000
--- a/FrontEnd/src/views/about/index.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useTranslation, Trans } from "react-i18next";
-
-import authorAvatarUrl from "./author-avatar.png";
-import githubLogoUrl from "./github.png";
-
-import Card from "../common/Card";
-
-import "./index.css";
-
-const frontendCredits: {
- name: string;
- url: string;
-}[] = [
- {
- name: "reactjs",
- url: "https://reactjs.org",
- },
- {
- name: "typescript",
- url: "https://www.typescriptlang.org",
- },
- {
- name: "bootstrap",
- url: "https://getbootstrap.com",
- },
- {
- name: "vite",
- url: "https://vitejs.dev",
- },
- {
- name: "eslint",
- url: "https://eslint.org",
- },
- {
- name: "prettier",
- url: "https://prettier.io",
- },
- {
- name: "pepjs",
- url: "https://github.com/jquery/PEP",
- },
-];
-
-const backendCredits: {
- name: string;
- url: string;
-}[] = [
- {
- name: "ASP.NET Core",
- url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core",
- },
- { name: "sqlite", url: "https://sqlite.org" },
- {
- name: "ImageSharp",
- url: "https://github.com/SixLabors/ImageSharp",
- },
-];
-
-export default function AboutPage() {
- const { t } = useTranslation();
-
- return (
- <div className="px-2 mb-4">
- <Card className="container mt-4 py-3">
- <h4 id="author-info">{t("about.author.title")}</h4>
- <div>
- <div className="d-block">
- <img
- src={authorAvatarUrl}
- className="cru-avatar large cru-round cru-float-left"
- />
- <p>
- <small>{t("about.author.name")}</small>
- <span className="cru-color-primary">crupest</span>
- </p>
- <p>
- <small>{t("about.author.introduction")}</small>
- {t("about.author.introductionContent")}
- </p>
- </div>
- <p>
- <small>{t("about.author.links")}</small>
- <a
- href="https://github.com/crupest"
- target="_blank"
- rel="noopener noreferrer"
- >
- <img src={githubLogoUrl} className="about-link-icon" />
- </a>
- </p>
- </div>
- </Card>
- <Card className="container mt-4 py-3">
- <h4>{t("about.site.title")}</h4>
- <p>
- <Trans i18nKey="about.site.content">
- 0<span className="cru-color-primary">1</span>2<b>3</b>4
- <a href="#author-info">5</a>6
- </Trans>
- </p>
- <p>
- <a
- href="https://github.com/crupest/Timeline"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t("about.site.repo")}
- </a>
- </p>
- </Card>
- <Card className="container mt-4 py-3">
- <h4>{t("about.credits.title")}</h4>
- <p>{t("about.credits.content")}</p>
- <p>{t("about.credits.frontend")}</p>
- <ul>
- {frontendCredits.map((item, index) => {
- return (
- <li key={index}>
- <a href={item.url} target="_blank" rel="noopener noreferrer">
- {item.name}
- </a>
- </li>
- );
- })}
- <li>...</li>
- </ul>
- <p>{t("about.credits.backend")}</p>
- <ul>
- {backendCredits.map((item, index) => {
- return (
- <li key={index}>
- <a href={item.url} target="_blank" rel="noopener noreferrer">
- {item.name}
- </a>
- </li>
- );
- })}
- <li>...</li>
- </ul>
- </Card>
- </div>
- );
-}
diff --git a/FrontEnd/src/views/common/AppBar.css b/FrontEnd/src/views/common/AppBar.css
deleted file mode 100644
index 3ec4fa36..00000000
--- a/FrontEnd/src/views/common/AppBar.css
+++ /dev/null
@@ -1,95 +0,0 @@
-.app-bar {
- display: flex;
- align-items: center;
- height: 56px;
- position: fixed;
- z-index: 1030;
- top: 0;
- left: 0;
- right: 0;
- background-color: var(--cru-primary-color);
- transition: background-color 1s;
-}
-
-.app-bar .cru-avatar {
- background-color: white;
-}
-
-.app-bar a {
- color: var(--cru-primary-t1-color);
- text-decoration: none;
- margin: 0 1em;
- transition: color 1s;
-}
-.app-bar a:hover {
- color: var(--cru-primary-t-color);
-}
-.app-bar a.active {
- color: var(--cru-primary-t-color);
-}
-
-.app-bar-brand {
- display: flex;
- align-items: center;
-}
-
-.app-bar-brand-icon {
- height: 2em;
-}
-
-.app-bar-main-area {
- display: flex;
- flex-grow: 1;
-}
-
-.app-bar-link-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
-}
-
-.app-bar-user-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- margin-left: auto;
-}
-
-.small-screen .app-bar-main-area {
- position: absolute;
- top: 56px;
- left: 0;
- right: 0;
- transform-origin: top;
- transition: transform 0.6s, background-color 1s;
- background-color: var(--cru-primary-color);
- flex-direction: column;
-}
-.small-screen .app-bar-main-area.app-bar-collapse {
- transform: scale(1, 0);
-}
-.small-screen .app-bar-main-area a {
- text-align: left;
- padding: 0.5em 0.5em;
-}
-.small-screen .app-bar-link-area {
- flex-direction: column;
- align-items: stretch;
-}
-.small-screen .app-bar-user-area {
- flex-direction: column;
- align-items: stretch;
- margin-left: unset;
-}
-.small-screen .app-bar-avatar {
- align-self: flex-end;
-}
-
-.app-bar-toggler {
- margin-left: auto;
- font-size: 2em;
- margin-right: 1em;
- color: var(--cru-primary-t-color);
- cursor: pointer;
- user-select: none;
-}
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx
deleted file mode 100644
index 278c70fd..00000000
--- a/FrontEnd/src/views/common/AppBar.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-import { Link, NavLink } from "react-router-dom";
-import { useMediaQuery } from "react-responsive";
-
-import { useUser } from "@/services/user";
-
-import TimelineLogo from "./TimelineLogo";
-import UserAvatar from "./user/UserAvatar";
-
-import "./AppBar.css";
-
-const AppBar: React.FC = () => {
- const { t } = useTranslation();
-
- const user = useUser();
- const hasAdministrationPermission = user && user.hasAdministrationPermission;
-
- const isSmallScreen = useMediaQuery({ maxWidth: 576 });
-
- const [expand, setExpand] = React.useState<boolean>(false);
- const collapse = (): void => setExpand(false);
- const toggleExpand = (): void => setExpand(!expand);
-
- const createLink = (
- link: string,
- label: React.ReactNode,
- className?: string
- ): React.ReactNode => (
- <NavLink
- to={link}
- onClick={collapse}
- className={({ isActive }) => classnames(className, isActive && "active")}
- >
- {label}
- </NavLink>
- );
-
- return (
- <nav className={classnames("app-bar", isSmallScreen && "small-screen")}>
- <Link to="/" className="app-bar-brand active">
- <TimelineLogo className="app-bar-brand-icon" />
- Timeline
- </Link>
-
- {isSmallScreen && (
- <i className="bi-list app-bar-toggler" onClick={toggleExpand} />
- )}
-
- <div
- className={classnames(
- "app-bar-main-area",
- !expand && "app-bar-collapse"
- )}
- >
- <div className="app-bar-link-area">
- {createLink("/settings", t("nav.settings"))}
- {createLink("/about", t("nav.about"))}
- {hasAdministrationPermission &&
- createLink("/admin", t("nav.administration"))}
- </div>
-
- <div className="app-bar-user-area">
- {user != null
- ? createLink(
- "/",
- <UserAvatar
- username={user.username}
- className="cru-avatar small cru-round cursor-pointer ml-auto"
- />,
- "app-bar-avatar"
- )
- : createLink("/login", t("nav.login"))}
- </div>
- </div>
- </nav>
- );
-};
-
-export default AppBar;
diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx
deleted file mode 100644
index 5e050ebe..00000000
--- a/FrontEnd/src/views/common/BlobImage.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import * as React from "react";
-
-const BlobImage: React.FC<
- Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
- blob?: Blob | unknown;
- }
-> = (props) => {
- const { blob, ...otherProps } = props;
-
- const [url, setUrl] = React.useState<string | undefined>(undefined);
-
- React.useEffect(() => {
- if (blob instanceof Blob) {
- const url = URL.createObjectURL(blob);
- setUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setUrl(undefined);
- }
- }, [blob]);
-
- return <img {...otherProps} src={url} />;
-};
-
-export default BlobImage;
diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css
deleted file mode 100644
index 6de0dd8e..00000000
--- a/FrontEnd/src/views/common/Card.css
+++ /dev/null
@@ -1,15 +0,0 @@
-:root {
- --cru-card-border-radius: 8px;
-}
-
-.cru-card {
- border: 1px solid;
- border-color: #e9ecef;
- border-radius: var(--cru-card-border-radius);
- background: #fefeff;
- transition: all 0.3s;
-}
-
-.cru-card:hover {
- border-color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx
deleted file mode 100644
index ebbce77e..00000000
--- a/FrontEnd/src/views/common/Card.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import classNames from "classnames";
-import * as React from "react";
-
-import "./Card.css";
-
-function _Card(
- {
- className,
- children,
- ...otherProps
- }: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>,
- ref: React.ForwardedRef<HTMLDivElement>
-): React.ReactElement | null {
- return (
- <div
- ref={ref}
- className={classNames("cru-card", className)}
- {...otherProps}
- >
- {children}
- </div>
- );
-}
-
-const Card = React.forwardRef(_Card);
-
-export default Card;
diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx
deleted file mode 100644
index 04e17415..00000000
--- a/FrontEnd/src/views/common/ImageCropper.tsx
+++ /dev/null
@@ -1,306 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { UiLogicError } from "@/common";
-
-import "./ImageCropper.css";
-
-export interface Clip {
- left: number;
- top: number;
- width: number;
-}
-
-interface NormailizedClip extends Clip {
- height: number;
-}
-
-interface ImageInfo {
- width: number;
- height: number;
- landscape: boolean;
- ratio: number;
- maxClipWidth: number;
- maxClipHeight: number;
-}
-
-interface ImageCropperSavedState {
- clip: NormailizedClip;
- x: number;
- y: number;
- pointerId: number;
-}
-
-export interface ImageCropperProps {
- clip: Clip | null;
- imageUrl: string;
- onChange: (clip: Clip) => void;
- imageElementCallback?: (element: HTMLImageElement | null) => void;
- className?: string;
-}
-
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
- const { clip, imageUrl, onChange, imageElementCallback, className } = props;
-
- const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
- null
- );
- const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
-
- const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
- if (c == null) {
- return { left: 0, top: 0, width: 0, height: 0 };
- }
-
- return {
- left: c.left || 0,
- top: c.top || 0,
- width: c.width || 0,
- height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
- };
- };
-
- const c = normalizeClip(clip);
-
- const imgElementRef = React.useRef<HTMLImageElement | null>(null);
-
- const onImageRef = React.useCallback(
- (e: HTMLImageElement | null) => {
- imgElementRef.current = e;
- if (imageElementCallback != null && e == null) {
- imageElementCallback(null);
- }
- },
- [imageElementCallback]
- );
-
- const onImageLoad = React.useCallback(
- (e: React.SyntheticEvent<HTMLImageElement>) => {
- const img = e.currentTarget;
- const landscape = img.naturalWidth >= img.naturalHeight;
-
- const info = {
- width: img.naturalWidth,
- height: img.naturalHeight,
- landscape,
- ratio: img.naturalHeight / img.naturalWidth,
- maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
- maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
- };
- setImageInfo(info);
- onChange({ left: 0, top: 0, width: info.maxClipWidth });
- if (imageElementCallback != null) {
- imageElementCallback(img);
- }
- },
- [onChange, imageElementCallback]
- );
-
- const onPointerDown = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState != null) return;
- e.currentTarget.setPointerCapture(e.pointerId);
- setOldState({
- x: e.clientX,
- y: e.clientY,
- clip: c,
- pointerId: e.pointerId,
- });
- },
- [oldState, c]
- );
-
- const onPointerUp = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null || oldState.pointerId !== e.pointerId) return;
- e.currentTarget.releasePointerCapture(e.pointerId);
- setOldState(null);
- },
- [oldState]
- );
-
- const onPointerMove = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null) return;
-
- const oldClip = oldState.clip;
-
- const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
- const { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError("Image element is null.");
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.y / imgElement.height,
- };
-
- const newRatio = {
- x: oldClip.left + moveRatio.x,
- y: oldClip.top + moveRatio.y,
- };
- if (newRatio.x < 0) {
- newRatio.x = 0;
- } else if (newRatio.x > 1 - oldClip.width) {
- newRatio.x = 1 - oldClip.width;
- }
- if (newRatio.y < 0) {
- newRatio.y = 0;
- } else if (newRatio.y > 1 - oldClip.height) {
- newRatio.y = 1 - oldClip.height;
- }
-
- onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
- },
- [oldState, onChange]
- );
-
- const onHandlerPointerMove = React.useCallback(
- (e: React.PointerEvent) => {
- if (oldState == null) return;
-
- const oldClip = oldState.clip;
-
- const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
- const ratio = imageInfo == null ? 1 : imageInfo.ratio;
-
- const { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError("Image element is null.");
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.x / imgElement.width / ratio,
- };
-
- const newRatio = {
- x: oldClip.width + moveRatio.x,
- y: oldClip.height + moveRatio.y,
- };
-
- const maxRatio = {
- x: Math.min(1 - oldClip.left, newRatio.x),
- y: Math.min(1 - oldClip.top, newRatio.y),
- };
-
- const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
-
- let newWidth;
- if (newRatio.x < 0) {
- newWidth = 0;
- } else if (newRatio.x > maxWidthRatio) {
- newWidth = maxWidthRatio;
- } else {
- newWidth = newRatio.x;
- }
-
- onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
- },
- [imageInfo, oldState, onChange]
- );
-
- const toPercentage = (n: number): string => `${n}%`;
-
- // fuck!!! I just can't find a better way to implement this in pure css
- const containerStyle: React.CSSProperties = (() => {
- if (imageInfo == null) {
- return { width: "100%", paddingTop: "100%", height: 0 };
- } else {
- if (imageInfo.ratio > 1) {
- return {
- width: toPercentage(100 / imageInfo.ratio),
- paddingTop: "100%",
- height: 0,
- };
- } else {
- return {
- width: "100%",
- paddingTop: toPercentage(100 * imageInfo.ratio),
- height: 0,
- };
- }
- }
- })();
-
- return (
- <div
- className={classnames("image-cropper-container", className)}
- style={containerStyle}
- >
- <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
- <div className="image-cropper-mask-container">
- <div
- className="image-cropper-mask"
- style={{
- left: toPercentage(c.left * 100),
- top: toPercentage(c.top * 100),
- width: toPercentage(c.width * 100),
- height: toPercentage(c.height * 100),
- }}
- onPointerMove={onPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- <div
- className="image-cropper-handler"
- style={{
- left: `calc(${(c.left + c.width) * 100}% - 15px)`,
- top: `calc(${(c.top + c.height) * 100}% - 15px)`,
- }}
- onPointerMove={onHandlerPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
- image: HTMLImageElement,
- clip: Clip,
- mimeType: string
-): Promise<Blob> {
- return new Promise((resolve, reject) => {
- const naturalSize = {
- width: image.naturalWidth,
- height: image.naturalHeight,
- };
- const clipArea = {
- x: naturalSize.width * clip.left,
- y: naturalSize.height * clip.top,
- length: naturalSize.width * clip.width,
- };
-
- const canvas = document.createElement("canvas");
- canvas.width = clipArea.length;
- canvas.height = clipArea.length;
- const context = canvas.getContext("2d");
-
- if (context == null) throw new Error("Failed to create context.");
-
- context.drawImage(
- image,
- clipArea.x,
- clipArea.y,
- clipArea.length,
- clipArea.length,
- 0,
- 0,
- clipArea.length,
- clipArea.length
- );
-
- canvas.toBlob((blob) => {
- if (blob == null) {
- reject(new Error("canvas.toBlob returns null"));
- } else {
- resolve(blob);
- }
- }, mimeType);
- });
-}
diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx
deleted file mode 100644
index 35ee1aa8..00000000
--- a/FrontEnd/src/views/common/LoadingPage.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as React from "react";
-
-import Spinner from "./Spinner";
-
-const LoadingPage: React.FC = () => {
- return (
- <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
- <Spinner />
- </div>
- );
-};
-
-export default LoadingPage;
diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx
deleted file mode 100644
index 9d644ab7..00000000
--- a/FrontEnd/src/views/common/SearchInput.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useCallback } from "react";
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import LoadingButton from "./button/LoadingButton";
-
-import "./SearchInput.css";
-
-export interface SearchInputProps {
- value: string;
- onChange: (value: string) => void;
- onButtonClick: () => void;
- className?: string;
- loading?: boolean;
- buttonText?: string;
- placeholder?: string;
- additionalButton?: React.ReactNode;
- alwaysOneline?: boolean;
-}
-
-const SearchInput: React.FC<SearchInputProps> = (props) => {
- const { onChange, onButtonClick, alwaysOneline } = props;
-
- const { t } = useTranslation();
-
- const onInputChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>): void => {
- onChange(event.currentTarget.value);
- },
- [onChange]
- );
-
- const onInputKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>): void => {
- if (event.key === "Enter") {
- onButtonClick();
- event.preventDefault();
- }
- },
- [onButtonClick]
- );
-
- return (
- <div
- className={classnames(
- "cru-search-input",
- alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap",
- props.className
- )}
- >
- <input
- type="text"
- className="cru-search-input-input me-sm-2 flex-grow-1"
- value={props.value}
- onChange={onInputChange}
- onKeyPress={onInputKeyPress}
- placeholder={props.placeholder}
- />
- {props.additionalButton ? (
- <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ms-sm-2">
- {props.additionalButton}
- </div>
- ) : null}
- <div
- className={classnames(
- alwaysOneline ? "mt-0 ms-2" : "mt-2 mt-sm-0 ms-auto ms-sm-0",
- "flex-shrink-0"
- )}
- >
- <LoadingButton loading={props.loading} onClick={props.onButtonClick}>
- {props.buttonText ?? t("search")}
- </LoadingButton>
- </div>
- </div>
- );
-};
-
-export default SearchInput;
diff --git a/FrontEnd/src/views/common/Skeleton.css b/FrontEnd/src/views/common/Skeleton.css
deleted file mode 100644
index db1a1c34..00000000
--- a/FrontEnd/src/views/common/Skeleton.css
+++ /dev/null
@@ -1,14 +0,0 @@
-.cru-skeleton {
- padding: 0 1em;
-}
-
-.cru-skeleton-line {
- height: 1em;
- background-color: #e6e6e6;
- margin: 0.7em 0;
- border-radius: 0.2em;
-}
-
-.cru-skeleton-line.last {
- width: 50%;
-}
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx
deleted file mode 100644
index 3b149db9..00000000
--- a/FrontEnd/src/views/common/Skeleton.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import range from "lodash/range";
-
-import "./Skeleton.css";
-
-export interface SkeletonProps {
- lineNumber?: number;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const Skeleton: React.FC<SkeletonProps> = (props) => {
- const { lineNumber: lineNumberProps, className, style } = props;
- const lineNumber = lineNumberProps ?? 3;
-
- return (
- <div className={classnames(className, "cru-skeleton")} style={style}>
- {range(lineNumber).map((i) => (
- <div
- key={i}
- className={classnames(
- "cru-skeleton-line",
- i === lineNumber - 1 && "last"
- )}
- />
- ))}
- </div>
- );
-};
-
-export default Skeleton;
diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx
deleted file mode 100644
index e99a9d1b..00000000
--- a/FrontEnd/src/views/common/Spinner.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { PaletteColorType } from "@/palette";
-
-import "./Spinner.css";
-
-export interface SpinnerProps {
- size?: "sm" | "md" | "lg" | number | string;
- color?: PaletteColorType;
- className?: string;
- style?: React.CSSProperties;
-}
-
-export default function Spinner(
- props: SpinnerProps
-): React.ReactElement | null {
- const { size, color, className, style } = props;
- const calculatedSize =
- size === "sm"
- ? "18px"
- : size === "md"
- ? "30px"
- : size === "lg"
- ? "42px"
- : typeof size === "number"
- ? size
- : size == null
- ? "20px"
- : size;
- const calculatedColor = color ?? "primary";
-
- return (
- <span
- className={classnames(
- "cru-spinner",
- `cru-color-${calculatedColor}`,
- className
- )}
- style={{ width: calculatedSize, height: calculatedSize, ...style }}
- />
- );
-}
diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx
deleted file mode 100644
index 42074781..00000000
--- a/FrontEnd/src/views/common/alert/AlertHost.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import * as React from "react";
-import without from "lodash/without";
-import { useTranslation } from "react-i18next";
-import classNames from "classnames";
-
-import { alertService, AlertInfoEx, AlertInfo } from "@/services/alert";
-import { convertI18nText } from "@/common";
-
-import IconButton from "../button/IconButton";
-
-import "./alert.css";
-
-interface AutoCloseAlertProps {
- alert: AlertInfo;
- close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
- const { alert, close } = props;
- const { dismissTime } = alert;
-
- const { t } = useTranslation();
-
- const timerTag = React.useRef<number | null>(null);
- const closeHandler = React.useRef<(() => void) | null>(null);
-
- React.useEffect(() => {
- closeHandler.current = close;
- }, [close]);
-
- React.useEffect(() => {
- const tag =
- dismissTime === "never"
- ? null
- : typeof dismissTime === "number"
- ? window.setTimeout(() => closeHandler.current?.(), dismissTime)
- : window.setTimeout(() => closeHandler.current?.(), 5000);
- timerTag.current = tag;
- return () => {
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
- }, [dismissTime]);
-
- const cancelTimer = (): void => {
- const { current: tag } = timerTag;
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
-
- return (
- <div
- className={classNames(
- "m-3 cru-alert",
- "cru-" + (alert.type ?? "primary")
- )}
- onClick={cancelTimer}
- >
- <div className="cru-alert-content">
- {(() => {
- const { message, customMessage } = alert;
- if (customMessage != null) {
- return customMessage;
- } else {
- return convertI18nText(message, t);
- }
- })()}
- </div>
- <div className="cru-alert-close-button-container">
- <IconButton
- icon="x"
- className="cru-alert-close-button"
- onClick={close}
- />
- </div>
- </div>
- );
-};
-
-const AlertHost: React.FC = () => {
- const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
-
- React.useEffect(() => {
- const consume = (alert: AlertInfoEx): void => {
- setAlerts((old) => [...old, alert]);
- };
-
- alertService.registerConsumer(consume);
- return () => {
- alertService.unregisterConsumer(consume);
- };
- }, []);
-
- return (
- <div className="alert-container">
- {alerts.map((alert) => {
- return (
- <AutoCloseAlert
- key={alert.id}
- alert={alert}
- close={() => {
- setAlerts((old) => without(old, alert));
- }}
- />
- );
- })}
- </div>
- );
-};
-
-export default AlertHost;
diff --git a/FrontEnd/src/views/common/alert/alert.css b/FrontEnd/src/views/common/alert/alert.css
deleted file mode 100644
index fc15e3cb..00000000
--- a/FrontEnd/src/views/common/alert/alert.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.alert-container {
- position: fixed;
- z-index: 1040;
-}
-
-.cru-alert {
- border-radius: 5px;
- border: var(--cru-theme-color) 1px solid;
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-r1-color);
-
- display: flex;
- overflow: hidden;
-}
-
-.cru-alert-content {
- padding: 0.5em 2em;
-}
-
-.cru-alert-close-button-container {
- flex-shrink: 0;
- margin-left: auto;
- width: 2em;
- text-align: center;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: var(--cru-theme-t-color);
-}
-
-.cru-alert-close-button {
- color: var(--cru-theme-color);
-}
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css
deleted file mode 100644
index c34176f6..00000000
--- a/FrontEnd/src/views/common/button/Button.css
+++ /dev/null
@@ -1,51 +0,0 @@
-.cru-button:not(.outline) {
- color: var(--cru-theme-t-color);
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- transition: all 0.5s;
- background-color: var(--cru-theme-color);
-}
-
-.cru-button:not(.outline):hover {
- background-color: var(--cru-theme-f1-color);
-}
-
-.cru-button:not(.outline):active {
- background-color: var(--cru-theme-f2-color);
-}
-
-.cru-button:not(.outline):disabled {
- background-color: var(--cru-disable-color);
- cursor: auto;
-}
-
-.cru-button.outline {
- color: var(--cru-theme-color);
- border: var(--cru-theme-color) 1px solid;
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- transition: all 0.6s;
- background-color: white;
-}
-
-.cru-button.outline:hover {
- color: var(--cru-theme-f1-color);
- border-color: var(--cru-theme-f1-color);
- background-color: var(--cru-background-color);
-}
-
-.cru-button.outline:active {
- color: var(--cru-theme-f2-color);
- border-color: var(--cru-theme-f2-color);
- background-color: var(--cru-background-1-color);
-}
-
-.cru-button.outline:disabled {
- color: var(--cru-disable-color);
- border-color: var(--cru-disable-color);
- background-color: white;
- cursor: auto;
-}
diff --git a/FrontEnd/src/views/common/button/FlatButton.css b/FrontEnd/src/views/common/button/FlatButton.css
deleted file mode 100644
index f0d33153..00000000
--- a/FrontEnd/src/views/common/button/FlatButton.css
+++ /dev/null
@@ -1,18 +0,0 @@
-.cru-flat-button {
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- background-color: transparent;
- transition: all 0.6s;
- color: var(--cru-theme-color);
-}
-
-.cru-flat-button.disabled {
- color: var(--cru-theme-l1-color);
- cursor: default;
-}
-
-.cru-flat-button:hover:not(.disabled) {
- background-color: #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/button/IconButton.css b/FrontEnd/src/views/common/button/IconButton.css
deleted file mode 100644
index 45fb103c..00000000
--- a/FrontEnd/src/views/common/button/IconButton.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.cru-icon-button {
- color: var(--cru-theme-color);
- font-size: 1.4rem;
- background: none;
- border: none;
-}
-
-.cru-icon-button.large {
- font-size: 1.6rem;
-}
diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx
deleted file mode 100644
index fceaec27..00000000
--- a/FrontEnd/src/views/common/button/LoadingButton.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import * as React from "react";
-import classNames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { convertI18nText, I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
-
-import Spinner from "../Spinner";
-
-interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
- color?: PaletteColorType;
- text?: I18nText;
- loading?: boolean;
-}
-
-function LoadingButton(props: LoadingButtonProps): JSX.Element {
- const { t } = useTranslation();
-
- const { color, text, loading, className, children, ...otherProps } = props;
-
- if (text != null && children != null) {
- console.warn("You can't set both text and children props.");
- }
-
- return (
- <button
- className={classNames(
- "cru-" + (color ?? "primary"),
- "cru-button outline",
- className,
- )}
- {...otherProps}
- >
- {text != null ? convertI18nText(text, t) : children}
- {loading && <Spinner />}
- </button>
- );
-}
-
-export default LoadingButton;
diff --git a/FrontEnd/src/views/common/button/index.tsx b/FrontEnd/src/views/common/button/index.tsx
deleted file mode 100644
index cff5ba3f..00000000
--- a/FrontEnd/src/views/common/button/index.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import Button from "./Button";
-import FlatButton from "./FlatButton";
-import IconButton from "./IconButton";
-import LoadingButton from "./LoadingButton";
-
-export { Button, FlatButton, IconButton, LoadingButton };
diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
deleted file mode 100644
index 8c2cea5a..00000000
--- a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { convertI18nText, I18nText } from "@/common";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import Button from "../button/Button";
-import Dialog from "./Dialog";
-
-const ConfirmDialog: React.FC<{
- open: boolean;
- onClose: () => void;
- onConfirm: () => void;
- title: I18nText;
- body: I18nText;
-}> = ({ open, onClose, onConfirm, title, body }) => {
- const { t } = useTranslation();
-
- return (
- <Dialog onClose={onClose} open={open}>
- <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3>
- <hr />
- <p>{convertI18nText(body, t)}</p>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={onClose}
- />
- <Button
- text="operationDialog.confirm"
- color="danger"
- onClick={() => {
- onConfirm();
- onClose();
- }}
- />
- </div>
- </Dialog>
- );
-};
-
-export default ConfirmDialog;
diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css
deleted file mode 100644
index 21ea52fc..00000000
--- a/FrontEnd/src/views/common/dialog/Dialog.css
+++ /dev/null
@@ -1,55 +0,0 @@
-.cru-dialog-overlay {
- position: fixed;
- z-index: 1040;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(255, 255, 255, 0.92);
-
- display: flex;
- padding: 2em;
-
- overflow: auto;
-}
-
-.cru-dialog-container {
- max-width: 100%;
- min-width: 30vw;
-
- margin: auto;
-
- border: var(--cru-primary-color) 1px solid;
- border-radius: 5px;
- padding: 1.5em;
- background-color: white;
-}
-
-.cru-dialog-bottom-area {
- display: flex;
- justify-content: flex-end;
-}
-
-.cru-dialog-bottom-area > * {
- margin: 0 0.5em;
-}
-
-.cru-dialog-enter .cru-dialog-container {
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
-
-.cru-dialog-enter-active .cru-dialog-container {
- transform: scale(1, 1);
- opacity: 1;
- transition: transform 0.3s, opacity 0.3s;
- transform-origin: center;
-}
-
-.cru-dialog-exit-active .cru-dialog-container {
- transition: transform 0.3s, opacity 0.3s;
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx
deleted file mode 100644
index 923c636b..00000000
--- a/FrontEnd/src/views/common/dialog/Dialog.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { ReactNode } from "react";
-import ReactDOM from "react-dom";
-import { CSSTransition } from "react-transition-group";
-
-import "./Dialog.css";
-
-const optionalPortalElement = document.getElementById("portal");
-if (optionalPortalElement == null) {
- throw new Error("Portal element not found");
-}
-const portalElement = optionalPortalElement;
-
-interface DialogProps {
- onClose: () => void;
- open: boolean;
- children?: ReactNode;
- disableCloseOnClickOnOverlay?: boolean;
-}
-
-export default function Dialog(props: DialogProps) {
- const { open, onClose, children, disableCloseOnClickOnOverlay } = props;
-
- return ReactDOM.createPortal(
- <CSSTransition
- mountOnEnter
- unmountOnExit
- in={open}
- timeout={300}
- classNames="cru-dialog"
- >
- <div
- className="cru-dialog-overlay"
- onPointerDown={
- disableCloseOnClickOnOverlay
- ? undefined
- : () => {
- onClose();
- }
- }
- >
- <div
- className="cru-dialog-container"
- onPointerDown={(e) => e.stopPropagation()}
- >
- {children}
- </div>
- </div>
- </CSSTransition>,
- portalElement,
- );
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css
deleted file mode 100644
index 2f1fc636..00000000
--- a/FrontEnd/src/views/common/dialog/FullPageDialog.css
+++ /dev/null
@@ -1,44 +0,0 @@
-.cru-full-page {
- position: fixed;
- z-index: 1030;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: white;
- padding-top: 56px;
-}
-
-.cru-full-page-top-bar {
- height: 56px;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1;
- background-color: var(--cru-primary-color);
- display: flex;
- align-items: center;
-}
-
-.cru-full-page-content-container {
- overflow: scroll;
-}
-
-.cru-full-page-back-button {
- color: var(--cru-primary-t-color);
-}
-
-.cru-full-page-enter {
- transform: translate(100%, 0);
-}
-
-.cru-full-page-enter-active {
- transform: none;
- transition: transform 0.3s;
-}
-
-.cru-full-page-exit-active {
- transition: transform 0.3s;
- transform: translate(100%, 0);
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx
deleted file mode 100644
index 6368fc0a..00000000
--- a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as React from "react";
-import { createPortal } from "react-dom";
-import classnames from "classnames";
-import { CSSTransition } from "react-transition-group";
-
-import "./FullPageDialog.css";
-import IconButton from "../button/IconButton";
-
-export interface FullPageDialogProps {
- show: boolean;
- onBack: () => void;
- contentContainerClassName?: string;
- children: React.ReactNode;
-}
-
-const FullPageDialog: React.FC<FullPageDialogProps> = ({
- show,
- onBack,
- children,
- contentContainerClassName,
-}) => {
- return createPortal(
- <CSSTransition
- mountOnEnter
- unmountOnExit
- in={show}
- timeout={300}
- classNames="cru-full-page"
- >
- <div className="cru-full-page">
- <div className="cru-full-page-top-bar">
- <IconButton
- icon="arrow-left"
- className="ms-3 cru-full-page-back-button"
- onClick={onBack}
- />
- </div>
- <div
- className={classnames(
- "cru-full-page-content-container",
- contentContainerClassName
- )}
- >
- {children}
- </div>
- </div>
- </CSSTransition>,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- document.getElementById("portal")!
- );
-};
-
-export default FullPageDialog;
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css
deleted file mode 100644
index 2f7617d0..00000000
--- a/FrontEnd/src/views/common/dialog/OperationDialog.css
+++ /dev/null
@@ -1,25 +0,0 @@
-.cru-operation-dialog-group {
- display: block;
- margin: 0.4em 0;
-}
-
-.cru-operation-dialog-label {
- display: block;
- color: var(--cru-primary-color);
-}
-
-.cru-operation-dialog-inline-label {
- margin-inline-start: 0.5em;
-}
-
-.cru-operation-dialog-error-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-danger-color);
-}
-
-.cru-operation-dialog-helper-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
deleted file mode 100644
index 71be030a..00000000
--- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx
+++ /dev/null
@@ -1,531 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { TwitterPicker } from "react-color";
-import classNames from "classnames";
-import moment from "moment";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-
-import { PaletteColorType } from "@/palette";
-
-import Button from "../button/Button";
-import LoadingButton from "../button/LoadingButton";
-import Dialog from "./Dialog";
-
-import "./OperationDialog.css";
-
-interface DefaultErrorPromptProps {
- error?: string;
-}
-
-const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
- const { t } = useTranslation();
-
- let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>;
-
- if (props.error != null) {
- result = (
- <>
- {result}
- <p className="cru-color-danger">{props.error}</p>
- </>
- );
- }
-
- return result;
-};
-
-export interface OperationDialogTextInput {
- type: "text";
- label?: I18nText;
- password?: boolean;
- initValue?: string;
- textFieldProps?: Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
- "type" | "value" | "onChange"
- >;
- helperText?: string;
-}
-
-export interface OperationDialogBoolInput {
- type: "bool";
- label: I18nText;
- initValue?: boolean;
- helperText?: string;
-}
-
-export interface OperationDialogSelectInputOption {
- value: string;
- label: I18nText;
- icon?: React.ReactElement;
-}
-
-export interface OperationDialogSelectInput {
- type: "select";
- label: I18nText;
- options: OperationDialogSelectInputOption[];
- initValue?: string;
-}
-
-export interface OperationDialogColorInput {
- type: "color";
- label?: I18nText;
- initValue?: string | null;
- canBeNull?: boolean;
-}
-
-export interface OperationDialogDateTimeInput {
- type: "datetime";
- label?: I18nText;
- initValue?: string;
- helperText?: string;
-}
-
-export type OperationDialogInput =
- | OperationDialogTextInput
- | OperationDialogBoolInput
- | OperationDialogSelectInput
- | OperationDialogColorInput
- | OperationDialogDateTimeInput;
-
-interface OperationInputTypeStringToValueTypeMap {
- text: string;
- bool: boolean;
- select: string;
- color: string | null;
- datetime: string;
-}
-
-type MapOperationInputTypeStringToValueType<Type> =
- Type extends keyof OperationInputTypeStringToValueTypeMap
- ? OperationInputTypeStringToValueTypeMap[Type]
- : never;
-
-type MapOperationInputInfoValueType<T> = T extends OperationDialogInput
- ? MapOperationInputTypeStringToValueType<T["type"]>
- : T;
-
-const initValueMapperMap: {
- [T in OperationDialogInput as T["type"]]: (
- item: T
- ) => MapOperationInputInfoValueType<T>;
-} = {
- bool: (item) => item.initValue ?? false,
- color: (item) => item.initValue ?? null,
- datetime: (item) => {
- if (item.initValue != null) {
- return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss");
- } else {
- return "";
- }
- },
- select: (item) => item.initValue ?? item.options[0].value,
- text: (item) => item.initValue ?? "",
-};
-
-type MapOperationInputInfoValueTypeList<
- Tuple extends readonly OperationDialogInput[]
-> = {
- [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>;
-} & { length: Tuple["length"] };
-
-export type OperationInputError =
- | {
- [index: number]: I18nText | null | undefined;
- }
- | null
- | undefined;
-
-const isNoError = (error: OperationInputError): boolean => {
- if (error == null) return true;
- for (const key in error) {
- if (error[key] != null) return false;
- }
- return true;
-};
-
-export interface OperationDialogProps<
- TData,
- OperationInputInfoList extends readonly OperationDialogInput[]
-> {
- open: boolean;
- onClose: () => void;
- title: I18nText | (() => React.ReactNode);
- themeColor?: PaletteColorType;
- onProcess: (
- inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
- ) => Promise<TData>;
- inputScheme?: OperationInputInfoList;
- inputValidator?: (
- inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
- ) => OperationInputError;
- inputPrompt?: I18nText | (() => React.ReactNode);
- processPrompt?: () => React.ReactNode;
- successPrompt?: (data: TData) => React.ReactNode;
- failurePrompt?: (error: unknown) => React.ReactNode;
- onSuccessAndClose?: (data: TData) => void;
-}
-
-const OperationDialog = <
- TData,
- OperationInputInfoList extends readonly OperationDialogInput[]
->(
- props: OperationDialogProps<TData, OperationInputInfoList>
-): React.ReactElement => {
- const inputScheme = (props.inputScheme ??
- []) as readonly OperationDialogInput[];
-
- const { t } = useTranslation();
-
- type Step =
- | { type: "input" }
- | { type: "process" }
- | {
- type: "success";
- data: TData;
- }
- | {
- type: "failure";
- data: unknown;
- };
- const [step, setStep] = useState<Step>({ type: "input" });
-
- type ValueType = boolean | string | null | undefined;
-
- const [values, setValues] = useState<ValueType[]>(
- inputScheme.map((item) => {
- if (item.type in initValueMapperMap) {
- return (
- initValueMapperMap[item.type] as (
- i: OperationDialogInput
- ) => ValueType
- )(item);
- } else {
- throw new UiLogicError("Unknown input scheme.");
- }
- })
- );
- const [dirtyList, setDirtyList] = useState<boolean[]>(() =>
- inputScheme.map(() => false)
- );
- const [inputError, setInputError] = useState<OperationInputError>();
-
- const close = (): void => {
- if (step.type !== "process") {
- props.onClose();
- if (step.type === "success" && props.onSuccessAndClose) {
- props.onSuccessAndClose(step.data);
- }
- } else {
- console.log("Attempt to close modal when processing.");
- }
- };
-
- const onConfirm = (): void => {
- setStep({ type: "process" });
- props
- .onProcess(
- values.map((v, index) => {
- if (inputScheme[index].type === "datetime" && v !== "")
- return new Date(v as string).toISOString();
- else return v;
- }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
- )
- .then(
- (d) => {
- setStep({
- type: "success",
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: "failure",
- data: e,
- });
- }
- );
- };
-
- let body: React.ReactNode;
- if (step.type === "input" || step.type === "process") {
- const process = step.type === "process";
-
- let inputPrompt =
- typeof props.inputPrompt === "function"
- ? props.inputPrompt()
- : convertI18nText(props.inputPrompt, t);
- inputPrompt = <h6>{inputPrompt}</h6>;
-
- const validate = (values: ValueType[]): boolean => {
- const { inputValidator } = props;
- if (inputValidator != null) {
- const result = inputValidator(
- values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
- );
- setInputError(result);
- return isNoError(result);
- }
- return true;
- };
-
- const updateValue = (index: number, newValue: ValueType): void => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- setValues(newValues);
- if (dirtyList[index] === false) {
- const newDirtyList = dirtyList.slice();
- newDirtyList[index] = true;
- setDirtyList(newDirtyList);
- }
- validate(newValues);
- };
-
- const canProcess = isNoError(inputError);
-
- body = (
- <>
- <div>
- {inputPrompt}
- {inputScheme.map((item, index) => {
- const value = values[index];
- const error: string | null =
- dirtyList[index] && inputError != null
- ? convertI18nText(inputError[index], t)
- : null;
-
- if (item.type === "text") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- {item.label && (
- <label className="cru-operation-dialog-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type={item.password === true ? "password" : "text"}
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={process}
- />
- {error != null && (
- <div className="cru-operation-dialog-error-text">
- {error}
- </div>
- )}
- {item.helperText && (
- <div className="cru-operation-dialog-helper-text">
- {t(item.helperText)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "bool") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- <input
- type="checkbox"
- checked={value as boolean}
- onChange={(event) => {
- updateValue(index, event.currentTarget.checked);
- }}
- disabled={process}
- />
- <label className="cru-operation-dialog-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- {error != null && (
- <div className="cru-operation-dialog-error-text">
- {error}
- </div>
- )}
- {item.helperText && (
- <div className="cru-operation-dialog-helper-text">
- {t(item.helperText)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "select") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- <label className="cru-operation-dialog-label">
- {convertI18nText(item.label, t)}
- </label>
- <select
- value={value as string}
- onChange={(event) => {
- updateValue(index, event.target.value);
- }}
- disabled={process}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {convertI18nText(option.label, t)}
- </option>
- );
- })}
- </select>
- </div>
- );
- } else if (item.type === "color") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- {item.canBeNull ? (
- <input
- type="checkbox"
- checked={value !== null}
- onChange={(event) => {
- if (event.currentTarget.checked) {
- updateValue(index, "#007bff");
- } else {
- updateValue(index, null);
- }
- }}
- disabled={process}
- />
- ) : null}
- <label className="cru-operation-dialog-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- {value !== null && (
- <TwitterPicker
- color={value as string}
- triangle="hide"
- onChange={(result) => updateValue(index, result.hex)}
- />
- )}
- </div>
- );
- } else if (item.type === "datetime") {
- return (
- <div
- key={index}
- className={classNames(
- "cru-operation-dialog-group",
- error != null ? "error" : null
- )}
- >
- {item.label && (
- <label className="cru-operation-dialog-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type="datetime-local"
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={process}
- />
- {error != null && <div>{error}</div>}
- </div>
- );
- }
- })}
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={close}
- disabled={process}
- />
- <LoadingButton
- color={props.themeColor}
- loading={process}
- disabled={!canProcess}
- onClick={() => {
- setDirtyList(inputScheme.map(() => true));
- if (validate(values)) {
- onConfirm();
- }
- }}
- >
- {t("operationDialog.confirm")}
- </LoadingButton>
- </div>
- </>
- );
- } else {
- let content: React.ReactNode;
- const result = step;
- if (result.type === "success") {
- content =
- props.successPrompt?.(result.data) ?? t("operationDialog.success");
- if (typeof content === "string")
- content = <p className="cru-color-success">{content}</p>;
- } else {
- content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
- if (typeof content === "string")
- content = <DefaultErrorPrompt error={content} />;
- }
- body = (
- <>
- <div>{content}</div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button text="operationDialog.ok" color="primary" onClick={close} />
- </div>
- </>
- );
- }
-
- const title =
- typeof props.title === "function"
- ? props.title()
- : convertI18nText(props.title, t);
-
- return (
- <Dialog open={props.open} onClose={close}>
- <h3
- className={
- props.themeColor != null
- ? "cru-color-" + props.themeColor
- : "cru-color-primary"
- }
- >
- {title}
- </h3>
- <hr />
- {body}
- </Dialog>
- );
-};
-
-export default OperationDialog;
diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css
deleted file mode 100644
index 111a3ec0..00000000
--- a/FrontEnd/src/views/common/index.css
+++ /dev/null
@@ -1,293 +0,0 @@
-:root {
- --cru-background-color: #f8f9fa;
- --cru-background-1-color: #e9ecef;
- --cru-background-2-color: #dee2e6;
-
- --cru-disable-color: #ced4da;
-
- /*
- --cru-primary-color: rgb(0, 123, 255);
- --cru-primary-l1-color: rgb(26, 136, 255);
- --cru-primary-l2-color: rgb(51, 149, 255);
- --cru-primary-l3-color: rgb(77, 163, 255);
- --cru-primary-d1-color: rgb(0, 111, 230);
- --cru-primary-d2-color: rgb(0, 98, 204);
- --cru-primary-d3-color: rgb(0, 86, 179);
- --cru-primary-f1-color: rgb(0, 111, 230);
- --cru-primary-f2-color: rgb(0, 98, 204);
- --cru-primary-f3-color: rgb(0, 86, 179);
- --cru-primary-r1-color: rgb(26, 136, 255);
- --cru-primary-r2-color: rgb(51, 149, 255);
- --cru-primary-r3-color: rgb(77, 163, 255);
- --cru-primary-t-color: rgb(255, 255, 255);
- --cru-primary-t1-color: rgb(230, 230, 230);
- --cru-primary-t2-color: rgb(204, 204, 204);
- --cru-primary-t3-color: rgb(179, 179, 179);
- --cru-primary-enhance-color: rgb(77, 163, 255);
- --cru-primary-enhance-l1-color: rgb(94, 172, 255);
- --cru-primary-enhance-l2-color: rgb(112, 181, 255);
- --cru-primary-enhance-l3-color: rgb(130, 190, 255);
- --cru-primary-enhance-d1-color: rgb(43, 145, 255);
- --cru-primary-enhance-d2-color: rgb(10, 128, 255);
- --cru-primary-enhance-d3-color: rgb(0, 112, 232);
- --cru-primary-enhance-f1-color: rgb(94, 172, 255);
- --cru-primary-enhance-f2-color: rgb(112, 181, 255);
- --cru-primary-enhance-f3-color: rgb(130, 190, 255);
- --cru-primary-enhance-r1-color: rgb(43, 145, 255);
- --cru-primary-enhance-r2-color: rgb(10, 128, 255);
- --cru-primary-enhance-r3-color: rgb(0, 112, 232);
- --cru-primary-enhance-t-color: rgb(0, 0, 0);
- --cru-primary-enhance-t1-color: rgb(26, 26, 26);
- --cru-primary-enhance-t2-color: rgb(51, 51, 51);
- --cru-primary-enhance-t3-color: rgb(77, 77, 77);
- --cru-secondary-color: rgb(128, 128, 128);
- --cru-secondary-l1-color: rgb(141, 141, 141);
- --cru-secondary-l2-color: rgb(153, 153, 153);
- --cru-secondary-l3-color: rgb(166, 166, 166);
- --cru-secondary-d1-color: rgb(115, 115, 115);
- --cru-secondary-d2-color: rgb(102, 102, 102);
- --cru-secondary-d3-color: rgb(90, 90, 90);
- --cru-secondary-f1-color: rgb(115, 115, 115);
- --cru-secondary-f2-color: rgb(102, 102, 102);
- --cru-secondary-f3-color: rgb(90, 90, 90);
- --cru-secondary-r1-color: rgb(141, 141, 141);
- --cru-secondary-r2-color: rgb(153, 153, 153);
- --cru-secondary-r3-color: rgb(166, 166, 166);
- --cru-secondary-t-color: rgb(255, 255, 255);
- --cru-secondary-t1-color: rgb(230, 230, 230);
- --cru-secondary-t2-color: rgb(204, 204, 204);
- --cru-secondary-t3-color: rgb(179, 179, 179);
- --cru-danger-color: rgb(255, 0, 0);
- --cru-danger-l1-color: rgb(255, 26, 26);
- --cru-danger-l2-color: rgb(255, 51, 51);
- --cru-danger-l3-color: rgb(255, 77, 77);
- --cru-danger-d1-color: rgb(230, 0, 0);
- --cru-danger-d2-color: rgb(204, 0, 0);
- --cru-danger-d3-color: rgb(179, 0, 0);
- --cru-danger-f1-color: rgb(230, 0, 0);
- --cru-danger-f2-color: rgb(204, 0, 0);
- --cru-danger-f3-color: rgb(179, 0, 0);
- --cru-danger-r1-color: rgb(255, 26, 26);
- --cru-danger-r2-color: rgb(255, 51, 51);
- --cru-danger-r3-color: rgb(255, 77, 77);
- --cru-danger-t-color: rgb(255, 255, 255);
- --cru-danger-t1-color: rgb(230, 230, 230);
- --cru-danger-t2-color: rgb(204, 204, 204);
- --cru-danger-t3-color: rgb(179, 179, 179);
- --cru-success-color: rgb(0, 128, 0);
- --cru-success-l1-color: rgb(0, 166, 0);
- --cru-success-l2-color: rgb(0, 204, 0);
- --cru-success-l3-color: rgb(0, 243, 0);
- --cru-success-d1-color: rgb(0, 115, 0);
- --cru-success-d2-color: rgb(0, 102, 0);
- --cru-success-d3-color: rgb(0, 90, 0);
- --cru-success-f1-color: rgb(0, 115, 0);
- --cru-success-f2-color: rgb(0, 102, 0);
- --cru-success-f3-color: rgb(0, 90, 0);
- --cru-success-r1-color: rgb(0, 166, 0);
- --cru-success-r2-color: rgb(0, 204, 0);
- --cru-success-r3-color: rgb(0, 243, 0);
- --cru-success-t-color: rgb(255, 255, 255);
- --cru-success-t1-color: rgb(230, 230, 230);
- --cru-success-t2-color: rgb(204, 204, 204);
- --cru-success-t3-color: rgb(179, 179, 179);
- */
-}
-
-.cru-primary {
- --cru-theme-color: var(--cru-primary-color);
- --cru-theme-l1-color: var(--cru-primary-l1-color);
- --cru-theme-l2-color: var(--cru-primary-l2-color);
- --cru-theme-l3-color: var(--cru-primary-l3-color);
- --cru-theme-d1-color: var(--cru-primary-d1-color);
- --cru-theme-d2-color: var(--cru-primary-d2-color);
- --cru-theme-d3-color: var(--cru-primary-d3-color);
- --cru-theme-f1-color: var(--cru-primary-f1-color);
- --cru-theme-f2-color: var(--cru-primary-f2-color);
- --cru-theme-f3-color: var(--cru-primary-f3-color);
- --cru-theme-r1-color: var(--cru-primary-r1-color);
- --cru-theme-r2-color: var(--cru-primary-r2-color);
- --cru-theme-r3-color: var(--cru-primary-r3-color);
- --cru-theme-t-color: var(--cru-primary-t-color);
- --cru-theme-t1-color: var(--cru-primary-t1-color);
- --cru-theme-t2-color: var(--cru-primary-t2-color);
- --cru-theme-t3-color: var(--cru-primary-t3-color);
-}
-
-.cru-primary-enhance {
- --cru-theme-color: var(--cru-primary-enhance-color);
- --cru-theme-l1-color: var(--cru-primary-enhance-l1-color);
- --cru-theme-l2-color: var(--cru-primary-enhance-l2-color);
- --cru-theme-l3-color: var(--cru-primary-enhance-l3-color);
- --cru-theme-d1-color: var(--cru-primary-enhance-d1-color);
- --cru-theme-d2-color: var(--cru-primary-enhance-d2-color);
- --cru-theme-d3-color: var(--cru-primary-enhance-d3-color);
- --cru-theme-f1-color: var(--cru-primary-enhance-f1-color);
- --cru-theme-f2-color: var(--cru-primary-enhance-f2-color);
- --cru-theme-f3-color: var(--cru-primary-enhance-f3-color);
- --cru-theme-r1-color: var(--cru-primary-enhance-r1-color);
- --cru-theme-r2-color: var(--cru-primary-enhance-r2-color);
- --cru-theme-r3-color: var(--cru-primary-enhance-r3-color);
- --cru-theme-t-color: var(--cru-primary-enhance-t-color);
- --cru-theme-t1-color: var(--cru-primary-enhance-t1-color);
- --cru-theme-t2-color: var(--cru-primary-enhance-t2-color);
- --cru-theme-t3-color: var(--cru-primary-enhance-t3-color);
-}
-
-.cru-secondary {
- --cru-theme-color: var(--cru-secondary-color);
- --cru-theme-l1-color: var(--cru-secondary-l1-color);
- --cru-theme-l2-color: var(--cru-secondary-l2-color);
- --cru-theme-l3-color: var(--cru-secondary-l3-color);
- --cru-theme-d1-color: var(--cru-secondary-d1-color);
- --cru-theme-d2-color: var(--cru-secondary-d2-color);
- --cru-theme-d3-color: var(--cru-secondary-d3-color);
- --cru-theme-f1-color: var(--cru-secondary-f1-color);
- --cru-theme-f2-color: var(--cru-secondary-f2-color);
- --cru-theme-f3-color: var(--cru-secondary-f3-color);
- --cru-theme-r1-color: var(--cru-secondary-r1-color);
- --cru-theme-r2-color: var(--cru-secondary-r2-color);
- --cru-theme-r3-color: var(--cru-secondary-r3-color);
- --cru-theme-t-color: var(--cru-secondary-t-color);
- --cru-theme-t1-color: var(--cru-secondary-t1-color);
- --cru-theme-t2-color: var(--cru-secondary-t2-color);
- --cru-theme-t3-color: var(--cru-secondary-t3-color);
-}
-
-.cru-success {
- --cru-theme-color: var(--cru-success-color);
- --cru-theme-l1-color: var(--cru-success-l1-color);
- --cru-theme-l2-color: var(--cru-success-l2-color);
- --cru-theme-l3-color: var(--cru-success-l3-color);
- --cru-theme-d1-color: var(--cru-success-d1-color);
- --cru-theme-d2-color: var(--cru-success-d2-color);
- --cru-theme-d3-color: var(--cru-success-d3-color);
- --cru-theme-f1-color: var(--cru-success-f1-color);
- --cru-theme-f2-color: var(--cru-success-f2-color);
- --cru-theme-f3-color: var(--cru-success-f3-color);
- --cru-theme-r1-color: var(--cru-success-r1-color);
- --cru-theme-r2-color: var(--cru-success-r2-color);
- --cru-theme-r3-color: var(--cru-success-r3-color);
- --cru-theme-t-color: var(--cru-success-t-color);
- --cru-theme-t1-color: var(--cru-success-t1-color);
- --cru-theme-t2-color: var(--cru-success-t2-color);
- --cru-theme-t3-color: var(--cru-success-t3-color);
-}
-
-.cru-danger {
- --cru-theme-color: var(--cru-danger-color);
- --cru-theme-l1-color: var(--cru-danger-l1-color);
- --cru-theme-l2-color: var(--cru-danger-l2-color);
- --cru-theme-l3-color: var(--cru-danger-l3-color);
- --cru-theme-d1-color: var(--cru-danger-d1-color);
- --cru-theme-d2-color: var(--cru-danger-d2-color);
- --cru-theme-d3-color: var(--cru-danger-d3-color);
- --cru-theme-f1-color: var(--cru-danger-f1-color);
- --cru-theme-f2-color: var(--cru-danger-f2-color);
- --cru-theme-f3-color: var(--cru-danger-f3-color);
- --cru-theme-r1-color: var(--cru-danger-r1-color);
- --cru-theme-r2-color: var(--cru-danger-r2-color);
- --cru-theme-r3-color: var(--cru-danger-r3-color);
- --cru-theme-t-color: var(--cru-danger-t-color);
- --cru-theme-t1-color: var(--cru-danger-t1-color);
- --cru-theme-t2-color: var(--cru-danger-t2-color);
- --cru-theme-t3-color: var(--cru-danger-t3-color);
-}
-
-.cru-color-primary {
- color: var(--cru-primary-color);
-}
-
-.cru-color-primary-enhance {
- color: var(--cru-primary-enhance-color);
-}
-
-.cru-color-secondary {
- color: var(--cru-secondary-color);
-}
-
-.cru-color-success {
- color: var(--cru-success-color);
-}
-
-.cru-color-danger {
- color: var(--cru-danger-color);
-}
-
-.cru-text-center {
- text-align: center;
-}
-
-.cru-text-end {
- text-align: end;
-}
-
-.cru-float-left {
- float: left;
-}
-
-.cru-float-right {
- float: right;
-}
-
-.cru-align-text-bottom {
- vertical-align: text-bottom;
-}
-
-.cru-align-middle {
- vertical-align: middle;
-}
-
-.cru-clearfix::after {
- clear: both;
-}
-
-.cru-fill-parent {
- width: 100%;
- height: 100%;
-}
-
-.cru-avatar {
- width: 60px;
- height: 60px;
-}
-
-.cru-avatar.large {
- width: 100px;
- height: 100px;
-}
-
-.cru-avatar.small {
- width: 40px;
- height: 40px;
-}
-
-.cru-round {
- border-radius: 50%;
-}
-
-.cru-tab-pages-action-area {
- display: flex;
- align-items: center;
-}
-
-.alert-container {
- position: fixed;
- z-index: 1070;
-}
-
-@media (min-width: 576px) {
- .alert-container {
- bottom: 0;
- right: 0;
- }
-}
-
-@media (max-width: 575.98px) {
- .alert-container {
- bottom: 0;
- right: 0;
- left: 0;
- text-align: center;
- }
-} \ No newline at end of file
diff --git a/FrontEnd/src/views/common/input/InputPanel.css b/FrontEnd/src/views/common/input/InputPanel.css
deleted file mode 100644
index f9d6ac8b..00000000
--- a/FrontEnd/src/views/common/input/InputPanel.css
+++ /dev/null
@@ -1,25 +0,0 @@
-.cru-input-panel-group {
- display: block;
- margin: 0.4em 0;
-}
-
-.cru-input-panel-label {
- display: block;
- color: var(--cru-primary-color);
-}
-
-.cru-input-panel-inline-label {
- margin-inline-start: 0.5em;
-}
-
-.cru-input-panel-error-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-danger-color);
-}
-
-.cru-input-panel-helper-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/input/InputPanel.tsx b/FrontEnd/src/views/common/input/InputPanel.tsx
deleted file mode 100644
index 234ed267..00000000
--- a/FrontEnd/src/views/common/input/InputPanel.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import * as React from "react";
-import classNames from "classnames";
-import { useTranslation } from "react-i18next";
-import { TwitterPicker } from "react-color";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import "./InputPanel.css";
-
-export interface TextInput {
- type: "text";
- label?: I18nText;
- helper?: I18nText;
- password?: boolean;
-}
-
-export interface BoolInput {
- type: "bool";
- label: I18nText;
- helper?: I18nText;
-}
-
-export interface SelectInputOption {
- value: string;
- label: I18nText;
- icon?: React.ReactElement;
-}
-
-export interface SelectInput {
- type: "select";
- label: I18nText;
- options: SelectInputOption[];
-}
-
-export interface ColorInput {
- type: "color";
- label?: I18nText;
-}
-
-export interface DateTimeInput {
- type: "datetime";
- label?: I18nText;
- helper?: I18nText;
-}
-
-export type Input =
- | TextInput
- | BoolInput
- | SelectInput
- | ColorInput
- | DateTimeInput;
-
-interface InputTypeToValueTypeMap {
- text: string;
- bool: boolean;
- select: string;
- color: string;
- datetime: string;
-}
-
-type ValueTypes = InputTypeToValueTypeMap[keyof InputTypeToValueTypeMap];
-
-type MapInputTypeToValueType<Type> = Type extends keyof InputTypeToValueTypeMap
- ? InputTypeToValueTypeMap[Type]
- : never;
-
-type MapInputToValueType<T> = T extends Input
- ? MapInputTypeToValueType<T["type"]>
- : T;
-
-type MapInputListToValueTypeList<Tuple extends readonly Input[]> = {
- [Index in keyof Tuple]: MapInputToValueType<Tuple[Index]>;
-} & { length: Tuple["length"] };
-
-export type InputPanelError = {
- [index: number]: I18nText | null | undefined;
-};
-
-export function hasError(e: InputPanelError | null | undefined): boolean {
- if (e == null) return false;
- for (const key of Object.keys(e)) {
- if (e[key as unknown as number] != null) return true;
- }
- return false;
-}
-
-export interface InputPanelProps<InputList extends readonly Input[]> {
- scheme: InputList;
- values: MapInputListToValueTypeList<InputList>;
- onChange: (
- values: MapInputListToValueTypeList<InputList>,
- index: number
- ) => void;
- error?: InputPanelError;
- disable?: boolean;
-}
-
-const InputPanel = <InputList extends readonly Input[]>(
- props: InputPanelProps<InputList>
-): React.ReactElement => {
- const { values, onChange, scheme, error, disable } = props;
-
- const { t } = useTranslation();
-
- const updateValue = (index: number, newValue: ValueTypes): void => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- onChange(
- newValues as unknown as MapInputListToValueTypeList<InputList>,
- index
- );
- };
-
- return (
- <div>
- {scheme.map((item, index) => {
- const v = values[index];
- const e: string | null = convertI18nText(error?.[index], t);
-
- if (item.type === "text") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- {item.label && (
- <label className="cru-input-panel-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type={item.password === true ? "password" : "text"}
- value={v as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={disable}
- />
- {e && <div className="cru-input-panel-error-text">{e}</div>}
- {item.helper && (
- <div className="cru-input-panel-helper-text">
- {convertI18nText(item.helper, t)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "bool") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- <input
- type="checkbox"
- checked={v as boolean}
- onChange={(event) => {
- const value = event.currentTarget.checked;
- updateValue(index, value);
- }}
- disabled={disable}
- />
- <label className="cru-input-panel-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- {e != null && (
- <div className="cru-input-panel-error-text">{e}</div>
- )}
- {item.helper && (
- <div className="cru-input-panel-helper-text">
- {convertI18nText(item.helper, t)}
- </div>
- )}
- </div>
- );
- } else if (item.type === "select") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- <label className="cru-input-panel-label">
- {convertI18nText(item.label, t)}
- </label>
- <select
- value={v as string}
- onChange={(event) => {
- const value = event.target.value;
- updateValue(index, value);
- }}
- disabled={disable}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {convertI18nText(option.label, t)}
- </option>
- );
- })}
- </select>
- </div>
- );
- } else if (item.type === "color") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- <label className="cru-input-panel-inline-label">
- {convertI18nText(item.label, t)}
- </label>
- <TwitterPicker
- color={v as string}
- triangle="hide"
- onChange={(result) => updateValue(index, result.hex)}
- />
- </div>
- );
- } else if (item.type === "datetime") {
- return (
- <div
- key={index}
- className={classNames("cru-input-panel-group", e && "error")}
- >
- {item.label && (
- <label className="cru-input-panel-label">
- {convertI18nText(item.label, t)}
- </label>
- )}
- <input
- type="datetime-local"
- value={v as string}
- onChange={(e) => {
- const v = e.target.value;
- updateValue(index, v);
- }}
- disabled={disable}
- />
- {e != null && (
- <div className="cru-input-panel-error-text">{e}</div>
- )}
- {item.helper && (
- <div className="cru-input-panel-helper-text">
- {convertI18nText(item.helper, t)}
- </div>
- )}
- </div>
- );
- }
- })}
- </div>
- );
-};
-
-export default InputPanel;
diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css
deleted file mode 100644
index c3fa82c4..00000000
--- a/FrontEnd/src/views/common/menu/Menu.css
+++ /dev/null
@@ -1,24 +0,0 @@
-.cru-menu {
- min-width: 200px;
-}
-
-.cru-menu-item {
- font-size: 1em;
- padding: 0.5em 1.5em;
- cursor: pointer;
- transition: all 0.5s;
- color: var(--cru-theme-color);
-}
-
-.cru-menu-item:hover {
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-color);
-}
-
-.cru-menu-item-icon {
- margin-right: 1em;
-}
-
-.cru-menu-divider {
- border-top: 1px solid #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/menu/Menu.tsx b/FrontEnd/src/views/common/menu/Menu.tsx
deleted file mode 100644
index de3b1664..00000000
--- a/FrontEnd/src/views/common/menu/Menu.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { convertI18nText, I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
-
-import "./Menu.css";
-
-export type MenuItem =
- | {
- type: "divider";
- }
- | {
- type: "button";
- text: I18nText;
- iconClassName?: string;
- color?: PaletteColorType;
- onClick: () => void;
- };
-
-export type MenuItems = MenuItem[];
-
-export type MenuProps = {
- items: MenuItems;
- onItemClicked?: () => void;
- className?: string;
- style?: React.CSSProperties;
-};
-
-export default function _Menu({
- items,
- onItemClicked,
- className,
- style,
-}: MenuProps): React.ReactElement | null {
- const { t } = useTranslation();
-
- return (
- <div className={classnames("cru-menu", className)} style={style}>
- {items.map((item, index) => {
- if (item.type === "divider") {
- return <div key={index} className="cru-menu-divider" />;
- } else {
- return (
- <div
- key={index}
- className={classnames(
- "cru-menu-item",
- `cru-${item.color ?? "primary"}`
- )}
- onClick={() => {
- item.onClick();
- onItemClicked?.();
- }}
- >
- {item.iconClassName != null ? (
- <i
- className={classnames(
- item.iconClassName,
- "cru-menu-item-icon"
- )}
- />
- ) : null}
- {convertI18nText(item.text, t)}
- </div>
- );
- }
- })}
- </div>
- );
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.css b/FrontEnd/src/views/common/menu/PopupMenu.css
deleted file mode 100644
index f6654f68..00000000
--- a/FrontEnd/src/views/common/menu/PopupMenu.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.cru-popup-menu-menu-container {
- z-index: 1040;
- border-radius: 5px;
- border: var(--cru-primary-color) 1px solid;
- background-color: white;
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.tsx b/FrontEnd/src/views/common/menu/PopupMenu.tsx
deleted file mode 100644
index 74ca7aba..00000000
--- a/FrontEnd/src/views/common/menu/PopupMenu.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import classNames from "classnames";
-import * as React from "react";
-import { createPortal } from "react-dom";
-import { usePopper } from "react-popper";
-
-import { useClickOutside } from "@/utilities/hooks";
-
-import Menu, { MenuItems } from "./Menu";
-
-import "./PopupMenu.css";
-
-export interface PopupMenuProps {
- items: MenuItems;
- children?: React.ReactNode;
- containerClassName?: string;
- containerStyle?: React.CSSProperties;
-}
-
-const PopupMenu: React.FC<PopupMenuProps> = ({
- items,
- children,
- containerClassName,
- containerStyle,
-}) => {
- const [show, setShow] = React.useState<boolean>(false);
-
- const [referenceElement, setReferenceElement] =
- React.useState<HTMLDivElement | null>(null);
- const [popperElement, setPopperElement] =
- React.useState<HTMLDivElement | null>(null);
- const { styles, attributes } = usePopper(referenceElement, popperElement);
-
- useClickOutside(popperElement, () => setShow(false), true);
-
- return (
- <>
- <div
- ref={setReferenceElement}
- className={classNames(
- "cru-popup-menu-trigger-container",
- containerClassName
- )}
- style={containerStyle}
- onClick={() => setShow(true)}
- >
- {children}
- </div>
- {show
- ? createPortal(
- <div
- ref={setPopperElement}
- className="cru-popup-menu-menu-container"
- style={styles.popper}
- {...attributes.popper}
- >
- <Menu
- items={items}
- onItemClicked={() => {
- setShow(false);
- }}
- />
- </div>,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- document.getElementById("portal")!
- )
- : null}
- </>
- );
-};
-
-export default PopupMenu;
diff --git a/FrontEnd/src/views/common/tab/TabPages.tsx b/FrontEnd/src/views/common/tab/TabPages.tsx
deleted file mode 100644
index cdb988e0..00000000
--- a/FrontEnd/src/views/common/tab/TabPages.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import * as React from "react";
-
-import { I18nText, UiLogicError } from "@/common";
-
-import Tabs from "./Tabs";
-
-export interface TabPage {
- name: string;
- text: I18nText;
- page: React.ReactNode;
-}
-
-export interface TabPagesProps {
- pages: TabPage[];
- actions?: React.ReactNode;
- dense?: boolean;
- className?: string;
- style?: React.CSSProperties;
- navClassName?: string;
- navStyle?: React.CSSProperties;
- pageContainerClassName?: string;
- pageContainerStyle?: React.CSSProperties;
-}
-
-const TabPages: React.FC<TabPagesProps> = ({
- pages,
- actions,
- dense,
- className,
- style,
- navClassName,
- navStyle,
- pageContainerClassName,
- pageContainerStyle,
-}) => {
- if (pages.length === 0) {
- throw new UiLogicError("Page list can't be empty.");
- }
-
- const [tab, setTab] = React.useState<string>(pages[0].name);
-
- const currentPage = pages.find((p) => p.name === tab);
-
- if (currentPage == null) {
- throw new UiLogicError("Current tab value is bad.");
- }
-
- return (
- <div className={className} style={style}>
- <Tabs
- tabs={pages.map((page) => ({
- name: page.name,
- text: page.text,
- onClick: () => {
- setTab(page.name);
- },
- }))}
- dense={dense}
- activeTabName={tab}
- className={navClassName}
- style={navStyle}
- actions={actions}
- />
- <div className={pageContainerClassName} style={pageContainerStyle}>
- {currentPage.page}
- </div>
- </div>
- );
-};
-
-export default TabPages;
diff --git a/FrontEnd/src/views/common/tab/Tabs.css b/FrontEnd/src/views/common/tab/Tabs.css
deleted file mode 100644
index 395d16a7..00000000
--- a/FrontEnd/src/views/common/tab/Tabs.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.cru-nav {
- border-bottom: var(--cru-primary-color) 1px solid;
- display: flex;
-}
-
-.cru-nav-item {
- color: var(--cru-primary-color);
- border: var(--cru-background-2-color) 0.5px solid;
- border-bottom: none;
- padding: 0.5em 1.5em;
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- transition: all 0.5s;
- cursor: pointer;
-}
-
-.cru-nav.dense .cru-nav-item {
- padding: 0.2em 1em;
-}
-
-.cru-nav-item:hover {
- background-color: var(--cru-background-1-color);
-}
-
-.cru-nav-item.active {
- color: var(--cru-primary-t-color);
- background-color: var(--cru-primary-color);
- border-color: var(--cru-primary-color);
-}
-
-.cru-nav-action-area {
- margin-left: auto;
-}
diff --git a/FrontEnd/src/views/common/tab/Tabs.tsx b/FrontEnd/src/views/common/tab/Tabs.tsx
deleted file mode 100644
index 3e3ef6fa..00000000
--- a/FrontEnd/src/views/common/tab/Tabs.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as React from "react";
-import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import classnames from "classnames";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import "./Tabs.css";
-
-export interface Tab {
- name: string;
- text: I18nText;
- link?: string;
- onClick?: () => void;
-}
-
-export interface TabsProps {
- activeTabName?: string;
- actions?: React.ReactNode;
- dense?: boolean;
- tabs: Tab[];
- className?: string;
- style?: React.CSSProperties;
-}
-
-export default function Tabs(props: TabsProps): React.ReactElement | null {
- const { tabs, activeTabName, className, style, dense, actions } = props;
-
- const { t } = useTranslation();
-
- return (
- <div
- className={classnames("cru-nav", dense && "dense", className)}
- style={style}
- >
- {tabs.map((tab) => {
- const active = activeTabName === tab.name;
- const className = classnames("cru-nav-item", active && "active");
-
- if (tab.link != null) {
- return (
- <Link
- key={tab.name}
- to={tab.link}
- onClick={tab.onClick}
- className={className}
- >
- {convertI18nText(tab.text, t)}
- </Link>
- );
- } else {
- return (
- <span key={tab.name} onClick={tab.onClick} className={className}>
- {convertI18nText(tab.text, t)}
- </span>
- );
- }
- })}
- <div className="cru-nav-action-area">{actions}</div>
- </div>
- );
-}
diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx
deleted file mode 100644
index fcff8c69..00000000
--- a/FrontEnd/src/views/common/user/UserAvatar.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react";
-
-import { getHttpUserClient } from "@/http/user";
-
-export interface UserAvatarProps
- extends React.ImgHTMLAttributes<HTMLImageElement> {
- username: string;
-}
-
-const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => {
- return (
- <img
- src={getHttpUserClient().generateAvatarUrl(username)}
- {...otherProps}
- />
- );
-};
-
-export default UserAvatar;
diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx
deleted file mode 100644
index fbcdc9b0..00000000
--- a/FrontEnd/src/views/home/TimelineListView.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { Link } from "react-router-dom";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import { TimelineBookmark } from "@/http/bookmark";
-
-import IconButton from "../common/button/IconButton";
-
-interface TimelineListItemProps {
- timeline: TimelineBookmark;
-}
-
-const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => {
- return (
- <div className="home-timeline-list-item home-timeline-list-item-timeline">
- <svg className="home-timeline-list-item-line" viewBox="0 0 120 100">
- <path
- d="M 80,50 m 0,-12 a 12 12 180 1 1 0,24 12 12 180 1 1 0,-24 z M 60,0 h 40 v 100 h -40 z"
- fillRule="evenodd"
- fill="#007bff"
- />
- </svg>
- <div>
- {timeline.timelineOwner}/{timeline.timelineName}
- </div>
- <Link to={`${timeline.timelineOwner}/${timeline.timelineName}`}>
- <IconButton icon="arrow-right" className="ms-3" />
- </Link>
- </div>
- );
-};
-
-const TimelineListArrow: React.FC = () => {
- return (
- <div>
- <div className="home-timeline-list-item">
- <svg className="home-timeline-list-item-line" viewBox="0 0 120 60">
- <path d="M 60,0 h 40 v 20 l -20,20 l -20,-20 z" fill="#007bff" />
- </svg>
- </div>
- <div className="home-timeline-list-item">
- <svg
- className="home-timeline-list-item-line home-timeline-list-loading-head"
- viewBox="0 0 120 40"
- >
- <path
- d="M 60,10 l 20,20 l 20,-20"
- fill="none"
- stroke="#007bff"
- strokeWidth="5"
- />
- </svg>
- </div>
- </div>
- );
-};
-
-interface TimelineListViewProps {
- headerText?: I18nText;
- timelines?: TimelineBookmark[];
-}
-
-const TimelineListView: React.FC<TimelineListViewProps> = ({
- headerText,
- timelines,
-}) => {
- const { t } = useTranslation();
-
- return (
- <div className="home-timeline-list">
- <div className="home-timeline-list-item">
- <svg className="home-timeline-list-item-line" viewBox="0 0 120 120">
- <path
- d="M 0,20 Q 80,20 80,80 l 0,40"
- stroke="#007bff"
- strokeWidth="40"
- fill="none"
- />
- </svg>
- <h3>{convertI18nText(headerText, t)}</h3>
- </div>
- {timelines != null
- ? timelines.map((t) => (
- <TimelineListItem
- key={`${t.timelineOwner}/${t.timelineName}`}
- timeline={t}
- />
- ))
- : null}
- <TimelineListArrow />
- </div>
- );
-};
-
-export default TimelineListView;
diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx
deleted file mode 100644
index e843c325..00000000
--- a/FrontEnd/src/views/home/WebsiteIntroduction.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { Link } from "react-router-dom";
-
-const WebsiteIntroduction: React.FC<{
- className?: string;
- style?: React.CSSProperties;
-}> = ({ className, style }) => {
- const { i18n } = useTranslation();
-
- if (i18n.language.startsWith("zh")) {
- return (
- <div className={className} style={style}>
- <h2>
- 欢迎来到<strong>时间线</strong>!🎉🎉🎉
- </h2>
- <p>
- 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。
- </p>
- <p>
- 如果你拥有一个账号,<Link to="/login">登陆</Link>
- 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。
- </p>
- <p>
- 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。
- </p>
- <p>
- 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在
- <Link to="/about">关于</Link>页面找到一些信息。
- </p>
- <p>
- <small className="text-secondary">
- 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅
- </small>
- </p>
- </div>
- );
- } else {
- return (
- <div className={className} style={style}>
- <h2>
- Welcome to <strong>Timeline</strong>!🎉🎉🎉
- </h2>
- <p>
- This website consists of many individual timelines. Each timeline is a
- list of messages just like a chat app.
- </p>
- <p>
- If you do have an account, you can <Link to="/login">login</Link> and
- post messages, which supports Markdown and images, in your timelines.
- You can also create a new timeline to open a new topic. You can set
- the permission of a timeline to only allow specified people to see the
- content of the timeline.
- </p>
- <p>
- If you don&apos;t have an account, you can view some public timelines
- like highlight timelines below set by website manager.
- </p>
- <p>
- Since this website is hosted on my tiny server, so account registry is
- not opened. If you want to host this service on your own server, you
- can find some useful information on <Link to="/about">about</Link>{" "}
- page.
- </p>
- <p>
- <small className="text-secondary">
- This introduction is added after my lover complained a lot of times
- about the obscuration of my website. May you understand the logic of
- it!😅
- </small>
- </p>
- </div>
- );
- }
-};
-
-export default WebsiteIntroduction;
diff --git a/FrontEnd/src/views/home/index.css b/FrontEnd/src/views/home/index.css
deleted file mode 100644
index 89d36f0d..00000000
--- a/FrontEnd/src/views/home/index.css
+++ /dev/null
@@ -1,42 +0,0 @@
-.home-timeline-list-item {
- display: flex;
- align-items: center;
-}
-
-.home-timeline-list-item-timeline {
- transition: background 0.8s;
- animation: 0.8s home-timeline-list-item-timeline-enter;
-}
-.home-timeline-list-item-timeline:hover {
- background: #e9ecef;
-}
-
-@keyframes home-timeline-list-item-timeline-enter {
- from {
- transform: translate(-100%, 0);
- opacity: 0;
- }
-}
-.home-timeline-list-item-line {
- width: 80px;
- flex-shrink: 0;
-}
-
-@keyframes home-timeline-list-loading-head-animation {
- from {
- transform: translate(0, -30px);
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-.home-timeline-list-loading-head {
- animation: 1s infinite home-timeline-list-loading-head-animation;
-}
-
-@media (min-width: 576px) {
- .home-search {
- float: right;
- }
-}
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx
deleted file mode 100644
index 3c80fb0c..00000000
--- a/FrontEnd/src/views/home/index.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { highlightTimelineUsername } from "@/common";
-
-import { Page } from "@/http/common";
-import { getHttpBookmarkClient, TimelineBookmark } from "@/http/bookmark";
-
-import SearchInput from "../common/SearchInput";
-import TimelineListView from "./TimelineListView";
-import WebsiteIntroduction from "./WebsiteIntroduction";
-
-import "./index.css";
-
-const highlightTimelineMessageMap = {
- loading: "home.loadingHighlightTimelines",
- done: "home.loadedHighlightTimelines",
- error: "home.errorHighlightTimelines",
-} as const;
-
-const HomeV2: React.FC = () => {
- const navigate = useNavigate();
-
- const [navText, setNavText] = React.useState<string>("");
-
- const [highlightTimelineState, setHighlightTimelineState] = React.useState<
- "loading" | "done" | "error"
- >("loading");
- const [highlightTimelines, setHighlightTimelines] = React.useState<
- Page<TimelineBookmark> | undefined
- >();
-
- React.useEffect(() => {
- if (highlightTimelineState === "loading") {
- let subscribe = true;
- void getHttpBookmarkClient()
- .list(highlightTimelineUsername)
- .then(
- (data) => {
- if (subscribe) {
- setHighlightTimelineState("done");
- setHighlightTimelines(data);
- }
- },
- () => {
- if (subscribe) {
- setHighlightTimelineState("error");
- setHighlightTimelines(undefined);
- }
- }
- );
- return () => {
- subscribe = false;
- };
- }
- }, [highlightTimelineState]);
-
- return (
- <>
- <SearchInput
- className="mx-2 my-3 home-search"
- value={navText}
- onChange={setNavText}
- onButtonClick={() => {
- navigate(`search?q=${navText}`);
- }}
- alwaysOneline
- />
- <WebsiteIntroduction className="m-2" />
- <TimelineListView
- headerText={highlightTimelineMessageMap[highlightTimelineState]}
- timelines={highlightTimelines?.items}
- />
- </>
- );
-};
-
-export default HomeV2;
diff --git a/FrontEnd/src/views/login/index.css b/FrontEnd/src/views/login/index.css
deleted file mode 100644
index aefe57e8..00000000
--- a/FrontEnd/src/views/login/index.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.login-container {
- max-width: 25em;
-}
-
-.login-container input[type="text"],
-.login-container input[type="password"] {
- width: 100%;
-}
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx
deleted file mode 100644
index cc1d9865..00000000
--- a/FrontEnd/src/views/login/index.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import * as React from "react";
-import { Link, useNavigate } from "react-router-dom";
-import { useTranslation, Trans } from "react-i18next";
-
-import { useUser, userService } from "@/services/user";
-
-import AppBar from "../common/AppBar";
-import LoadingButton from "../common/button/LoadingButton";
-
-import "./index.css";
-
-const LoginPage: React.FC = () => {
- const { t } = useTranslation();
-
- const navigate = useNavigate();
-
- const [username, setUsername] = React.useState<string>("");
- const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false);
- const [password, setPassword] = React.useState<string>("");
- const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false);
- const [rememberMe, setRememberMe] = React.useState<boolean>(true);
- const [process, setProcess] = React.useState<boolean>(false);
- const [error, setError] = React.useState<string | null>(null);
-
- const user = useUser();
-
- React.useEffect(() => {
- if (user != null) {
- const id = setTimeout(() => navigate("/"), 3000);
- return () => {
- clearTimeout(id);
- };
- }
- }, [navigate, user]);
-
- if (user != null) {
- return (
- <>
- <AppBar />
- <p>{t("login.alreadyLogin")}</p>
- </>
- );
- }
-
- const submit = (): void => {
- if (username === "" || password === "") {
- setUsernameDirty(true);
- setPasswordDirty(true);
- return;
- }
-
- setProcess(true);
- userService
- .login(
- {
- username: username,
- password: password,
- },
- rememberMe
- )
- .then(
- () => {
- if (history.length === 0) {
- navigate("/");
- } else {
- navigate(-1);
- }
- },
- (e: Error) => {
- setProcess(false);
- setError(e.message);
- }
- );
- };
-
- const onEnterPressInPassword: React.KeyboardEventHandler = (e) => {
- if (e.key === "Enter") {
- submit();
- }
- };
-
- return (
- <div className="login-container container-fluid mt-2">
- <h1 className="cru-text-center cru-color-primary">{t("welcome")}</h1>
- <div className="cru-operation-dialog-group">
- <label className="cru-operation-dialog-label" htmlFor="username">
- {t("user.username")}
- </label>
- <input
- id="username"
- type="text"
- disabled={process}
- onChange={(e) => {
- setUsername(e.target.value);
- setUsernameDirty(true);
- }}
- value={username}
- />
- {usernameDirty && username === "" && (
- <div className="cru-operation-dialog-error-text">
- {t("login.emptyUsername")}
- </div>
- )}
- </div>
- <div className="cru-operation-dialog-group">
- <label className="cru-operation-dialog-label" htmlFor="password">
- {t("user.password")}
- </label>
- <input
- id="password"
- type="password"
- disabled={process}
- onChange={(e) => {
- setPassword(e.target.value);
- setPasswordDirty(true);
- }}
- value={password}
- onKeyDown={onEnterPressInPassword}
- />
- {passwordDirty && password === "" && (
- <div className="cru-operation-dialog-error-text">
- {t("login.emptyPassword")}
- </div>
- )}
- </div>
- <div className="cru-operation-dialog-group">
- <input
- id="remember-me"
- type="checkbox"
- checked={rememberMe}
- onChange={(e) => {
- setRememberMe(e.currentTarget.checked);
- }}
- />
- <label className="cru-operation-dialog-inline-label">
- {t("user.rememberMe")}
- </label>
- </div>
- {error ? <p className="cru-color-danger">{t(error)}</p> : null}
- <div className="cru-text-end">
- <LoadingButton
- loading={process}
- onClick={(e) => {
- submit();
- e.preventDefault();
- }}
- disabled={username === "" || password === "" ? true : undefined}
- >
- {t("user.login")}
- </LoadingButton>
- </div>
- <Trans i18nKey="login.noAccount">
- 0<Link to="/register">1</Link>2
- </Trans>
- </div>
- );
-};
-
-export default LoginPage;
diff --git a/FrontEnd/src/views/register/index.tsx b/FrontEnd/src/views/register/index.tsx
deleted file mode 100644
index c1b95ff7..00000000
--- a/FrontEnd/src/views/register/index.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router-dom";
-
-import { HttpBadRequestError } from "@/http/common";
-import { getHttpTokenClient } from "@/http/token";
-import { userService, useUser } from "@/services/user";
-
-import { LoadingButton } from "../common/button";
-import InputPanel, {
- hasError,
- InputPanelError,
-} from "../common/input/InputPanel";
-
-import "./index.css";
-
-const validate = (values: string[], dirties: boolean[]): InputPanelError => {
- const e: InputPanelError = {};
- if (dirties[0] && values[0].length === 0) {
- e[0] = "register.error.usernameEmpty";
- }
- if (dirties[1] && values[1].length === 0) {
- e[1] = "register.error.passwordEmpty";
- }
- if (dirties[2] && values[2] !== values[1]) {
- e[2] = "register.error.confirmPasswordWrong";
- }
- if (dirties[3] && values[3].length === 0) {
- e[3] = "register.error.registerCodeEmpty";
- }
- return e;
-};
-
-const RegisterPage: React.FC = () => {
- const navigate = useNavigate();
-
- const { t } = useTranslation();
-
- const [username, setUsername] = React.useState<string>("");
- const [password, setPassword] = React.useState<string>("");
- const [confirmPassword, setConfirmPassword] = React.useState<string>("");
- const [registerCode, setRegisterCode] = React.useState<string>("");
-
- const [dirty, setDirty] = React.useState<boolean[]>(new Array(4).fill(false));
-
- const [process, setProcess] = React.useState<boolean>(false);
-
- const [inputError, setInputError] = React.useState<InputPanelError>();
- const [resultError, setResultError] = React.useState<string | null>(null);
-
- const user = useUser();
-
- React.useEffect(() => {
- if (user != null) {
- navigate("/");
- }
- });
-
- return (
- <div className="container register-page">
- <InputPanel
- scheme={[
- {
- type: "text",
- label: "register.username",
- },
- {
- type: "text",
- label: "register.password",
- password: true,
- },
- {
- type: "text",
- label: "register.confirmPassword",
- password: true,
- },
- { type: "text", label: "register.registerCode" },
- ]}
- values={[username, password, confirmPassword, registerCode]}
- onChange={(values, index) => {
- setUsername(values[0]);
- setPassword(values[1]);
- setConfirmPassword(values[2]);
- setRegisterCode(values[3]);
- const newDirty = dirty.slice();
- newDirty[index] = true;
- setDirty(newDirty);
-
- setInputError(validate(values, newDirty));
- }}
- error={inputError}
- disable={process}
- />
- {resultError && <div className="cru-color-danger">{t(resultError)}</div>}
- <LoadingButton
- text="register.register"
- loading={process}
- disabled={hasError(inputError)}
- onClick={() => {
- const newDirty = dirty.slice().fill(true);
- setDirty(newDirty);
- const e = validate(
- [username, password, confirmPassword, registerCode],
- newDirty
- );
- if (hasError(e)) {
- setInputError(e);
- } else {
- setProcess(true);
- void getHttpTokenClient()
- .register({
- username,
- password,
- registerCode,
- })
- .then(
- () => {
- void userService
- .login({ username, password }, true)
- .then(() => {
- navigate("/");
- });
- },
- (error) => {
- if (error instanceof HttpBadRequestError) {
- setResultError("register.error.registerCodeInvalid");
- } else {
- setResultError("error.network");
- }
- setProcess(false);
- }
- );
- }
- }}
- />
- </div>
- );
-};
-
-export default RegisterPage;
diff --git a/FrontEnd/src/views/search/index.css b/FrontEnd/src/views/search/index.css
deleted file mode 100644
index 6ff4d9fa..00000000
--- a/FrontEnd/src/views/search/index.css
+++ /dev/null
@@ -1,15 +0,0 @@
-.timeline-search-result-item {
- border: 1px solid;
- border-color: #e9ecef;
- background: #f8f9fa;
- transition: all 0.3s;
-}
-.timeline-search-result-item:hover {
- border-color: #0d6efd;
-}
-
-.timeline-search-result-item-avatar {
- width: 2em;
- height: 2em;
- border-radius: 50%;
-}
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx
deleted file mode 100644
index 58257465..00000000
--- a/FrontEnd/src/views/search/index.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { useNavigate, useLocation } from "react-router-dom";
-import { Link } from "react-router-dom";
-
-import { HttpNetworkError } from "@/http/common";
-import { getHttpSearchClient } from "@/http/search";
-import { HttpTimelineInfo } from "@/http/timeline";
-
-import SearchInput from "../common/SearchInput";
-import UserAvatar from "../common/user/UserAvatar";
-
-import "./index.css";
-
-const TimelineSearchResultItemView: React.FC<{
- timeline: HttpTimelineInfo;
-}> = ({ timeline }) => {
- return (
- <div className="timeline-search-result-item my-2 p-3">
- <h4>
- <Link
- to={`/${timeline.owner.username}/${timeline.nameV2}`}
- className="mb-2 text-primary"
- >
- {timeline.title}
- <small className="ms-3 text-secondary">{timeline.nameV2}</small>
- </Link>
- </h4>
- <div>
- <UserAvatar
- username={timeline.owner.username}
- className="timeline-search-result-item-avatar me-2"
- />
- {timeline.owner.nickname}
- <small className="ms-3 text-secondary">
- @{timeline.owner.username}
- </small>
- </div>
- </div>
- );
-};
-
-const SearchPage: React.FC = () => {
- const { t } = useTranslation();
-
- const navigate = useNavigate();
- const location = useLocation();
- const searchParams = new URLSearchParams(location.search);
- const queryParam = searchParams.get("q");
-
- const [searchText, setSearchText] = React.useState<string>("");
- const [state, setState] = React.useState<
- HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error"
- >("init");
-
- const [forceResearchKey, setForceResearchKey] = React.useState<number>(0);
-
- React.useEffect(() => {
- setState("init");
- if (queryParam != null && queryParam.length > 0) {
- setSearchText(queryParam);
- setState("loading");
- void getHttpSearchClient()
- .searchTimelines(queryParam)
- .then(
- (ts) => {
- setState(ts);
- },
- (e) => {
- if (e instanceof HttpNetworkError) {
- setState("network-error");
- } else {
- setState("error");
- }
- }
- );
- }
- }, [queryParam, forceResearchKey]);
-
- return (
- <div className="container my-3">
- <div className="row justify-content-center">
- <SearchInput
- className="col-12 col-sm-9 col-md-6"
- value={searchText}
- onChange={setSearchText}
- loading={state === "loading"}
- onButtonClick={() => {
- if (queryParam === searchText) {
- setForceResearchKey((old) => old + 1);
- } else {
- navigate(`/search?q=${searchText}`);
- }
- }}
- />
- </div>
- {(() => {
- switch (state) {
- case "init": {
- if (queryParam == null || queryParam.length === 0) {
- return <div>{t("searchPage.input")}</div>;
- }
- break;
- }
- case "loading": {
- return <div>{t("searchPage.loading")}</div>;
- }
- case "network-error": {
- return <div className="text-danger">{t("error.network")}</div>;
- }
- case "error": {
- return <div className="text-danger">{t("error.unknown")}</div>;
- }
- default: {
- if (state.length === 0) {
- return <div>{t("searchPage.noResult")}</div>;
- }
- return state.map((t) => (
- <TimelineSearchResultItemView
- key={`${t.owner.username}/${t.nameV2}`}
- timeline={t}
- />
- ));
- }
- }
- })()}
- </div>
- );
-};
-
-export default SearchPage;
diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
deleted file mode 100644
index 44bd2c68..00000000
--- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-import { useState, useEffect } from "react";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { AxiosError } from "axios";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-
-import { useUser } from "@/services/user";
-
-import { getHttpUserClient } from "@/http/user";
-
-import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
-
-export interface ChangeAvatarDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
- const { t } = useTranslation();
-
- const user = useUser();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [clip, setClip] = React.useState<Clip | null>(null);
- const [cropImgElement, setCropImgElement] =
- React.useState<HTMLImageElement | null>(null);
- const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
- const [resultUrl, setResultUrl] = React.useState<string | null>(null);
-
- const [state, setState] = React.useState<
- | "select"
- | "crop"
- | "processcrop"
- | "preview"
- | "uploading"
- | "success"
- | "error"
- >("select");
-
- const [message, setMessage] = useState<I18nText>(
- "settings.dialogChangeAvatar.prompt.select"
- );
-
- const trueMessage = convertI18nText(message, t);
-
- const closeDialog = props.close;
-
- const close = React.useCallback((): void => {
- if (!(state === "uploading")) {
- closeDialog();
- }
- }, [state, closeDialog]);
-
- useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setClip(null);
- setFileUrl(url);
- setState("crop");
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setFileUrl(null);
- setState("select");
- }
- }, [file]);
-
- React.useEffect(() => {
- if (resultBlob != null) {
- const url = URL.createObjectURL(resultBlob);
- setResultUrl(url);
- setState("preview");
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setResultUrl(null);
- }
- }, [resultBlob]);
-
- const onSelectFile = React.useCallback(
- (e: React.ChangeEvent<HTMLInputElement>): void => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- } else {
- setFile(files[0]);
- }
- },
- []
- );
-
- const onCropNext = React.useCallback(() => {
- if (
- cropImgElement == null ||
- clip == null ||
- clip.width === 0 ||
- file == null
- ) {
- throw new UiLogicError();
- }
-
- setState("processcrop");
- void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
- setResultBlob(b);
- });
- }, [cropImgElement, clip, file]);
-
- const onCropPrevious = React.useCallback(() => {
- setFile(null);
- setState("select");
- }, []);
-
- const onPreviewPrevious = React.useCallback(() => {
- setResultBlob(null);
- setState("crop");
- }, []);
-
- const upload = React.useCallback(() => {
- if (resultBlob == null) {
- throw new UiLogicError();
- }
-
- if (user == null) {
- throw new UiLogicError();
- }
-
- setState("uploading");
- getHttpUserClient()
- .putAvatar(user.username, resultBlob)
- .then(
- () => {
- setState("success");
- },
- (e: unknown) => {
- setState("error");
- setMessage({ type: "custom", value: (e as AxiosError).message });
- }
- );
- }, [user, resultBlob]);
-
- const createPreviewRow = (): React.ReactElement => {
- if (resultUrl == null) {
- throw new UiLogicError();
- }
- return (
- <div className="row justify-content-center">
- <div className="col col-auto">
- <img
- className="change-avatar-img"
- src={resultUrl}
- alt={t("settings.dialogChangeAvatar.previewImgAlt") ?? undefined}
- />
- </div>
- </div>
- );
- };
-
- return (
- <Dialog open={props.open} onClose={close}>
- <h3 className="cru-color-primary">
- {t("settings.dialogChangeAvatar.title")}
- </h3>
- <hr />
- {(() => {
- if (state === "select") {
- return (
- <>
- <div className="container">
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.select")}
- </div>
- <div className="row">
- <input
- className="px-0"
- type="file"
- accept="image/*"
- onChange={onSelectFile}
- />
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- onClick={close}
- />
- </div>
- </>
- );
- } else if (state === "crop") {
- if (fileUrl == null) {
- throw new UiLogicError();
- }
- return (
- <>
- <div className="container">
- <div className="row justify-content-center">
- <ImageCropper
- clip={clip}
- onChange={setClip}
- imageUrl={fileUrl}
- imageElementCallback={setCropImgElement}
- />
- </div>
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.crop")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={close}
- />
- <Button
- text="operationDialog.previousStep"
- color="secondary"
- outline
- onClick={onCropPrevious}
- />
- <Button
- text="operationDialog.nextStep"
- color="primary"
- onClick={onCropNext}
- disabled={
- cropImgElement == null || clip == null || clip.width === 0
- }
- />
- </div>
- </>
- );
- } else if (state === "processcrop") {
- return (
- <>
- <div className="container">
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.processingCrop")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- onClick={close}
- outline
- />
- <Button
- text="operationDialog.previousStep"
- color="secondary"
- onClick={onPreviewPrevious}
- outline
- />
- </div>
- </>
- );
- } else if (state === "preview") {
- return (
- <>
- <div className="container">
- {createPreviewRow()}
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.preview")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.cancel"
- color="secondary"
- outline
- onClick={close}
- />
- <Button
- text="operationDialog.previousStep"
- color="secondary"
- outline
- onClick={onPreviewPrevious}
- />
- <Button
- text="settings.dialogChangeAvatar.upload"
- color="primary"
- onClick={upload}
- />
- </div>
- </>
- );
- } else if (state === "uploading") {
- return (
- <>
- <div className="container">
- {createPreviewRow()}
- <div className="row">
- {t("settings.dialogChangeAvatar.prompt.uploading")}
- </div>
- </div>
- </>
- );
- } else if (state === "success") {
- return (
- <>
- <div className="container">
- <div className="row p-4 text-success">
- {t("operationDialog.success")}
- </div>
- </div>
- <hr />
- <div className="cru-dialog-bottom-area">
- <Button
- text="operationDialog.ok"
- color="success"
- onClick={close}
- />
- </div>
- </>
- );
- } else {
- return (
- <>
- <div className="container">
- {createPreviewRow()}
- <div className="row text-danger">{trueMessage}</div>
- </div>
- <hr />
- <div>
- <Button
- text="operationDialog.cancel"
- color="secondary"
- onClick={close}
- />
- <Button
- text="operationDialog.retry"
- color="primary"
- onClick={upload}
- />
- </div>
- </>
- );
- }
- })()}
- </Dialog>
- );
-};
-
-export default ChangeAvatarDialog;
diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
deleted file mode 100644
index 7ba12de8..00000000
--- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { getHttpUserClient } from "@/http/user";
-import { useUser } from "@/services/user";
-import * as React from "react";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface ChangeNicknameDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
- const user = useUser();
-
- if (user == null) return null;
-
- return (
- <OperationDialog
- open={props.open}
- title="settings.dialogChangeNickname.title"
- inputScheme={[
- { type: "text", label: "settings.dialogChangeNickname.inputLabel" },
- ]}
- onProcess={([newNickname]) => {
- return getHttpUserClient().patch(user.username, {
- nickname: newNickname,
- });
- }}
- onClose={props.close}
- />
- );
-};
-
-export default ChangeNicknameDialog;
diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
deleted file mode 100644
index a34ca4a7..00000000
--- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { userService } from "@/services/user";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface ChangePasswordDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
- const navigate = useNavigate();
-
- const [redirect, setRedirect] = useState<boolean>(false);
-
- return (
- <OperationDialog
- open={props.open}
- title="settings.dialogChangePassword.title"
- themeColor="danger"
- inputPrompt="settings.dialogChangePassword.prompt"
- inputScheme={[
- {
- type: "text",
- label: "settings.dialogChangePassword.inputOldPassword",
- password: true,
- },
- {
- type: "text",
- label: "settings.dialogChangePassword.inputNewPassword",
- password: true,
- },
- {
- type: "text",
- label: "settings.dialogChangePassword.inputRetypeNewPassword",
- password: true,
- },
- ]}
- inputValidator={([oldPassword, newPassword, retypedNewPassword]) => {
- const result: Record<number, string> = {};
- if (oldPassword === "") {
- result[0] = "settings.dialogChangePassword.errorEmptyOldPassword";
- }
- if (newPassword === "") {
- result[1] = "settings.dialogChangePassword.errorEmptyNewPassword";
- }
- if (retypedNewPassword !== newPassword) {
- result[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
- }
- return result;
- }}
- onProcess={async ([oldPassword, newPassword]) => {
- await userService.changePassword(oldPassword, newPassword);
- setRedirect(true);
- }}
- onClose={() => {
- props.close();
- if (redirect) {
- navigate("/login");
- }
- }}
- />
- );
-};
-
-export default ChangePasswordDialog;
diff --git a/FrontEnd/src/views/settings/index.css b/FrontEnd/src/views/settings/index.css
deleted file mode 100644
index ccf7a97a..00000000
--- a/FrontEnd/src/views/settings/index.css
+++ /dev/null
@@ -1,31 +0,0 @@
-.change-avatar-cropper-row {
- max-height: 400px;
-}
-
-.change-avatar-img {
- min-width: 50%;
- max-width: 100%;
- max-height: 400px;
-}
-
-.settings-item {
- padding: 0.5em 1em;
- transition: background 0.3s;
- border-bottom: 1px solid #e9ecef;
- align-items: center;
-}
-.settings-item.first {
- border-top: 1px solid #e9ecef;
-}
-.settings-item.clickable {
- cursor: pointer;
-}
-.settings-item:hover {
- background: #dee2e6;
-}
-
-.register-code {
- border: 1px solid black;
- border-radius: 3px;
- padding: 0.2em;
-} \ No newline at end of file
diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx
deleted file mode 100644
index 6647826f..00000000
--- a/FrontEnd/src/views/settings/index.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import classNames from "classnames";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-import { useUser, userService } from "@/services/user";
-import { getHttpUserClient } from "@/http/user";
-import { TimelineVisibility } from "@/http/timeline";
-
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import Card from "../common/Card";
-import Spinner from "../common/Spinner";
-import ChangePasswordDialog from "./ChangePasswordDialog";
-import ChangeAvatarDialog from "./ChangeAvatarDialog";
-import ChangeNicknameDialog from "./ChangeNicknameDialog";
-
-import "./index.css";
-import { pushAlert } from "@/services/alert";
-
-interface SettingSectionProps {
- title: I18nText;
- children: React.ReactNode;
-}
-
-const SettingSection: React.FC<SettingSectionProps> = ({ title, children }) => {
- const { t } = useTranslation();
-
- return (
- <Card className="my-3 py-3">
- <h3 className="px-3 mb-3 cru-color-primary">
- {convertI18nText(title, t)}
- </h3>
- {children}
- </Card>
- );
-};
-
-interface SettingItemContainerWithoutChildrenProps {
- title: I18nText;
- subtext?: I18nText;
- first?: boolean;
- danger?: boolean;
- style?: React.CSSProperties;
- className?: string;
- onClick?: () => void;
-}
-
-interface SettingItemContainerProps
- extends SettingItemContainerWithoutChildrenProps {
- children?: React.ReactNode;
-}
-
-function SettingItemContainer({
- title,
- subtext,
- first,
- danger,
- children,
- style,
- className,
- onClick,
-}: SettingItemContainerProps): JSX.Element {
- const { t } = useTranslation();
-
- return (
- <div
- style={style}
- className={classNames(
- "row settings-item mx-0",
- first && "first",
- onClick && "clickable",
- className,
- )}
- onClick={onClick}
- >
- <div className="px-0 col col-auto">
- <div className={classNames(danger && "cru-color-danger")}>
- {convertI18nText(title, t)}
- </div>
- <small className="d-block cru-color-secondary">
- {convertI18nText(subtext, t)}
- </small>
- </div>
- <div className="col col-auto">{children}</div>
- </div>
- );
-}
-
-type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps;
-
-const ButtonSettingItem: React.FC<ButtonSettingItemProps> = ({ ...props }) => {
- return <SettingItemContainer {...props} />;
-};
-
-interface SelectSettingItemProps
- extends SettingItemContainerWithoutChildrenProps {
- options: {
- value: string;
- label: I18nText;
- }[];
- value?: string;
- onSelect: (value: string) => void;
-}
-
-const SelectSettingsItem: React.FC<SelectSettingItemProps> = ({
- options,
- value,
- onSelect,
- ...props
-}) => {
- const { t } = useTranslation();
-
- return (
- <SettingItemContainer {...props}>
- {value == null ? (
- <Spinner />
- ) : (
- <select
- value={value}
- onChange={(e) => {
- onSelect(e.target.value);
- }}
- >
- {options.map(({ value, label }) => (
- <option key={value} value={value}>
- {convertI18nText(label, t)}
- </option>
- ))}
- </select>
- )}
- </SettingItemContainer>
- );
-};
-
-const SettingsPage: React.FC = () => {
- const { i18n } = useTranslation();
- const user = useUser();
- const navigate = useNavigate();
-
- const [dialog, setDialog] = useState<
- | null
- | "changepassword"
- | "changeavatar"
- | "changenickname"
- | "logout"
- | "renewregistercode"
- >(null);
-
- const [registerCode, setRegisterCode] = useState<undefined | null | string>(
- undefined,
- );
-
- const [bookmarkVisibility, setBookmarkVisibility] =
- useState<TimelineVisibility>();
-
- React.useEffect(() => {
- if (user != null) {
- void getHttpUserClient()
- .getBookmarkVisibility(user.username)
- .then(({ visibility }) => {
- setBookmarkVisibility(visibility);
- });
- } else {
- setBookmarkVisibility(undefined);
- }
- }, [user]);
-
- React.useEffect(() => {
- setRegisterCode(undefined);
- }, [user]);
-
- React.useEffect(() => {
- if (user != null && registerCode === undefined) {
- void getHttpUserClient()
- .getRegisterCode(user.username)
- .then((code) => {
- setRegisterCode(code.registerCode ?? null);
- });
- }
- }, [user, registerCode]);
-
- const language = i18n.language.slice(0, 2);
-
- return (
- <>
- <div className="container">
- {user ? (
- <SettingSection title="settings.subheaders.account">
- <SettingItemContainer
- title="settings.myRegisterCode"
- subtext="settings.myRegisterCodeDesc"
- onClick={() => setDialog("renewregistercode")}
- >
- {registerCode === undefined ? (
- <Spinner />
- ) : registerCode === null ? (
- <span>Noop</span>
- ) : (
- <code
- className="register-code"
- onClick={(event) => {
- void navigator.clipboard
- .writeText(registerCode)
- .then(() => {
- pushAlert({
- type: "success",
- message: "settings.myRegisterCodeCopied",
- });
- });
- event.stopPropagation();
- }}
- >
- {registerCode}
- </code>
- )}
- </SettingItemContainer>
- <ButtonSettingItem
- title="settings.changeAvatar"
- onClick={() => setDialog("changeavatar")}
- first
- />
- <ButtonSettingItem
- title="settings.changeNickname"
- onClick={() => setDialog("changenickname")}
- />
- <SelectSettingsItem
- title="settings.changeBookmarkVisibility"
- options={[
- {
- value: "Private",
- label: "visibility.private",
- },
- {
- value: "Register",
- label: "visibility.register",
- },
- {
- value: "Public",
- label: "visibility.public",
- },
- ]}
- value={bookmarkVisibility}
- onSelect={(value) => {
- void getHttpUserClient()
- .putBookmarkVisibility(user.username, {
- visibility: value as TimelineVisibility,
- })
- .then(() => {
- setBookmarkVisibility(value as TimelineVisibility);
- });
- }}
- />
- <ButtonSettingItem
- title="settings.changePassword"
- onClick={() => setDialog("changepassword")}
- danger
- />
- <ButtonSettingItem
- title="settings.logout"
- onClick={() => {
- setDialog("logout");
- }}
- danger
- />
- </SettingSection>
- ) : null}
- <SettingSection title="settings.subheaders.customization">
- <SelectSettingsItem
- title="settings.languagePrimary"
- subtext="settings.languageSecondary"
- options={[
- {
- value: "zh",
- label: {
- type: "custom",
- value: "中文",
- },
- },
- {
- value: "en",
- label: {
- type: "custom",
- value: "English",
- },
- },
- ]}
- value={language}
- onSelect={(value) => {
- void i18n.changeLanguage(value);
- }}
- first
- />
- </SettingSection>
- </div>
- <ChangePasswordDialog
- open={dialog === "changepassword"}
- close={() => setDialog(null)}
- />
- <ConfirmDialog
- title="settings.dialogConfirmLogout.title"
- body="settings.dialogConfirmLogout.prompt"
- onClose={() => setDialog(null)}
- open={dialog === "logout"}
- onConfirm={() => {
- void userService.logout().then(() => {
- navigate("/");
- });
- }}
- />
- <ConfirmDialog
- title="settings.renewRegisterCode"
- body="settings.renewRegisterCodeDesc"
- onClose={() => setDialog(null)}
- open={dialog === "renewregistercode"}
- onConfirm={() => {
- if (user == null) throw new UiLogicError();
- void getHttpUserClient()
- .renewRegisterCode(user.username)
- .then(() => {
- setRegisterCode(undefined);
- });
- }}
- />
- <ChangeAvatarDialog
- open={dialog === "changeavatar"}
- close={() => setDialog(null)}
- />
- <ChangeNicknameDialog
- open={dialog === "changenickname"}
- close={() => setDialog(null)}
- />
- </>
- );
-};
-
-export default SettingsPage;
diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx
deleted file mode 100644
index 374ccc2e..00000000
--- a/FrontEnd/src/views/timeline/CollapseButton.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react";
-
-import IconButton from "../common/button/IconButton";
-
-const CollapseButton: React.FC<{
- collapse: boolean;
- onClick: () => void;
- className?: string;
- style?: React.CSSProperties;
-}> = ({ collapse, onClick, className, style }) => {
- return (
- <IconButton
- icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"}
- onClick={onClick}
- className={className}
- style={style}
- />
- );
-};
-
-export default CollapseButton;
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css
deleted file mode 100644
index e36be992..00000000
--- a/FrontEnd/src/views/timeline/MarkdownPostEdit.css
+++ /dev/null
@@ -1,21 +0,0 @@
-.timeline-markdown-post-edit-page {
- overflow: auto;
- max-height: 300px;
-}
-
-.timeline-markdown-post-edit-image-container {
- position: relative;
- text-align: center;
- margin-bottom: 1em;
-}
-
-.timeline-markdown-post-edit-image {
- max-width: 100%;
- max-height: 200px;
-}
-
-.timeline-markdown-post-edit-image-delete-button {
- position: absolute;
- right: 10px;
- top: 2px;
-}
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
deleted file mode 100644
index 6401cfaa..00000000
--- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import TimelinePostBuilder from "@/services/TimelinePostBuilder";
-
-import FlatButton from "../common/button/FlatButton";
-import TabPages from "../common/tab/TabPages";
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import Spinner from "../common/Spinner";
-import IconButton from "../common/button/IconButton";
-
-import "./MarkdownPostEdit.css";
-
-export interface MarkdownPostEditProps {
- owner: string;
- timeline: string;
- onPosted: (post: HttpTimelinePostInfo) => void;
- onPostError: () => void;
- onClose: () => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
- owner: ownerUsername,
- timeline: timelineName,
- onPosted,
- onClose,
- onPostError,
- className,
- style,
-}) => {
- const { t } = useTranslation();
-
- const [canLeave, setCanLeave] = React.useState<boolean>(true);
-
- const [process, setProcess] = React.useState<boolean>(false);
-
- const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] =
- React.useState<boolean>(false);
-
- const [text, _setText] = React.useState<string>("");
- const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
- []
- );
- const [previewHtml, _setPreviewHtml] = React.useState<string>("");
-
- const _builder = React.useRef<TimelinePostBuilder | null>(null);
-
- const getBuilder = (): TimelinePostBuilder => {
- if (_builder.current == null) {
- const builder = new TimelinePostBuilder(() => {
- setCanLeave(builder.isEmpty);
- _setText(builder.text);
- _setImages(builder.images);
- _setPreviewHtml(builder.renderHtml());
- });
- _builder.current = builder;
- }
- return _builder.current;
- };
-
- const canSend = text.length > 0;
-
- React.useEffect(() => {
- return () => {
- getBuilder().dispose();
- };
- }, []);
-
- React.useEffect(() => {
- window.onbeforeunload = (): unknown => {
- if (!canLeave) {
- return t("timeline.confirmLeave");
- }
- };
-
- return () => {
- window.onbeforeunload = null;
- };
- }, [canLeave, t]);
-
- const send = async (): Promise<void> => {
- setProcess(true);
- try {
- const dataList = await getBuilder().build();
- const post = await getHttpTimelineClient().postPost(
- ownerUsername,
- timelineName,
- {
- dataList,
- }
- );
- onPosted(post);
- onClose();
- } catch (e) {
- setProcess(false);
- onPostError();
- }
- };
-
- return (
- <>
- <TabPages
- className={className}
- style={style}
- pageContainerClassName="py-2"
- dense
- actions={
- process ? (
- <Spinner />
- ) : (
- <div>
- <IconButton
- icon="x"
- color="danger"
- large
- className="cru-align-middle me-2"
- onClick={() => {
- if (canLeave) {
- onClose();
- } else {
- setShowLeaveConfirmDialog(true);
- }
- }}
- />
- {canSend && (
- <FlatButton text="timeline.send" onClick={() => void send()} />
- )}
- </div>
- )
- }
- pages={[
- {
- name: "text",
- text: "edit",
- page: (
- <textarea
- value={text}
- disabled={process}
- className="cru-fill-parent"
- onChange={(event) => {
- getBuilder().setMarkdownText(event.currentTarget.value);
- }}
- />
- ),
- },
- {
- name: "images",
- text: "image",
- page: (
- <div className="timeline-markdown-post-edit-page">
- {images.map((image, index) => (
- <div
- key={image.url}
- className="timeline-markdown-post-edit-image-container"
- >
- <img
- src={image.url}
- className="timeline-markdown-post-edit-image"
- />
- <IconButton
- icon="trash"
- color="danger"
- className={classnames(
- "timeline-markdown-post-edit-image-delete-button",
- process && "d-none"
- )}
- onClick={() => {
- getBuilder().deleteImage(index);
- }}
- />
- </div>
- ))}
- <input
- type="file"
- accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
- onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
- const { files } = event.currentTarget;
- if (files != null && files.length !== 0) {
- getBuilder().appendImage(files[0]);
- }
- }}
- disabled={process}
- />
- </div>
- ),
- },
- {
- name: "preview",
- text: "preview",
- page: (
- <div
- className="markdown-container timeline-markdown-post-edit-page"
- dangerouslySetInnerHTML={{ __html: previewHtml }}
- />
- ),
- },
- ]}
- />
- <ConfirmDialog
- onClose={() => setShowLeaveConfirmDialog(false)}
- onConfirm={onClose}
- open={showLeaveConfirmDialog}
- title="timeline.dropDraft"
- body="timeline.confirmLeave"
- />
- </>
- );
-};
-
-export default MarkdownPostEdit;
diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
deleted file mode 100644
index fc55185c..00000000
--- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import * as React from "react";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-function PostPropertyChangeDialog(props: {
- open: boolean;
- onClose: () => void;
- post: HttpTimelinePostInfo;
- onSuccess: (post: HttpTimelinePostInfo) => void;
-}): React.ReactElement | null {
- const { open, onClose, post, onSuccess } = props;
-
- return (
- <OperationDialog
- title="timeline.changePostPropertyDialog.title"
- onClose={onClose}
- open={open}
- inputScheme={[
- {
- label: "timeline.changePostPropertyDialog.time",
- type: "datetime",
- initValue: post.time,
- },
- ]}
- onProcess={([time]) => {
- return getHttpTimelineClient().patchPost(
- post.timelineOwnerV2,
- post.timelineNameV2,
- post.id,
- {
- time: time === "" ? undefined : new Date(time).toISOString(),
- }
- );
- }}
- onSuccessAndClose={onSuccess}
- />
- );
-}
-
-export default PostPropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css
deleted file mode 100644
index 4dd4fdcc..00000000
--- a/FrontEnd/src/views/timeline/Timeline.css
+++ /dev/null
@@ -1,244 +0,0 @@
-.timeline {
- z-index: 0;
- position: relative;
- width: 100%;
-}
-
-@keyframes timeline-line-node {
- to {
- box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
- }
-}
-
-@keyframes timeline-line-node-current {
- to {
- box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color);
- }
-}
-
-@keyframes timeline-line-node-loading {
- to {
- box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
- }
-}
-
-@keyframes timeline-line-node-loading-edge {
- from {
- transform: rotate(0turn);
- }
- to {
- transform: rotate(1turn);
- }
-}
-
-@keyframes timeline-top-loading-enter {
- from {
- transform: translate(0, -100%);
- }
-}
-
-@keyframes timeline-post-enter {
- from {
- transform: translate(0, 100%);
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-.timeline-top-loading-enter {
- animation: 1s timeline-top-loading-enter;
-}
-
-.timeline-line {
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 30px;
- position: absolute;
- z-index: 1;
- left: 2em;
- top: 0;
- bottom: 0;
- transition: left 0.5s;
-}
-
-@media (max-width: 575.98px) {
- .timeline-line {
- left: 1em;
- }
-}
-
-.timeline-line .segment {
- width: 7px;
- background: var(--cru-primary-color);
-}
-.timeline-line .segment.start {
- height: 1.8em;
- flex: 0 0 auto;
-}
-.timeline-line .segment.end {
- flex: 1 1 auto;
-}
-.timeline-line .segment.current-end {
- height: 2em;
- flex: 0 0 auto;
- background: linear-gradient(var(--cru-primary-enhance-color), white);
-}
-.timeline-line .node-container {
- flex: 0 0 auto;
- position: relative;
- width: 18px;
- height: 18px;
-}
-.timeline-line .node {
- width: 20px;
- height: 20px;
- position: absolute;
- background: var(--cru-primary-color);
- left: -1px;
- top: -1px;
- border-radius: 50%;
- box-sizing: border-box;
- z-index: 1;
- animation: 1s infinite alternate;
- animation-name: timeline-line-node;
-}
-.timeline-line .node-loading-edge {
- color: var(--cru-primary-color);
- width: 38px;
- height: 38px;
- position: absolute;
- left: -10px;
- top: -10px;
- box-sizing: border-box;
- z-index: 2;
- animation: 1.5s linear infinite timeline-line-node-loading-edge;
-}
-.timeline-line.current .segment.start {
- background: linear-gradient(
- var(--cru-primary-color),
- var(--cru-primary-enhance-color)
- );
-}
-
-.timeline-line.current .segment.end {
- background: var(--cru-primary-enhance-color);
-}
-
-.timeline-line.current .node {
- background: var(--cru-primary-enhance-color);
- animation-name: timeline-line-node-current;
-}
-
-.timeline-line.loading .node {
- background: var(--cru-primary-color);
- animation-name: timeline-line-node-loading;
-}
-
-.timeline-item {
- position: relative;
- padding: 0.5em;
-}
-
-.timeline-item-card {
- position: relative;
- padding: 0.5em 0.5em 0.5em 4em;
-}
-
-.timeline-item-card.enter-animation {
- animation: 0.6s forwards;
- opacity: 0;
-}
-
-@media (max-width: 575.98px) {
- .timeline-item-card {
- padding-left: 3em;
- }
-}
-
-.timeline-item-header {
- display: flex;
- align-items: center;
-}
-
-.timeline-avatar {
- border-radius: 50%;
- width: 2em;
- height: 2em;
-}
-
-.timeline-item-delete-button {
- position: absolute;
- right: 0;
- bottom: 0;
-}
-
-.timeline-content {
- white-space: pre-line;
-}
-
-.timeline-content-image {
- max-width: 80%;
- max-height: 200px;
-}
-
-.timeline-date-item {
- position: relative;
- padding: 0.3em 0 0.3em 4em;
-}
-
-.timeline-date-item-badge {
- display: inline-block;
- padding: 0.1em 0.4em;
- border-radius: 0.4em;
- background: #7c7c7c;
- color: white;
- font-size: 0.8em;
-}
-
-.timeline-post-item-options-mask {
- background: rgba(255, 255, 255, 0.85);
- z-index: 100;
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
-
- display: flex;
- justify-content: space-around;
- align-items: center;
-
- border-radius: var(--cru-card-border-radius);
-}
-
-.timeline-sync-state-badge {
- font-size: 0.8em;
- padding: 3px 8px;
- border-radius: 5px;
- background: #e8fbff;
-}
-
-.timeline-sync-state-badge-pin {
- display: inline-block;
- width: 0.4em;
- height: 0.4em;
- border-radius: 50%;
- vertical-align: middle;
- margin-right: 0.6em;
-}
-
-.timeline-card {
- position: fixed;
- z-index: 1029;
- top: 56px;
- right: 0;
- margin: 0.5em;
-}
-
-.timeline-top {
- position: sticky;
- top: 56px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx
deleted file mode 100644
index fdf7f0a0..00000000
--- a/FrontEnd/src/views/timeline/TimelineCard.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import classnames from "classnames";
-import { HubConnectionState } from "@microsoft/signalr";
-
-import { useIsSmallScreen } from "@/utilities/hooks";
-import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
-import { useUser } from "@/services/user";
-import { pushAlert } from "@/services/alert";
-import { HttpTimelineInfo } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
-
-import UserAvatar from "../common/user/UserAvatar";
-import PopupMenu from "../common/menu/PopupMenu";
-import FullPageDialog from "../common/dialog/FullPageDialog";
-import Card from "../common/Card";
-import TimelineDeleteDialog from "./TimelineDeleteDialog";
-import ConnectionStatusBadge from "./ConnectionStatusBadge";
-import CollapseButton from "./CollapseButton";
-import { TimelineMemberDialog } from "./TimelineMember";
-import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import IconButton from "../common/button/IconButton";
-
-export interface TimelinePageCardProps {
- timeline: HttpTimelineInfo;
- connectionStatus: HubConnectionState;
- className?: string;
- onReload: () => void;
-}
-
-const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
- const { timeline, connectionStatus, onReload, className } = props;
-
- const { t } = useTranslation();
-
- const [dialog, setDialog] = React.useState<
- "member" | "property" | "delete" | null
- >(null);
-
- const [collapse, setCollapse] = React.useState(true);
- const toggleCollapse = (): void => {
- setCollapse((o) => !o);
- };
-
- const isSmallScreen = useIsSmallScreen();
-
- const user = useUser();
-
- const content = (
- <>
- <h3 className="cru-color-primary d-inline-block align-middle">
- {timeline.title}
- <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small>
- </h3>
- <div>
- <UserAvatar
- username={timeline.owner.username}
- className="cru-avatar small cru-round me-3"
- />
- {timeline.owner.nickname}
- <small className="ms-3 cru-color-secondary">
- @{timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
- </small>
- <div className="mt-2 cru-text-end">
- {user != null ? (
- <IconButton
- icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
- className="me-3"
- onClick={() => {
- getHttpBookmarkClient()
- [timeline.isBookmark ? "delete" : "post"](
- user.username,
- timeline.owner.username,
- timeline.nameV2
- )
- .then(onReload, () => {
- pushAlert({
- message: timeline.isBookmark
- ? "timeline.removeBookmarkFail"
- : "timeline.addBookmarkFail",
- type: "danger",
- });
- });
- }}
- />
- ) : null}
- <IconButton
- icon="people"
- className="me-3"
- onClick={() => setDialog("member")}
- />
- {timeline.manageable ? (
- <PopupMenu
- items={[
- {
- type: "button",
- text: "timeline.manageItem.property",
- onClick: () => setDialog("property"),
- },
- { type: "divider" },
- {
- type: "button",
- onClick: () => setDialog("delete"),
- color: "danger",
- text: "timeline.manageItem.delete",
- },
- ]}
- containerClassName="d-inline"
- >
- <IconButton icon="three-dots-vertical" />
- </PopupMenu>
- ) : null}
- </div>
- </>
- );
-
- return (
- <>
- <Card className={classnames("p-2 cru-clearfix", className)}>
- <div
- className={classnames(
- "cru-float-right d-flex align-items-center",
- !collapse && "ms-3"
- )}
- >
- <ConnectionStatusBadge status={connectionStatus} className="me-2" />
- <CollapseButton collapse={collapse} onClick={toggleCollapse} />
- </div>
- {isSmallScreen ? (
- <FullPageDialog
- onBack={toggleCollapse}
- show={!collapse}
- contentContainerClassName="p-2"
- >
- {content}
- </FullPageDialog>
- ) : (
- <div style={{ display: collapse ? "none" : "inline" }}>{content}</div>
- )}
- </Card>
- <TimelineMemberDialog
- timeline={timeline}
- onClose={() => setDialog(null)}
- open={dialog === "member"}
- onChange={onReload}
- />
- <TimelinePropertyChangeDialog
- timeline={timeline}
- close={() => setDialog(null)}
- open={dialog === "property"}
- onChange={onReload}
- />
- <TimelineDeleteDialog
- timeline={timeline}
- open={dialog === "delete"}
- close={() => setDialog(null)}
- />
- </>
- );
-};
-
-export default TimelineCard;
diff --git a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
deleted file mode 100644
index 5f4ac706..00000000
--- a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react";
-import TimelineLine from "./TimelineLine";
-
-export interface TimelineDateItemProps {
- date: Date;
-}
-
-const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
- return (
- <div className="timeline-date-item">
- <TimelineLine center="none" />
- <div className="timeline-date-item-badge">
- {date.toLocaleDateString()}
- </div>
- </div>
- );
-};
-
-export default TimelineDateLabel;
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
deleted file mode 100644
index c960b3c2..00000000
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { Trans } from "react-i18next";
-
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-interface TimelineDeleteDialog {
- timeline: HttpTimelineInfo;
- open: boolean;
- close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
- const navigate = useNavigate();
-
- const { timeline } = props;
-
- return (
- <OperationDialog
- open={props.open}
- onClose={props.close}
- title="timeline.deleteDialog.title"
- themeColor="danger"
- inputPrompt={() => {
- return (
- <Trans
- i18nKey="timeline.deleteDialog.inputPrompt"
- values={{ name: timeline.nameV2 }}
- >
- 0<code className="mx-2">1</code>2
- </Trans>
- );
- }}
- inputScheme={[
- {
- type: "text",
- },
- ]}
- inputValidator={([value]) => {
- if (value !== timeline.nameV2) {
- return { 0: "timeline.deleteDialog.notMatch" };
- } else {
- return null;
- }
- }}
- onProcess={() => {
- return getHttpTimelineClient().deleteTimeline(
- timeline.owner.username,
- timeline.nameV2
- );
- }}
- onSuccessAndClose={() => {
- navigate("/", { replace: true });
- }}
- />
- );
-};
-
-export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
deleted file mode 100644
index 5e0728d4..00000000
--- a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import TimelineLine, { TimelineLineProps } from "./TimelineLine";
-
-export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> {
- height?: number | string;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => {
- const { height, style, className, center, ...lineProps } = props;
-
- return (
- <div
- style={{ ...style, height: height }}
- className={classnames("timeline-item", className)}
- >
- <TimelineLine center={center ?? "none"} {...lineProps} />
- </div>
- );
-};
-
-export default TimelineEmptyItem;
diff --git a/FrontEnd/src/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx
deleted file mode 100644
index 4a87e6e0..00000000
--- a/FrontEnd/src/views/timeline/TimelineLine.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-export interface TimelineLineProps {
- current?: boolean;
- startSegmentLength?: string | number;
- center: "node" | "loading" | "none";
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineLine: React.FC<TimelineLineProps> = ({
- startSegmentLength,
- center,
- current,
- className,
- style,
-}) => {
- return (
- <div
- className={classnames(
- "timeline-line",
- current && "current",
- center === "loading" && "loading",
- className
- )}
- style={style}
- >
- <div className="segment start" style={{ height: startSegmentLength }} />
- {center !== "none" ? (
- <div className="node-container">
- <div className="node"></div>
- {center === "loading" ? (
- <svg className="node-loading-edge" viewBox="0 0 100 100">
- <path
- d="M 50,10 A 40 40 45 0 1 78.28,21.72"
- stroke="currentcolor"
- strokeLinecap="square"
- strokeWidth="8"
- />
- </svg>
- ) : null}
- </div>
- ) : null}
- {center !== "loading" ? <div className="segment end"></div> : null}
- {current && <div className="segment current-end" />}
- </div>
- );
-};
-
-export default TimelineLine;
diff --git a/FrontEnd/src/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx
deleted file mode 100644
index f876cba9..00000000
--- a/FrontEnd/src/views/timeline/TimelineLoading.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as React from "react";
-
-import TimelineEmptyItem from "./TimelineEmptyItem";
-
-const TimelineLoading: React.FC = () => {
- return (
- <TimelineEmptyItem
- className="timeline-top-loading-enter"
- height={100}
- center="loading"
- startSegmentLength={56}
- />
- );
-};
-
-export default TimelineLoading;
diff --git a/FrontEnd/src/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css
deleted file mode 100644
index adb78764..00000000
--- a/FrontEnd/src/views/timeline/TimelineMember.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.timeline-member-item {
- border: var(--cru-background-1-color) solid;
- border-width: 0.5px 1px;
-}
-
-.timeline-member-item > div {
- padding: 0.5em;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
deleted file mode 100644
index 9ed192e5..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { marked } from "marked";
-
-import { UiLogicError } from "@/common";
-
-import { HttpNetworkError } from "@/http/common";
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import { useUser } from "@/services/user";
-
-import Skeleton from "../common/Skeleton";
-import LoadFailReload from "../common/LoadFailReload";
-
-const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
- const { post, className, style } = props;
-
- const [text, setText] = React.useState<string | null>(null);
- const [error, setError] = React.useState<"offline" | "error" | null>(null);
-
- const [reloadKey, setReloadKey] = React.useState<number>(0);
-
- React.useEffect(() => {
- let subscribe = true;
-
- setText(null);
- setError(null);
-
- void getHttpTimelineClient()
- .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id)
- .then(
- (data) => {
- if (subscribe) setText(data);
- },
- (error) => {
- if (subscribe) {
- if (error instanceof HttpNetworkError) {
- setError("offline");
- } else {
- setError("error");
- }
- }
- }
- );
-
- return () => {
- subscribe = false;
- };
- }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
-
- if (error != null) {
- return (
- <LoadFailReload
- className={className}
- style={style}
- onReload={() => setReloadKey(reloadKey + 1)}
- />
- );
- } else if (text == null) {
- return <Skeleton />;
- } else {
- return (
- <div className={className} style={style}>
- {text}
- </div>
- );
- }
-};
-
-const ImageView: React.FC<TimelinePostContentViewProps> = (props) => {
- const { post, className, style } = props;
-
- useUser();
-
- return (
- <img
- src={getHttpTimelineClient().generatePostDataUrl(
- post.timelineOwnerV2,
- post.timelineNameV2,
- post.id
- )}
- className={classnames(className, "timeline-content-image")}
- style={style}
- />
- );
-};
-
-const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
- const { post, className, style } = props;
-
- const [markdown, setMarkdown] = React.useState<string | null>(null);
- const [error, setError] = React.useState<"offline" | "error" | null>(null);
-
- const [reloadKey, setReloadKey] = React.useState<number>(0);
-
- React.useEffect(() => {
- let subscribe = true;
-
- setMarkdown(null);
- setError(null);
-
- void getHttpTimelineClient()
- .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id)
- .then(
- (data) => {
- if (subscribe) setMarkdown(data);
- },
- (error) => {
- if (subscribe) {
- if (error instanceof HttpNetworkError) {
- setError("offline");
- } else {
- setError("error");
- }
- }
- }
- );
-
- return () => {
- subscribe = false;
- };
- }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
-
- const markdownHtml = React.useMemo<string | null>(() => {
- if (markdown == null) return null;
- return marked.parse(markdown);
- }, [markdown]);
-
- if (error != null) {
- return (
- <LoadFailReload
- className={className}
- style={style}
- onReload={() => setReloadKey(reloadKey + 1)}
- />
- );
- } else if (markdown == null) {
- return <Skeleton />;
- } else {
- if (markdownHtml == null) {
- throw new UiLogicError("Markdown is not null but markdown html is.");
- }
- return (
- <div
- className={classnames(className, "markdown-container")}
- style={style}
- dangerouslySetInnerHTML={{
- __html: markdownHtml,
- }}
- />
- );
- }
-};
-
-export interface TimelinePostContentViewProps {
- post: HttpTimelinePostInfo;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
- "text/plain": TextView,
- "text/markdown": MarkdownView,
- "image/png": ImageView,
- "image/jpeg": ImageView,
- "image/gif": ImageView,
- "image/webp": ImageView,
-};
-
-const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = (
- props
-) => {
- const { post, className, style } = props;
-
- const type = post.dataList[0].kind;
-
- if (type in viewMap) {
- const View = viewMap[type];
- return <View post={post} className={className} style={style} />;
- } else {
- // TODO: i18n
- console.error("Unknown post type", post);
- return <div>Error, unknown post type!</div>;
- }
-};
-
-export default TimelinePostContentView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css
deleted file mode 100644
index 9b7629e2..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEdit.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.timeline-post-edit {
- position: sticky !important;
- top: 106px;
- z-index: 100;
-}
-
-.timeline-post-edit-image {
- max-width: 100px;
- max-height: 100px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
deleted file mode 100644
index 38e72264..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-import {
- getHttpTimelineClient,
- HttpTimelineInfo,
- HttpTimelinePostInfo,
- HttpTimelinePostPostRequestData,
-} from "@/http/timeline";
-
-import { pushAlert } from "@/services/alert";
-
-import base64 from "@/utilities/base64";
-
-import BlobImage from "../common/BlobImage";
-import LoadingButton from "../common/button/LoadingButton";
-import PopupMenu from "../common/menu/PopupMenu";
-import MarkdownPostEdit from "./MarkdownPostEdit";
-import TimelinePostEditCard from "./TimelinePostEditCard";
-import IconButton from "../common/button/IconButton";
-
-import "./TimelinePostEdit.css";
-
-interface TimelinePostEditTextProps {
- text: string;
- disabled: boolean;
- onChange: (text: string) => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => {
- const { text, disabled, onChange, className, style } = props;
-
- return (
- <textarea
- value={text}
- disabled={disabled}
- onChange={(event) => {
- onChange(event.target.value);
- }}
- className={className}
- style={style}
- />
- );
-};
-
-interface TimelinePostEditImageProps {
- onSelect: (file: File | null) => void;
- disabled: boolean;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
- const { onSelect, disabled } = props;
-
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [error, setError] = React.useState<boolean>(false);
-
- const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
- setError(false);
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- onSelect(null);
- } else {
- setFile(files[0]);
- }
- };
-
- React.useEffect(() => {
- return () => {
- onSelect(null);
- };
- }, [onSelect]);
-
- return (
- <>
- <input
- type="file"
- onChange={onInputChange}
- accept="image/*"
- disabled={disabled}
- className="mx-3 my-1"
- />
- {file != null && !error && (
- <BlobImage
- blob={file}
- className="timeline-post-edit-image"
- onLoad={() => onSelect(file)}
- onError={() => {
- onSelect(null);
- setError(true);
- }}
- />
- )}
- {error ? <div className="text-danger">{t("loadImageError")}</div> : null}
- </>
- );
-};
-
-type PostKind = "text" | "markdown" | "image";
-
-const postKindIconMap: Record<PostKind, string> = {
- text: "fonts",
- markdown: "markdown",
- image: "image",
-};
-
-export interface TimelinePostEditProps {
- className?: string;
- style?: React.CSSProperties;
- timeline: HttpTimelineInfo;
- onPosted: (newPost: HttpTimelinePostInfo) => void;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { timeline, style, className, onPosted } = props;
-
- const { t } = useTranslation();
-
- const [process, setProcess] = React.useState<boolean>(false);
-
- const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text");
- const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false);
-
- const [text, setText] = React.useState<string>("");
- const [image, setImage] = React.useState<File | null>(null);
-
- const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
-
- React.useEffect(() => {
- setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
- }, [draftTextLocalStorageKey]);
-
- const canSend =
- (kind === "text" && text.length !== 0) ||
- (kind === "image" && image != null);
-
- const onPostError = (): void => {
- pushAlert({
- type: "danger",
- message: "timeline.sendPostFailed",
- });
- };
-
- const onSend = async (): Promise<void> => {
- setProcess(true);
-
- let requestData: HttpTimelinePostPostRequestData;
- switch (kind) {
- case "text":
- requestData = {
- contentType: "text/plain",
- data: await base64(text),
- };
- break;
- case "image":
- if (image == null) {
- throw new UiLogicError(
- "Content type is image but image blob is null.",
- );
- }
- requestData = {
- contentType: image.type,
- data: await base64(image),
- };
- break;
- default:
- throw new UiLogicError("Unknown content type.");
- }
-
- getHttpTimelineClient()
- .postPost(timeline.owner.username, timeline.nameV2, {
- dataList: [requestData],
- })
- .then(
- (data) => {
- if (kind === "text") {
- setText("");
- window.localStorage.removeItem(draftTextLocalStorageKey);
- }
- setProcess(false);
- setKind("text");
- onPosted(data);
- },
- () => {
- setProcess(false);
- onPostError();
- },
- );
- };
-
- return (
- <TimelinePostEditCard className={className} style={style}>
- {showMarkdown ? (
- <MarkdownPostEdit
- className="cru-fill-parent"
- onClose={() => setShowMarkdown(false)}
- owner={timeline.owner.username}
- timeline={timeline.nameV2}
- onPosted={onPosted}
- onPostError={onPostError}
- />
- ) : (
- <div className="row">
- <div className="col px-1 py-1">
- {(() => {
- if (kind === "text") {
- return (
- <TimelinePostEditText
- className="cru-fill-parent timeline-post-edit"
- text={text}
- disabled={process}
- onChange={(t) => {
- setText(t);
- window.localStorage.setItem(draftTextLocalStorageKey, t);
- }}
- />
- );
- } else if (kind === "image") {
- return (
- <TimelinePostEditImage
- onSelect={setImage}
- disabled={process}
- />
- );
- }
- })()}
- </div>
- <div className="col col-auto align-self-end m-1">
- <div className="d-block cru-text-center mt-1 mb-2">
- <PopupMenu
- items={(["text", "image", "markdown"] as const).map((kind) => ({
- type: "button",
- text: `timeline.post.type.${kind}`,
- iconClassName: postKindIconMap[kind],
- onClick: () => {
- if (kind === "markdown") {
- setShowMarkdown(true);
- } else {
- setKind(kind);
- }
- },
- }))}
- >
- <IconButton large icon={postKindIconMap[kind]} />
- </PopupMenu>
- </div>
- <LoadingButton
- onClick={() => void onSend()}
- disabled={!canSend}
- loading={process}
- >
- {t("timeline.send")}
- </LoadingButton>
- </div>
- </div>
- )}
- </TimelinePostEditCard>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
deleted file mode 100644
index d2f7bd72..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import Card from "../common/Card";
-import TimelineLine from "./TimelineLine";
-
-import "./TimelinePostEdit.css";
-
-export interface TimelinePostEditCardProps {
- className?: string;
- style?: React.CSSProperties;
- children?: React.ReactNode;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({
- className,
- style,
- children,
-}) => {
- return (
- <div
- className={classnames("timeline-item timeline-post-edit", className)}
- style={style}
- >
- <TimelineLine center="node" />
- <Card className="timeline-item-card">{children}</Card>
- </div>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
deleted file mode 100644
index 1ef0a287..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import * as React from "react";
-import { Trans } from "react-i18next";
-import { Link } from "react-router-dom";
-
-import TimelinePostEditCard from "./TimelinePostEditCard";
-
-export default function TimelinePostEditNoLogin(): React.ReactElement | null {
- return (
- <TimelinePostEditCard>
- <div className="mt-3 mb-4">
- <Trans
- i18nKey="timeline.postNoLogin"
- components={{ l: <Link to="/login" /> }}
- />
- </div>
- </TimelinePostEditCard>
- );
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx
deleted file mode 100644
index e3eac0f4..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostView.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import { pushAlert } from "@/services/alert";
-
-import { useClickOutside } from "@/utilities/hooks";
-
-import UserAvatar from "../common/user/UserAvatar";
-import Card from "../common/Card";
-import FlatButton from "../common/button/FlatButton";
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import TimelineLine from "./TimelineLine";
-import TimelinePostContentView from "./TimelinePostContentView";
-import PostPropertyChangeDialog from "./PostPropertyChangeDialog";
-import IconButton from "../common/button/IconButton";
-
-export interface TimelinePostViewProps {
- post: HttpTimelinePostInfo;
- className?: string;
- style?: React.CSSProperties;
- cardStyle?: React.CSSProperties;
- onChanged: (post: HttpTimelinePostInfo) => void;
- onDeleted: () => void;
-}
-
-const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
- const { post, className, style, cardStyle, onChanged, onDeleted } = props;
-
- const [operationMaskVisible, setOperationMaskVisible] =
- React.useState<boolean>(false);
- const [dialog, setDialog] = React.useState<
- "delete" | "changeproperty" | null
- >(null);
-
- const [maskElement, setMaskElement] = React.useState<HTMLElement | null>(
- null
- );
-
- useClickOutside(maskElement, () => setOperationMaskVisible(false));
-
- const cardRef = React.useRef<HTMLDivElement>(null);
- React.useEffect(() => {
- const cardIntersectionObserver = new IntersectionObserver(([e]) => {
- if (e.intersectionRatio > 0) {
- if (cardRef.current != null) {
- cardRef.current.style.animationName = "timeline-post-enter";
- }
- }
- });
- if (cardRef.current) {
- cardIntersectionObserver.observe(cardRef.current);
- }
-
- return () => {
- cardIntersectionObserver.disconnect();
- };
- }, []);
-
- return (
- <div
- id={`timeline-post-${post.id}`}
- className={classnames("timeline-item", className)}
- style={style}
- >
- <TimelineLine center="node" />
- <Card
- ref={cardRef}
- className="timeline-item-card enter-animation"
- style={cardStyle}
- >
- {post.editable ? (
- <IconButton
- icon="chevron-down"
- color="primary-enhance"
- className="cru-float-right"
- onClick={(e) => {
- setOperationMaskVisible(true);
- e.stopPropagation();
- }}
- />
- ) : null}
- <div className="timeline-item-header">
- <span className="me-2">
- <span>
- <UserAvatar
- username={post.author.username}
- className="timeline-avatar me-1"
- />
- <small className="text-dark me-2">{post.author.nickname}</small>
- <small className="text-secondary white-space-no-wrap">
- {new Date(post.time).toLocaleTimeString()}
- </small>
- </span>
- </span>
- </div>
- <div className="timeline-content">
- <TimelinePostContentView post={post} />
- </div>
- {operationMaskVisible ? (
- <div
- ref={setMaskElement}
- className="timeline-post-item-options-mask"
- onClick={() => {
- setOperationMaskVisible(false);
- }}
- >
- <FlatButton
- text="changeProperty"
- onClick={(e) => {
- setDialog("changeproperty");
- e.stopPropagation();
- }}
- />
- <FlatButton
- text="delete"
- color="danger"
- onClick={(e) => {
- setDialog("delete");
- e.stopPropagation();
- }}
- />
- </div>
- ) : null}
- </Card>
- <ConfirmDialog
- title="timeline.post.deleteDialog.title"
- body="timeline.post.deleteDialog.prompt"
- open={dialog === "delete"}
- onClose={() => {
- setDialog(null);
- setOperationMaskVisible(false);
- }}
- onConfirm={() => {
- void getHttpTimelineClient()
- .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
- .then(onDeleted, () => {
- pushAlert({
- type: "danger",
- message: "timeline.deletePostFailed",
- });
- });
- }}
- />
- <PostPropertyChangeDialog
- open={dialog === "changeproperty"}
- onClose={() => {
- setDialog(null);
- setOperationMaskVisible(false);
- }}
- post={post}
- onSuccess={onChanged}
- />
- </div>
- );
-};
-
-export default TimelinePostView;
diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index 63750445..00000000
--- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import * as React from "react";
-
-import {
- getHttpTimelineClient,
- HttpTimelineInfo,
- HttpTimelinePatchRequest,
- kTimelineVisibilities,
- TimelineVisibility,
-} from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface TimelinePropertyChangeDialogProps {
- open: boolean;
- close: () => void;
- timeline: HttpTimelineInfo;
- onChange: () => void;
-}
-
-const labelMap: { [key in TimelineVisibility]: string } = {
- Private: "timeline.visibility.private",
- Public: "timeline.visibility.public",
- Register: "timeline.visibility.register",
-};
-
-const TimelinePropertyChangeDialog: React.FC<
- TimelinePropertyChangeDialogProps
-> = (props) => {
- const { timeline, onChange } = props;
-
- return (
- <OperationDialog
- title={"timeline.dialogChangeProperty.title"}
- inputScheme={
- [
- {
- type: "text",
- label: "timeline.dialogChangeProperty.titleField",
- initValue: timeline.title,
- },
- {
- type: "select",
- label: "timeline.dialogChangeProperty.visibility",
- options: kTimelineVisibilities.map((v) => ({
- label: labelMap[v],
- value: v,
- })),
- initValue: timeline.visibility,
- },
- {
- type: "text",
- label: "timeline.dialogChangeProperty.description",
- initValue: timeline.description,
- },
- {
- type: "color",
- label: "timeline.dialogChangeProperty.color",
- initValue: timeline.color ?? null,
- canBeNull: true,
- },
- ] as const
- }
- open={props.open}
- onClose={props.close}
- onProcess={([newTitle, newVisibility, newDescription, newColor]) => {
- const req: HttpTimelinePatchRequest = {};
- if (newTitle !== timeline.title) {
- req.title = newTitle;
- }
- if (newVisibility !== timeline.visibility) {
- req.visibility = newVisibility as TimelineVisibility;
- }
- if (newDescription !== timeline.description) {
- req.description = newDescription;
- }
- const nc = newColor ?? "";
- if (nc !== timeline.color) {
- req.color = nc;
- }
- return getHttpTimelineClient()
- .patchTimeline(timeline.owner.username, timeline.nameV2, req)
- .then(onChange);
- }}
- />
- );
-};
-
-export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/tools/theme-generator.ts b/FrontEnd/tools/theme-generator.ts
new file mode 100644
index 00000000..3583d240
--- /dev/null
+++ b/FrontEnd/tools/theme-generator.ts
@@ -0,0 +1,495 @@
+#!/usr/bin/env ts-node
+
+/**
+ * Color variable name scheme:
+ * no variant: --[prefix]-[name]-color: [color];
+ * with variant: --[prefix]-[name]-[variant]-color: [color];
+ *
+ * Lightness variants come from material design (https://m3.material.io/styles/color/the-color-system/tokens)
+ */
+
+import { stdout } from "process";
+
+interface CssSegment {
+ toCssString(): string;
+}
+
+interface Color extends CssSegment {
+ readonly type: "hsl" | "css-var";
+ toString(): string;
+}
+
+class HslColor implements Color {
+ readonly type = "hsl";
+
+ constructor(
+ public h: number,
+ public s: number,
+ public l: number,
+ ) {}
+
+ withLightness(lightness: number): HslColor {
+ return new HslColor(this.h, this.s, lightness);
+ }
+
+ toCssString(): string {
+ return this.toString();
+ }
+
+ toString(): string {
+ return `hsl(${this.h} ${this.s}% ${this.l}%)`;
+ }
+
+ static readonly white = new HslColor(0, 0, 100);
+ static readonly black = new HslColor(0, 0, 0);
+}
+
+class ColorVariable implements CssSegment {
+ constructor(
+ public prefix: string,
+ public name: string,
+ public variant: string,
+ ) {}
+
+ toString(): string {
+ const variantPart = this.variant !== "" ? `-${this.variant}` : "";
+ return `--${this.prefix}-${this.name}${variantPart}-color`;
+ }
+
+ toCssString(): string {
+ return this.toString();
+ }
+}
+
+class CssVarColor implements Color {
+ readonly type = "css-var";
+
+ constructor(public colorVariable: ColorVariable) {}
+
+ toCssString(): string {
+ return this.toString();
+ }
+
+ toString(): string {
+ return `var(${this.colorVariable.toString()})`;
+ }
+}
+
+class ColorVariableDefinition implements CssSegment {
+ constructor(
+ public variable: ColorVariable,
+ public color: Color,
+ ) {}
+
+ toCssString(): string {
+ return `${this.variable.toCssString()}: ${this.color.toCssString()};`;
+ }
+}
+
+abstract class ColorGroup implements CssSegment {
+ abstract getColorVariables(): ColorVariableDefinition[];
+ toCssString(): string {
+ return this.getColorVariables()
+ .map((c) => c.toCssString())
+ .join("\n");
+ }
+}
+
+interface LightnessVariantInfo {
+ name: string;
+ lightness: number;
+}
+
+class LightnessVariantColorGroup extends ColorGroup {
+ constructor(
+ public prefix: string,
+ public name: string,
+ public baseColor: HslColor,
+ public variants: LightnessVariantInfo[],
+ ) {
+ super();
+ }
+
+ getColorVariables(): ColorVariableDefinition[] {
+ const result: ColorVariableDefinition[] = [];
+
+ for (const variant of this.variants) {
+ const color = this.baseColor.withLightness(variant.lightness);
+ result.push(
+ new ColorVariableDefinition(
+ new ColorVariable(this.prefix, this.name, variant.name),
+ color,
+ ),
+ );
+ }
+
+ return result;
+ }
+}
+
+class VarAliasColorGroup extends ColorGroup {
+ constructor(
+ public prefix: string,
+ public newName: string,
+ public oldName: string,
+ public variants: string[],
+ ) {
+ super();
+ }
+
+ getColorVariables(): ColorVariableDefinition[] {
+ const result = [];
+ for (const variant of this.variants) {
+ result.push(
+ new ColorVariableDefinition(
+ new ColorVariable(this.prefix, this.newName, variant),
+ new CssVarColor(
+ new ColorVariable(this.prefix, this.oldName, variant),
+ ),
+ ),
+ );
+ }
+ return result;
+ }
+}
+
+class CompositeColorGroup extends ColorGroup {
+ constructor(public groups: ColorGroup[]) {
+ super();
+ }
+
+ getColorVariables(): ColorVariableDefinition[] {
+ return this.groups
+ .map((g) => g.getColorVariables())
+ .reduce((prev, curr) => prev.concat(curr), []);
+ }
+}
+
+interface ThemeColorsInfo {
+ keyColors: { name: string; color: HslColor }[];
+ neutralColor: HslColor;
+}
+
+type ColorMode = "light" | "dark";
+
+type ThemeColorVariantLightnessVariantsInfo =
+ | number
+ | number[]
+ | {
+ base: number;
+ direction: "darker" | "lighter";
+ levels: number;
+ step: number;
+ };
+
+interface ThemeColorVariantInfo {
+ name: string;
+ variants: {
+ light: ThemeColorVariantLightnessVariantsInfo;
+ dark: ThemeColorVariantLightnessVariantsInfo;
+ };
+}
+
+class ThemeColorVariant {
+ constructor(
+ public name: string,
+ public variants: {
+ light: ThemeColorVariantLightnessVariantsInfo;
+ dark: ThemeColorVariantLightnessVariantsInfo;
+ },
+ ) {}
+ getLightnessVariants(mode: ColorMode): LightnessVariantInfo[] {
+ const { name, variants } = this;
+ const list = variants[mode];
+
+ function variantName(i: number) {
+ if (name.length === 0) {
+ return i === 0 ? "" : String(i);
+ } else {
+ return i === 0 ? name : `${name}-${i}`;
+ }
+ }
+
+ function fromList(list: number[]): LightnessVariantInfo[] {
+ return list.map((l, i) => ({
+ name: variantName(i),
+ lightness: l,
+ }));
+ }
+
+ if (typeof list === "number") {
+ return fromList([list]);
+ } else if (Array.isArray(list)) {
+ return fromList(list);
+ } else {
+ const l = [list.base];
+ for (let i = 1; i <= list.levels; i++) {
+ if (list.direction === "darker") {
+ l.push(list.base - i * list.step);
+ } else {
+ l.push(list.base + i * list.step);
+ }
+ }
+ return fromList(l);
+ }
+ }
+
+ static from(info: ThemeColorVariantInfo): ThemeColorVariant {
+ return new ThemeColorVariant(info.name, info.variants);
+ }
+}
+
+class ThemeColor {
+ variants: ThemeColorVariant[];
+
+ constructor(
+ public prefix: string,
+ public name: string,
+ public color: HslColor,
+ variants: ThemeColorVariantInfo[],
+ ) {
+ this.variants = variants.map((v) => ThemeColorVariant.from(v));
+ }
+
+ getLightnessVariants(mode: ColorMode): LightnessVariantInfo[] {
+ return this.variants.flatMap((v) => v.getLightnessVariants(mode));
+ }
+
+ getLightnessVariantColorGroup(mode: ColorMode): LightnessVariantColorGroup {
+ return new LightnessVariantColorGroup(
+ this.prefix,
+ this.name,
+ this.color,
+ this.getLightnessVariants(mode),
+ );
+ }
+}
+
+class Theme {
+ static keyColorVariants: ThemeColorVariantInfo[] = [
+ {
+ name: "",
+ variants: {
+ light: [40, 37, 34],
+ dark: [80, 75, 68],
+ },
+ },
+ {
+ name: "on",
+ variants: {
+ light: 100,
+ dark: 20,
+ },
+ },
+ {
+ name: "container",
+ variants: {
+ light: [90, 80, 70],
+ dark: [30, 25, 20],
+ },
+ },
+ {
+ name: "on-container",
+ variants: {
+ light: 10,
+ dark: 90,
+ },
+ },
+ ];
+
+ static surfaceColorVariants: ThemeColorVariantInfo[] = [
+ {
+ name: "dim",
+ variants: {
+ light: 87,
+ dark: 6,
+ },
+ },
+ {
+ name: "",
+ variants: {
+ light: [98, 90, 82],
+ dark: [6, 25, 40],
+ },
+ },
+ {
+ name: "bright",
+ variants: {
+ light: 98,
+ dark: 24,
+ },
+ },
+ {
+ name: "container-lowest",
+ variants: {
+ light: 100,
+ dark: 4,
+ },
+ },
+ {
+ name: "container-low",
+ variants: {
+ light: 96,
+ dark: 10,
+ },
+ },
+ {
+ name: "container",
+ variants: {
+ light: 94,
+ dark: 12,
+ },
+ },
+ {
+ name: "container-high",
+ variants: {
+ light: 92,
+ dark: 17,
+ },
+ },
+ {
+ name: "container-highest",
+ variants: {
+ light: 90,
+ dark: 22,
+ },
+ },
+ {
+ name: "on",
+ variants: {
+ light: 10,
+ dark: 90,
+ },
+ },
+ {
+ name: "on-variant",
+ variants: {
+ light: 30,
+ dark: 80,
+ },
+ },
+ {
+ name: "outline",
+ variants: {
+ light: 50,
+ dark: 60,
+ },
+ },
+ {
+ name: "outline-variant",
+ variants: {
+ light: 80,
+ dark: 30,
+ },
+ },
+ ];
+
+ constructor(
+ public prefix: string,
+ public themeColors: ThemeColorsInfo,
+ ) {}
+
+ getColorModeColorDefinitions(mode: ColorMode): ColorGroup {
+ const groups: ColorGroup[] = [];
+ for (const { name, color } of this.themeColors.keyColors) {
+ const themeColor = new ThemeColor(
+ this.prefix,
+ name,
+ color,
+ Theme.keyColorVariants,
+ );
+ groups.push(themeColor.getLightnessVariantColorGroup(mode));
+ }
+ const neutralThemeColor = new ThemeColor(
+ this.prefix,
+ "surface",
+ this.themeColors.neutralColor,
+ Theme.surfaceColorVariants,
+ );
+ groups.push(neutralThemeColor.getLightnessVariantColorGroup(mode));
+ return new CompositeColorGroup(groups);
+ }
+
+ getAliasColorDefinitions(name: string): ColorGroup {
+ const sampleThemeColor = this.themeColors.keyColors[0];
+ const themeColor = new ThemeColor(
+ this.prefix,
+ sampleThemeColor.name,
+ sampleThemeColor.color,
+ Theme.keyColorVariants,
+ );
+ const sampleMode = "light";
+ return new VarAliasColorGroup(
+ this.prefix,
+ "key",
+ name,
+ themeColor.getLightnessVariants(sampleMode).map((v) => v.name),
+ );
+ }
+
+ generateCss(print: (text: string, indent: number) => void): void {
+ print(":root {", 0);
+ print(this.getColorModeColorDefinitions("light").toCssString(), 1);
+ print("}", 0);
+
+ print("", 0);
+
+ print("@media (prefers-color-scheme: dark) {", 0);
+ print(":root {", 1);
+ print(this.getColorModeColorDefinitions("dark").toCssString(), 2);
+ print("}", 1);
+ print("}", 0);
+
+ print("", 0);
+
+ for (const { name } of this.themeColors.keyColors) {
+ print(`.${this.prefix}-${name} {`, 0);
+ print(this.getAliasColorDefinitions(name).toCssString(), 1);
+ print("}", 0);
+
+ print("", 0);
+ }
+ }
+}
+
+(function main() {
+ const prefix = "cru";
+ const themeColors: ThemeColorsInfo = {
+ keyColors: [
+ { name: "primary", color: new HslColor(210, 100, 50) },
+ { name: "secondary", color: new HslColor(40, 100, 50) },
+ { name: "tertiary", color: new HslColor(160, 100, 50) },
+ { name: "danger", color: new HslColor(0, 100, 50) },
+ { name: "success", color: new HslColor(120, 60, 50) },
+ ],
+ neutralColor: new HslColor(0, 0, 50),
+ };
+
+ const theme = new Theme(prefix, themeColors);
+
+ let output = "";
+
+ function indentText(
+ text: string,
+ level: number,
+ indentWidth = 2,
+ appendNewlines = 1,
+ ): string {
+ const lines = text.split("\n");
+ const indent = " ".repeat(level * indentWidth);
+ return (
+ lines
+ .map((line) => (line.length === 0 ? "" : `${indent}${line}`))
+ .join("\n") + "\n".repeat(appendNewlines)
+ );
+ }
+
+ function print(text: string, indent = 0, appendNewlines = 1) {
+ output += indentText(text, indent, 2, appendNewlines);
+ }
+
+ print("/* Generated by theme-generator.ts */\n");
+ theme.generateCss(print);
+
+ stdout.write(output);
+})();
diff --git a/FrontEnd/tools/tsconfig.json b/FrontEnd/tools/tsconfig.json
new file mode 100644
index 00000000..08f53190
--- /dev/null
+++ b/FrontEnd/tools/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ // This is an alias to @tsconfig/node20: https://github.com/tsconfig/bases
+ "extends": "@tsconfig/node20/tsconfig.json",
+ // Most ts-node options can be specified here using their programmatic names.
+ "ts-node": {
+ // It is faster to skip typechecking.
+ // Remove if you want ts-node to do typechecking.
+ "transpileOnly": true,
+ "compilerOptions": {
+ // compilerOptions specified here will override those declared below,
+ // but *only* in ts-node. Useful if you want ts-node and tsc to use
+ // different options with a single tsconfig.json.
+ }
+ },
+ "compilerOptions": {
+ // typescript options here
+ },
+ "include": [
+ "**/*"
+ ]
+} \ No newline at end of file
diff --git a/FrontEnd/tsconfig.json b/FrontEnd/tsconfig.json
index 97946126..bf106d95 100644
--- a/FrontEnd/tsconfig.json
+++ b/FrontEnd/tsconfig.json
@@ -21,13 +21,13 @@
"~*": [
"./*"
],
- "@/*": [
- "./src/*"
- ]
},
"noEmit": true
},
"include": [
"src"
+ ],
+ "exclude": [
+ "src/migrating"
]
} \ No newline at end of file
diff --git a/dev b/dev
index b10e9d08..70130209 100755
--- a/dev
+++ b/dev
@@ -2,5 +2,5 @@
MYDIR="$(dirname "$(realpath "$0")")"
-exec tmux new-session "cd ${MYDIR}/FrontEnd && pnpm run start" \; \
- split-window -h "cd ${MYDIR}/BackEnd/Timeline && dotnet run --launch-profile Dev"
+exec tmux new-session "./dev-frontend" \; \
+ split-window -h "./dev-backend"
diff --git a/dev-backend b/dev-backend
new file mode 100755
index 00000000..1ed31f4e
--- /dev/null
+++ b/dev-backend
@@ -0,0 +1,5 @@
+#!/usr/bin/env sh
+
+MYDIR="$(dirname "$(realpath "$0")")"
+
+cd ${MYDIR}/BackEnd/Timeline && dotnet run --launch-profile Dev
diff --git a/dev-backend.ps1 b/dev-backend.ps1
new file mode 100644
index 00000000..958ae75e
--- /dev/null
+++ b/dev-backend.ps1
@@ -0,0 +1,3 @@
+Push-Location $PSCommandPath/../Backend/Timeline
+dotnet run --launch-profile Dev
+Pop-Location
diff --git a/dev-frontend b/dev-frontend
new file mode 100755
index 00000000..28342ba2
--- /dev/null
+++ b/dev-frontend
@@ -0,0 +1,5 @@
+#!/usr/bin/env sh
+
+MYDIR="$(dirname "$(realpath "$0")")"
+
+cd ${MYDIR}/FrontEnd && pnpm run start
diff --git a/dev-frontend.ps1 b/dev-frontend.ps1
new file mode 100644
index 00000000..e004980d
--- /dev/null
+++ b/dev-frontend.ps1
@@ -0,0 +1,3 @@
+Push-Location $PSCommandPath/../FrontEnd
+pnpm run start
+Pop-Location