Browse Source

Merge branch 'feat/120698-read-only-user' into feat/120698-121330-add-is-read-only-user

ryoji-s 2 years ago
parent
commit
f30ad85a4b
61 changed files with 2374 additions and 252 deletions
  1. 1 0
      README.md
  2. 1 0
      README_JP.md
  3. 6 5
      apps/app/public/static/locales/en_US/commons.json
  4. 35 4
      apps/app/public/static/locales/en_US/translation.json
  5. 6 5
      apps/app/public/static/locales/ja_JP/commons.json
  6. 35 3
      apps/app/public/static/locales/ja_JP/translation.json
  7. 6 5
      apps/app/public/static/locales/zh_CN/commons.json
  8. 31 1
      apps/app/public/static/locales/zh_CN/translation.json
  9. 46 0
      apps/app/src/client/util/bookmark-utils.ts
  10. 32 0
      apps/app/src/client/util/input-validator.ts
  11. 25 32
      apps/app/src/components/BookmarkButtons.tsx
  12. 297 0
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  13. 63 0
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  14. 200 0
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  15. 27 0
      apps/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx
  16. 30 0
      apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx
  17. 85 0
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  18. 134 0
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  19. 162 0
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  20. 23 0
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  21. 73 0
      apps/app/src/components/Bookmarks/DragAndDropWrapper.tsx
  22. 9 16
      apps/app/src/components/Common/ClosableTextInput.tsx
  23. 3 1
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  24. 70 0
      apps/app/src/components/DeleteBookmarkFolderModal.tsx
  25. 17 0
      apps/app/src/components/Icons/CompressIcon.tsx
  26. 18 0
      apps/app/src/components/Icons/ExpandIcon.tsx
  27. 37 0
      apps/app/src/components/Icons/FolderIcon.tsx
  28. 16 0
      apps/app/src/components/Icons/FolderPlusIcon.tsx
  29. 1 3
      apps/app/src/components/Icons/TriangleIcon.tsx
  30. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  31. 5 20
      apps/app/src/components/Navbar/SubNavButtons.tsx
  32. 1 2
      apps/app/src/components/NotFoundPage.tsx
  33. 8 0
      apps/app/src/components/PageEditor.tsx
  34. 1 1
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  35. 0 79
      apps/app/src/components/PageList/BookmarkList.tsx
  36. 5 1
      apps/app/src/components/PageList/PageListItemL.tsx
  37. 5 2
      apps/app/src/components/PageList/PageListItemS.tsx
  38. 7 1
      apps/app/src/components/PrivateLegacyPages.tsx
  39. 28 0
      apps/app/src/components/Sidebar/Bookmarks.tsx
  40. 59 0
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  41. 15 20
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  42. 4 0
      apps/app/src/components/Sidebar/SidebarContents.tsx
  43. 1 0
      apps/app/src/components/Sidebar/SidebarNav.tsx
  44. 96 0
      apps/app/src/components/UsersHomePageFooter.module.scss
  45. 21 5
      apps/app/src/components/UsersHomePageFooter.tsx
  46. 32 0
      apps/app/src/interfaces/bookmark-info.ts
  47. 2 0
      apps/app/src/interfaces/ui.ts
  48. 232 0
      apps/app/src/server/models/bookmark-folder.ts
  49. 3 0
      apps/app/src/server/models/errors.ts
  50. 126 0
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  51. 6 30
      apps/app/src/server/routes/apiv3/bookmarks.js
  52. 1 1
      apps/app/src/server/routes/apiv3/index.js
  53. 15 0
      apps/app/src/stores/bookmark-folder.ts
  54. 20 0
      apps/app/src/stores/bookmark.ts
  55. 45 4
      apps/app/src/stores/modal.tsx
  56. 56 2
      apps/app/src/styles/theme/_apply-colors-dark.scss
  57. 58 2
      apps/app/src/styles/theme/_apply-colors-light.scss
  58. 17 2
      apps/app/src/styles/theme/apply-colors.scss
  59. 2 1
      apps/app/src/styles/theme/mixins/_list-group.scss
  60. 6 4
      apps/app/test/cypress/integration/20-basic-features/20-basic-features--click-page-icons.spec.ts
  61. 6 0
      packages/preset-themes/src/styles/island.scss

+ 1 - 0
README.md

@@ -83,6 +83,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 - Node.js v16.x or v18.x
 - Node.js v16.x or v18.x
 - npm 6.x
 - npm 6.x
 - yarn
 - yarn
+- [Turborepo](https://turbo.build/repo)
 - MongoDB 4.x
 - MongoDB 4.x
 
 
 ### Optional Dependencies
 ### Optional Dependencies

+ 1 - 0
README_JP.md

@@ -82,6 +82,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 - Node.js v16.x or v18.x
 - Node.js v16.x or v18.x
 - npm 6.x
 - npm 6.x
 - yarn
 - yarn
+- [Turborepo](https://turbo.build/repo)
 - MongoDB 4.x
 - MongoDB 4.x
 
 
 ### オプションの依存関係
 ### オプションの依存関係

+ 6 - 5
apps/app/public/static/locales/en_US/commons.json

@@ -10,13 +10,14 @@
     "display_name": "English"
     "display_name": "English"
   },
   },
   "toaster": {
   "toaster": {
-    "create_succeeded": "Succeeded to create {{target}}",
+    "add_succeeded": "Succeeded to add {{target}}",
     "create_failed": "Failed to create {{target}}",
     "create_failed": "Failed to create {{target}}",
-    "update_successed": "Succeeded to update {{target}}",
-    "update_failed": "Failed to update {{target}}",
-
+    "create_succeeded": "Succeeded to create {{target}}",
+    "delete_succeeded": "Succeeded to delete {{target}}",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
-    "remove_share_link": "Succeeded to remove {{count}} share links"
+    "update_failed": "Failed to update {{target}}",
+    "update_successed": "Succeeded to update {{target}}"
   },
   },
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",

+ 35 - 4
apps/app/public/static/locales/en_US/translation.json

@@ -158,8 +158,12 @@
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required."
+    "title_required": "Title is required.",
+    "field_required": "{{target}} is required"
   },
   },
+  "page_name": "Page name",
+  "folder_name": "Folder name",
+  "field": "field",
   "not_creatable_page": {
   "not_creatable_page": {
     "could_not_creata_path": "Couldn't create path."
     "could_not_creata_path": "Couldn't create path."
   },
   },
@@ -440,8 +444,19 @@
   "toaster": {
   "toaster": {
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "file_upload_failed": "File upload failed.",
-    "save_succeeded": "Saved successfully",
-    "issue_share_link": "Succeeded to issue new share link"
+    "initialize_successed": "Succeeded to initialize {{target}}",
+    "give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "activate_user_success": "Succeeded to activating {{username}}",
+    "deactivate_user_success": "Succeeded to deactivate {{username}}",
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "issue_share_link": "Succeeded to issue new share link",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
+    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
+    "failed_to_reset_password":"Failed to reset password",
+    "save_succeeded": "Saved successfully"
   },
   },
   "template": {
   "template": {
     "modal_label": {
     "modal_label": {
@@ -766,7 +781,23 @@
     "bookmarks": "Bookmarks",
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
     "recently_created": "Recently Created"
   },
   },
-
+  "bookmark_folder":{
+    "bookmark_folder": "bookmark folder",
+    "bookmark": "bookmark",
+    "delete_modal": {
+      "modal_header_label": "Delete Bookmark Folder",
+      "modal_body_description": "Delete this bookmark folder and its contents",
+      "modal_body_alert": "Deleted folder and its contents cannot be recovered",
+      "modal_footer_button": "Delete Folder"
+    },
+    "input_placeholder": "Input folder name",
+    "new_folder": "New Folder",
+    "delete": "Delete Folder",
+    "drop_item_here": "Drag and drop item here",
+    "cancel_bookmark": "Un-bookmark this page",
+    "move_to_root": "Move to the root",
+    "root": "root (default)"
+  },
   "v5_page_migration": {
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "go_to_settings": "Go to settings to enable the feature"
     "go_to_settings": "Go to settings to enable the feature"

+ 6 - 5
apps/app/public/static/locales/ja_JP/commons.json

@@ -9,13 +9,14 @@
     "display_name": "日本語"
     "display_name": "日本語"
   },
   },
   "toaster": {
   "toaster": {
-    "create_succeeded": "新しい{{target}}が作成されました",
+    "add_succeeded": "新しい{{target}}が追加されました",
+    "delete_succeeded": "{{target}} の削除に成功しました",
     "create_failed": "{{target}}の作成に失敗しました",
     "create_failed": "{{target}}の作成に失敗しました",
-    "update_successed": "{{target}}を更新しました",
+    "create_succeeded": "新しい{{target}}が作成されました",
     "update_failed": "{{target}}の更新に失敗しました",
     "update_failed": "{{target}}の更新に失敗しました",
-
-    "remove_share_link_success": "{{shareLinkId}}を削除しました",
-    "remove_share_link": "共有リンクを{{count}}件削除しました"
+    "update_successed": "{{target}}を更新しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました"
   },
   },
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",

+ 35 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -159,8 +159,12 @@
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください"
+    "title_required": "タイトルを入力してください",
+    "field_required": "{{target}}に値を入力してください"
   },
   },
+  "page_name": "ページ名",
+  "folder_name": "フォルダ名",
+  "field": "フィールド",
   "not_creatable_page": {
   "not_creatable_page": {
     "could_not_creata_path": "パスを作成できませんでした。"
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   },
@@ -473,8 +477,19 @@
   "toaster": {
   "toaster": {
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
-    "save_succeeded": "保存に成功しました",
-    "issue_share_link": "共有リンクを作成しました"
+    "initialize_successed": "{{target}}を初期化しました",
+    "give_user_admin": "{{username}}を管理者に設定しました",
+    "remove_user_admin": "{{username}}を管理者から外しました",
+    "activate_user_success": "{{username}}を有効化しました",
+    "deactivate_user_success": "{{username}}を無効化しました",
+    "remove_user_success": "{{username}}を削除しました",
+    "remove_external_user_success": "{{accountId}}を削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました",
+    "issue_share_link": "共有リンクを作成しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
+    "failed_to_reset_password":"パスワードのリセットに失敗しました",
+    "save_succeeded": "保存に成功しました"
   },
   },
   "template": {
   "template": {
     "modal_label": {
     "modal_label": {
@@ -799,6 +814,23 @@
     "bookmarks": "ブックマーク",
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
     "recently_created": "最近作成したページ"
   },
   },
+  "bookmark_folder":{
+    "bookmark_folder": "ブックマークフォルダ",
+    "bookmark": "ブックマーク",
+    "delete_modal": {
+      "modal_header_label": "ブックマークフォルダを削除",
+      "modal_body_description": "このブックマークフォルダと配下のブックマークを削除する",
+      "modal_body_alert": "削除されたフォルダとその内容は復元できません",
+      "modal_footer_button": "フォルダを削除"
+    },
+    "input_placeholder": "フォルダ名を入力してください",
+    "new_folder": "新しいフォルダ",
+    "delete": "フォルダを削除",
+    "drop_item_here": "ルートに配置する",
+    "cancel_bookmark": "このページのブックマークを解除",
+    "move_to_root": "ルートに配置する",
+    "root": "root (default)"
+  },
   "v5_page_migration": {
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "go_to_settings": "設定する"
     "go_to_settings": "設定する"

+ 6 - 5
apps/app/public/static/locales/zh_CN/commons.json

@@ -10,13 +10,14 @@
     "display_name": "简体中文"
     "display_name": "简体中文"
   },
   },
   "toaster": {
   "toaster": {
-    "create_succeeded": "Succeeded to create {{target}}",
+    "add_succeeded": "Succeeded to add {{target}}",
     "create_failed": "Failed to create {{target}}",
     "create_failed": "Failed to create {{target}}",
-    "update_successed": "Succeeded to update {{target}}",
-    "update_failed": "Failed to update {{target}}",
-
+    "create_succeeded": "Succeeded to create {{target}}",
+    "delete_succeeded": "Succeeded to delete {{target}}",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
-    "remove_share_link": "Succeeded to remove {{count}} share links"
+    "update_failed": "Failed to update {{target}}",
+    "update_successed": "Succeeded to update {{target}}"
   },
   },
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",

+ 31 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -165,8 +165,12 @@
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。"
+    "title_required": "标题是必需的。",
+    "field_required": "{{target}} 是必需的"
   },
   },
+  "page_name": "页面名称",
+  "folder_name": "文件夹名称",
+  "field": "字段",
   "not_creatable_page": {
   "not_creatable_page": {
     "could_not_creata_path": "无法创建路径"
     "could_not_creata_path": "无法创建路径"
   },
   },
@@ -429,6 +433,15 @@
 	"toaster": {
 	"toaster": {
     "file_upload_succeeded": "文件上传成功",
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "file_upload_failed": "文件上传失败",
+    "initialize_successed": "Succeeded to initialize {{target}}",
+		"give_user_admin": "Succeeded to give {{username}} admin",
+    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+		"activate_user_success": "Succeeded to activating {{username}}",
+		"deactivate_user_success": "Succeeded to deactivate {{username}}",
+		"remove_user_success": "Succeeded to removing {{username}} ",
+    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "switch_disable_link_sharing_success": "成功更新分享链接设置",
+    "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",
     "save_succeeded": "已成功保存",
     "issue_share_link": "Succeeded to issue new share link"
     "issue_share_link": "Succeeded to issue new share link"
   },
   },
@@ -771,6 +784,23 @@
     "bookmarks": "书签",
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
     "recently_created": "最近创建页面"
   },
   },
+  "bookmark_folder": {
+    "bookmark_folder": "书签文件夹",
+    "bookmark": "书签",
+    "delete_modal": {
+      "modal_header_label": "删除书签文件夹",
+      "modal_body_description": "删除此书签文件夹及其内容",
+      "modal_body_alert": "已删除的文件夹及其内容无法恢复",
+      "modal_footer_button": "删除文件夹"
+    },
+    "input_placeholder": "输入文件夹名称",
+    "new_folder": "新建文件夹",
+    "delete": "删除文件夹",
+    "drop_item_here": "将项目拖放到此处",
+    "cancel_bookmark": "取消收藏此页面",
+    "move_to_root": "移动到根部",
+    "root": "root (default)"
+  },
   "questionnaire": {
   "questionnaire": {
     "give_us_feedback": "向我们提供反馈以进行改进",
     "give_us_feedback": "向我们提供反馈以进行改进",
     "thank_you_for_answering": "谢谢你的回答",
     "thank_you_for_answering": "谢谢你的回答",

+ 46 - 0
apps/app/src/client/util/bookmark-utils.ts

@@ -0,0 +1,46 @@
+import { IRevision, Ref } from '@growi/core';
+
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+
+import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
+
+// Check if bookmark folder item has children
+export const hasChildren = (item: BookmarkFolderItems | BookmarkFolderItems[]): boolean => {
+  if (item === null) {
+    return false;
+  }
+  if (Array.isArray(item)) {
+    return item.length > 0;
+  }
+  return item.children && item.children.length > 0;
+};
+
+// Add new folder helper
+export const addNewFolder = async(name: string, parent: string | null): Promise<void> => {
+  await apiv3Post('/bookmark-folder', { name, parent });
+};
+
+// Put bookmark to a folder
+export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
+  await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId, folderId });
+};
+
+// Delete bookmark folder
+export const deleteBookmarkFolder = async(bookmarkFolderId: string): Promise<void> => {
+  await apiv3Delete(`/bookmark-folder/${bookmarkFolderId}`);
+};
+
+// Rename page from bookmark item control
+export const renamePage = async(pageId: string, revisionId: Ref<IRevision>, newPagePath: string): Promise<void> => {
+  await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
+};
+
+// Update bookmark by isBookmarked status
+export const toggleBookmark = async(pageId: string, status: boolean): Promise<void> => {
+  await apiv3Put('/bookmark-folder/update-bookmark', { pageId, status });
+};
+
+// Update Bookmark folder
+export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null): Promise<void> => {
+  await apiv3Put('/bookmark-folder', { bookmarkFolderId, name, parent });
+};

+ 32 - 0
apps/app/src/client/util/input-validator.ts

@@ -0,0 +1,32 @@
+export const AlertType = {
+  WARNING: 'warning',
+  ERROR: 'error',
+} as const;
+
+export type AlertType = typeof AlertType[keyof typeof AlertType];
+
+export const ValidationTarget = {
+  FOLDER: 'folder_name',
+  PAGE: 'page_name',
+  DEFAULT: 'field',
+};
+
+export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+
+export type AlertInfo = {
+  type?: AlertType
+  message?: string,
+  target?: string
+}
+
+export const inputValidator = async(title: string | null, target?: string): Promise<AlertInfo | null> => {
+  const validationTarget = target || ValidationTarget.DEFAULT;
+  if (title == null || title === '' || title.trim() === '') {
+    return {
+      type: AlertType.WARNING,
+      message: 'form_validation.field_required',
+      target: validationTarget,
+    };
+  }
+  return null;
+};

+ 25 - 32
apps/app/src/components/BookmarkButtons.tsx

@@ -1,29 +1,32 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, {
+  FC, useState, useCallback,
+} from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import {
+  UncontrolledTooltip, Popover, PopoverBody, DropdownToggle,
+} from 'reactstrap';
 
 
+import { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 
 
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
+import { BookmarkFolderMenu } from './Bookmarks/BookmarkFolderMenu';
 import UserPictureList from './User/UserPictureList';
 import UserPictureList from './User/UserPictureList';
 
 
 import styles from './BookmarkButtons.module.scss';
 import styles from './BookmarkButtons.module.scss';
 
 
 interface Props {
 interface Props {
-  bookmarkCount?: number
-  isBookmarked?: boolean
   bookmarkedUsers?: IUser[]
   bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
   hideTotalNumber?: boolean
-  onBookMarkClicked: ()=>void;
+  bookmarkInfo? : IBookmarkInfo
 }
 }
 
 
-const BookmarkButtons: FC<Props> = (props: Props) => {
+export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-
   const {
   const {
-    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+    bookmarkedUsers, hideTotalNumber, bookmarkInfo,
   } = props;
   } = props;
 
 
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -34,33 +37,25 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
     setIsPopoverOpen(!isPopoverOpen);
     setIsPopoverOpen(!isPopoverOpen);
   };
   };
 
 
-  const handleClick = async() => {
-    if (props.onBookMarkClicked != null) {
-      props.onBookMarkClicked();
-    }
-  };
-
   const getTooltipMessage = useCallback(() => {
   const getTooltipMessage = useCallback(() => {
 
 
-    if (isBookmarked) {
-      return 'tooltip.cancel_bookmark';
+    if (isGuestUser) {
+      return 'Not available for guest';
     }
     }
     return 'tooltip.bookmark';
     return 'tooltip.bookmark';
-  }, [isBookmarked]);
+  }, [isGuestUser]);
+
 
 
   return (
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-      <button
-        type="button"
-        id="bookmark-button"
-        onClick={handleClick}
-        className={`shadow-none btn btn-bookmark border-0
-          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-      >
-        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
-      </button>
-
-      <UncontrolledTooltip data-testid="bookmark-button-tooltip" placement="top" target="bookmark-button" fade={false}>
+      <BookmarkFolderMenu >
+        <DropdownToggle id='bookmark-dropdown-btn' color="transparent" className={`shadow-none btn btn-bookmark border-0
+          ${bookmarkInfo?.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
+          <i className={`fa ${bookmarkInfo?.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+        </DropdownToggle>
+      </BookmarkFolderMenu>
+
+      <UncontrolledTooltip placement="top" data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
         {t(getTooltipMessage())}
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
       </UncontrolledTooltip>
 
 
@@ -70,9 +65,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
             type="button"
             type="button"
             id="po-total-bookmarks"
             id="po-total-bookmarks"
             className={`shadow-none btn btn-bookmark border-0
             className={`shadow-none btn btn-bookmark border-0
-              total-bookmarks ${props.isBookmarked ? 'active' : ''}`}
+              total-bookmarks ${bookmarkInfo?.isBookmarked ? 'active' : ''}`}
           >
           >
-            {bookmarkCount ?? 0}
+            {bookmarkInfo?.sumOfBookmarks ?? 0}
           </button>
           </button>
           { bookmarkedUsers != null && (
           { bookmarkedUsers != null && (
             <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
             <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
@@ -88,5 +83,3 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
     </div>
     </div>
   );
   );
 };
 };
-
-export default BookmarkButtons;

+ 297 - 0
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -0,0 +1,297 @@
+import {
+  FC, useCallback, useState,
+} from 'react';
+
+import { DropdownToggle } from 'reactstrap';
+
+import {
+  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+} from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import { FolderIcon } from '~/components/Icons/FolderIcon';
+import { TriangleIcon } from '~/components/Icons/TriangleIcon';
+import {
+  BookmarkFolderItems, DragItemDataType, DragItemType, DRAG_ITEM_TYPE,
+} from '~/interfaces/bookmark-info';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
+import { useBookmarkFolderDeleteModal } from '~/stores/modal';
+
+import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
+import { BookmarkFolderNameInput } from './BookmarkFolderNameInput';
+import { BookmarkItem } from './BookmarkItem';
+import { DragAndDropWrapper } from './DragAndDropWrapper';
+
+type BookmarkFolderItemProps = {
+  bookmarkFolder: BookmarkFolderItems
+  isOpen?: boolean
+  level: number
+  root: string
+  isUserHomePage?: boolean
+  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  bookmarkFolderTreeMutation: () => void
+}
+
+export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+  const BASE_FOLDER_PADDING = 15;
+  const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const {
+    bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
+    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+  } = props;
+
+  const {
+    name, _id: folderId, children, parent, bookmarks,
+  } = bookmarkFolder;
+
+  const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+  const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+
+  const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
+
+  const childrenExists = hasChildren(children);
+
+  const paddingLeft = BASE_FOLDER_PADDING * level;
+
+  const loadChildFolder = useCallback(async() => {
+    setIsOpen(!isOpen);
+    setTargetFolder(folderId);
+  }, [folderId, isOpen]);
+
+  // Rename for bookmark folder handler
+  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+    try {
+      await updateBookmarkFolder(folderId, folderName, parent);
+      bookmarkFolderTreeMutation();
+      setIsRenameAction(false);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolderTreeMutation, folderId, parent]);
+
+  // Create new folder / subfolder handler
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await addNewFolder(folderName, targetFolder);
+      setIsOpen(true);
+      setIsCreateAction(false);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolderTreeMutation, targetFolder]);
+
+  const onClickPlusButton = useCallback(async(e) => {
+    e.stopPropagation();
+    if (!isOpen && childrenExists) {
+      setIsOpen(true);
+    }
+    setIsCreateAction(true);
+  }, [childrenExists, isOpen]);
+
+  const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
+    if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
+      try {
+        if (item.bookmarkFolder != null) {
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id);
+          bookmarkFolderTreeMutation();
+        }
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+    else {
+      try {
+        if (item != null) {
+          await addBookmarkToFolder(item._id, bookmarkFolder._id);
+          bookmarkFolderTreeMutation();
+        }
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+  };
+
+  const isDropable = (item: DragItemDataType, type: string | null| symbol): boolean => {
+    if (type === DRAG_ITEM_TYPE.FOLDER) {
+      if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
+        return false;
+      }
+
+      // Maximum folder hierarchy of 2 levels
+      // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
+      // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
+      if (item.bookmarkFolder.children.length !== 0 || bookmarkFolder.parent != null) {
+        return false;
+      }
+
+      return item.root !== root || item.level >= level;
+    }
+
+    if (item.parentFolder != null && item.parentFolder._id === bookmarkFolder._id) {
+      return false;
+    }
+    return true;
+  };
+
+  const renderChildFolder = () => {
+    return isOpen && children?.map((childFolder) => {
+      return (
+        <div key={childFolder._id} className="grw-foldertree-item-children">
+          <BookmarkFolderItem
+            key={childFolder._id}
+            bookmarkFolder={childFolder}
+            level={level + 1}
+            root={root}
+            isUserHomePage={isUserHomePage}
+            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+          />
+        </div>
+      );
+    });
+  };
+
+  const renderBookmarkItem = () => {
+    return isOpen && bookmarks?.map((bookmark) => {
+      return (
+        <BookmarkItem
+          key={bookmark._id}
+          bookmarkedPage={bookmark.page}
+          level={level + 1}
+          parentFolder={bookmarkFolder}
+          canMoveToRoot={true}
+          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+        />
+      );
+    });
+  };
+
+  const onClickRenameHandler = useCallback(() => {
+    setIsRenameAction(true);
+  }, []);
+
+  const onClickDeleteHandler = useCallback(() => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+      if (typeof folderId !== 'string') {
+        return;
+      }
+      bookmarkFolderTreeMutation();
+    };
+
+    if (bookmarkFolder == null) {
+      return;
+    }
+    openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
+  }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
+
+  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
+    try {
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolder._id, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+
+  return (
+    <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
+      <DragAndDropWrapper
+        key={folderId}
+        type={acceptedTypes}
+        item={props}
+        useDragMode={true}
+        useDropMode={true}
+        onDropItem={itemDropHandler}
+        isDropable={isDropable}
+      >
+        <li
+          className={'list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center'}
+          onClick={loadChildFolder}
+          style={{ paddingLeft }}
+        >
+          <div className="grw-triangle-container d-flex justify-content-center">
+            {childrenExists && (
+              <button
+                type="button"
+                className={`grw-foldertree-triangle-btn btn ${isOpen ? 'grw-foldertree-open' : ''}`}
+                onClick={loadChildFolder}
+              >
+                <div className="d-flex justify-content-center">
+                  <TriangleIcon />
+                </div>
+              </button>
+            )}
+          </div>
+          {
+            <div>
+              <FolderIcon isOpen={isOpen} />
+            </div>
+          }
+          {isRenameAction ? (
+            <BookmarkFolderNameInput
+              onClickOutside={() => setIsRenameAction(false)}
+              onPressEnter={onPressEnterHandlerForRename}
+              value={name}
+            />
+          ) : (
+            <>
+              <div className='grw-foldertree-title-anchor pl-2' >
+                <p className={'text-truncate m-auto '}>{name}</p>
+              </div>
+            </>
+          )}
+          <div className="grw-foldertree-control d-flex">
+            <BookmarkFolderItemControl
+              onClickRename={onClickRenameHandler}
+              onClickDelete={onClickDeleteHandler}
+              onClickMoveToRoot={bookmarkFolder.parent != null
+                ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                : undefined
+              }
+            >
+              <div onClick={e => e.stopPropagation()}>
+                <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+                  <i className="icon-options fa fa-rotate-90 p-1"></i>
+                </DropdownToggle>
+              </div>
+            </BookmarkFolderItemControl>
+            {/* Maximum folder hierarchy of 2 levels */}
+            {!(bookmarkFolder.parent != null) && (
+              <button
+                id='create-bookmark-folder-button'
+                type="button"
+                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                onClick={onClickPlusButton}
+              >
+                <i className="icon-plus d-block p-0" />
+              </button>
+            )}
+          </div>
+        </li>
+      </DragAndDropWrapper>
+      {isCreateAction && (
+        <div className="flex-fill">
+          <BookmarkFolderNameInput
+            onClickOutside={() => setIsCreateAction(false)}
+            onPressEnter={onPressEnterHandlerForCreate}
+          />
+        </div>
+      )}
+      {
+        renderChildFolder()
+      }
+      {
+        renderBookmarkItem()
+      }
+    </div>
+  );
+};

+ 63 - 0
apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+} from 'reactstrap';
+
+export const BookmarkFolderItemControl: React.FC<{
+  children?: React.ReactNode
+  onClickMoveToRoot?: () => Promise<void>
+  onClickRename: () => void
+  onClickDelete: () => void
+}> = ({
+  children,
+  onClickMoveToRoot,
+  onClickRename,
+  onClickDelete,
+}): JSX.Element => {
+  const { t } = useTranslation();
+  const [isOpen, setIsOpen] = useState(false);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+          <i className="icon-options"></i>
+        </DropdownToggle>
+      ) }
+      <DropdownMenu
+        modifiers={{ preventOverflow: { boundariesElement: 'viewport' } }}
+        container="body"
+        style={{ zIndex: 1055 }} /* make it larger than $zindex-modal of bootstrap */
+      >
+        {onClickMoveToRoot && (
+          <DropdownItem
+            onClick={onClickMoveToRoot}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+            {t('bookmark_folder.move_to_root')}
+          </DropdownItem>
+        )}
+        <DropdownItem
+          onClick={onClickRename}
+          className="grw-page-control-dropdown-item"
+        >
+          <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
+          {t('Rename')}
+        </DropdownItem>
+
+        <DropdownItem divider/>
+
+        <DropdownItem
+          className='pt-2 grw-page-control-dropdown-item text-danger'
+          onClick={onClickDelete}
+        >
+          <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
+          {t('Delete')}
+        </DropdownItem>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};

+ 200 - 0
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -0,0 +1,200 @@
+import React, { useCallback, useMemo, useState } from 'react';
+
+import { getCustomModifiers } from '@growi/ui/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
+
+import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+
+import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
+
+export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ children }): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [selectedItem, setSelectedItem] = useState<string | null>(null);
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
+
+  const isBookmarked = bookmarkInfo?.isBookmarked ?? false;
+
+  const isBookmarkFolderExists = useMemo((): boolean => {
+    return bookmarkFolders != null && bookmarkFolders.length > 0;
+  }, [bookmarkFolders]);
+
+  const toggleBookmarkHandler = useCallback(async() => {
+    try {
+      if (currentPage != null) {
+        await toggleBookmark(currentPage._id, isBookmarked);
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [currentPage, isBookmarked]);
+
+  const onUnbookmarkHandler = useCallback(async() => {
+    await toggleBookmarkHandler();
+    setIsOpen(false);
+    setSelectedItem(null);
+    mutateUserBookmarks();
+    mutateBookmarkInfo();
+    mutateBookmarkFolders();
+    mutatePageInfo();
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutatePageInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+
+  const toggleHandler = useCallback(async() => {
+    setIsOpen(!isOpen);
+
+    if (isOpen && bookmarkFolders != null) {
+      bookmarkFolders.forEach((bookmarkFolder) => {
+        bookmarkFolder.bookmarks.forEach((bookmark) => {
+          if (bookmark.page._id === currentPage?._id) {
+            setSelectedItem(bookmarkFolder._id);
+          }
+        });
+      });
+    }
+
+    if (selectedItem == null) {
+      setSelectedItem('root');
+    }
+
+    if (!isOpen && !isBookmarked) {
+      try {
+        await toggleBookmarkHandler();
+        mutateUserBookmarks();
+        mutateBookmarkInfo();
+        mutatePageInfo();
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+  },
+  [isOpen, bookmarkFolders, selectedItem, isBookmarked, currentPage?._id, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
+
+  const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
+    e.stopPropagation();
+
+    setSelectedItem(itemId);
+
+    try {
+      if (isBookmarked) {
+        await toggleBookmarkHandler();
+      }
+      if (currentPage != null) {
+        await addBookmarkToFolder(currentPage._id, itemId === 'root' ? null : itemId);
+      }
+      mutateUserBookmarks();
+      mutateBookmarkFolders();
+      mutateBookmarkInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateBookmarkFolders, isBookmarked, currentPage, mutateBookmarkInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+
+  console.log(selectedItem);
+  const renderBookmarkMenuItem = () => {
+    return (
+      <>
+        <DropdownItem
+          toggle={false}
+          onClick={onUnbookmarkHandler}
+          className={'grw-bookmark-folder-menu-item text-danger'}
+        >
+          <i className="fa fa-bookmark"></i>{' '}
+          <span className="mx-2 ">
+            {t('bookmark_folder.cancel_bookmark')}
+          </span>
+        </DropdownItem>
+
+        {isBookmarkFolderExists && (
+          <>
+            <DropdownItem divider />
+            <div key='root'>
+              <div
+                className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
+                tabIndex={0}
+                role="menuitem"
+                onClick={e => onMenuItemClickHandler(e, 'root')}
+              >
+                <BookmarkFolderMenuItem
+                  itemId='root'
+                  itemName={t('bookmark_folder.root')}
+                  isSelected={selectedItem === 'root'}
+                />
+              </div>
+            </div>
+            {bookmarkFolders?.map(folder => (
+              <>
+                <div key={folder._id}>
+                  <div
+                    className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
+                    style={{ paddingLeft: '40px' }}
+                    tabIndex={0}
+                    role="menuitem"
+                    onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  >
+                    <BookmarkFolderMenuItem
+                      itemId={folder._id}
+                      itemName={folder.name}
+                      isSelected={selectedItem === folder._id}
+                    />
+                  </div>
+                </div>
+                <>
+                  {folder.children?.map(child => (
+                    <div key={child._id}>
+                      <div
+                        className='dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0'
+                        style={{ paddingLeft: '60px' }}
+                        tabIndex={0}
+                        role="menuitem"
+                        onClick={e => onMenuItemClickHandler(e, child._id)}>
+                        <BookmarkFolderMenuItem
+                          itemId={child._id}
+                          itemName={child.name}
+                          isSelected={selectedItem === child._id}
+                        />
+                      </div>
+                    </div>
+                  ))}
+                </>
+              </>
+            ))}
+          </>
+        )}
+      </>
+    );
+  };
+
+  return (
+    <UncontrolledDropdown
+      isOpen={isOpen}
+      onToggle={toggleHandler}
+      direction={isBookmarkFolderExists ? 'up' : 'down'}
+      className='grw-bookmark-folder-dropdown'
+    >
+      {children}
+      <DropdownMenu
+        right
+        persist
+        positionFixed
+        className='grw-bookmark-folder-menu'
+        modifiers={getCustomModifiers(true)}
+      >
+        { renderBookmarkMenuItem() }
+      </DropdownMenu>
+    </UncontrolledDropdown>
+  );
+};

+ 27 - 0
apps/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -0,0 +1,27 @@
+import React from 'react';
+
+export const BookmarkFolderMenuItem: React.FC<{
+  itemId: string
+  itemName: string
+  isSelected: boolean
+}> = ({
+  itemId,
+  itemName,
+  isSelected,
+}) => {
+  return (
+    <div className='d-flex justify-content-start grw-bookmark-folder-menu-item-title'>
+      <input
+        type="radio"
+        checked={isSelected}
+        name="bookmark-folder-menu-item"
+        id={`bookmark-folder-menu-item-${itemId}`}
+        onChange={e => e.stopPropagation()}
+        onClick={e => e.stopPropagation()}
+      />
+      <label htmlFor={`bookmark-folder-menu-item-${itemId}`} className='p-2 m-0'>
+        {itemName}
+      </label>
+    </div>
+  );
+};

+ 30 - 0
apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -0,0 +1,30 @@
+import { useTranslation } from 'next-i18next';
+
+import { inputValidator, ValidationTarget } from '~/client/util/input-validator';
+import ClosableTextInput from '~/components/Common/ClosableTextInput';
+
+
+type Props = {
+  onClickOutside: () => void
+  onPressEnter: (folderName: string) => void
+  value?: string
+}
+
+export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
+  const {
+    onClickOutside, onPressEnter, value,
+  } = props;
+  const { t } = useTranslation();
+
+  return (
+    <div className="flex-fill folder-name-input">
+      <ClosableTextInput
+        value={ value }
+        placeholder={t('bookmark_folder.input_placeholder')}
+        onClickOutside={onClickOutside}
+        onPressEnter={onPressEnter}
+        validationTarget={ValidationTarget.FOLDER}
+      />
+    </div>
+  );
+};

+ 85 - 0
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -0,0 +1,85 @@
+$grw-foldertree-item-padding-left: 15px;
+$grw-bookmark-item-padding-left: 35px;
+
+.grw-folder-tree-container :global {
+  .grw-foldertree-item-container, .grw-drop-item-area {
+    & .grw-accept-drop-item {
+      border-style: dashed !important;
+      border-width: 0.15rem !important;
+    }
+  }
+
+  .grw-drop-item-area {
+    padding: 1rem;
+    & .grw-accept-drop-item {
+      padding: 0.7rem;
+    }
+  }
+  .grw-drag-drop-container > .grw-drop-item-area {
+    margin: 1rem;
+    border-style: dashed !important;
+    border-width: 0.15rem !important;
+  }
+}
+
+.grw-foldertree :global {
+
+  .btn-page-item-control .icon-plus::before {
+    font-size: 18px;
+  }
+
+  .list-group-item {
+    .grw-visible-on-hover {
+      display: none;
+    }
+
+    &:hover {
+      .grw-visible-on-hover {
+        display: block;
+      }
+    }
+
+    .grw-foldertree-triangle-btn {
+      background-color: transparent;
+      transition: all 0.2s ease-out;
+      transform: rotate(0deg);
+
+      &.grw-foldertree-open {
+        transform: rotate(90deg);
+      }
+    }
+
+    .grw-foldertree-title-anchor {
+      width: 100%;
+      overflow: hidden;
+      text-decoration: none;
+    }
+  }
+
+  .grw-foldertree-item-container {
+    .grw-triangle-container {
+      min-width: 35px;
+      height: 40px;
+    }
+
+    .grw-bookmark-item-list{
+      min-width: 30px;
+      height: 35px;
+
+      .picture {
+        width: 16px;
+        height: 16px;
+        vertical-align: text-bottom;
+
+        &.picture-md {
+          width: 20px;
+          height: 20px;
+        }
+      }
+
+      .grw-foldertree-control{
+        margin-left: auto;
+      }
+    }
+  }
+}

+ 134 - 0
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -0,0 +1,134 @@
+
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess } from '~/client/util/toastr';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useSWRxCurrentUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
+import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+import { BookmarkFolderItem } from './BookmarkFolderItem';
+import { BookmarkItem } from './BookmarkItem';
+
+import styles from './BookmarkFolderTree.module.scss';
+
+// type DragItemDataType = {
+//   bookmarkFolder: BookmarkFolderItems
+//   level: number
+//   parentFolder: BookmarkFolderItems | null
+//  } & IPageHasId
+
+export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUserHomePage }) => {
+  // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const { t } = useTranslation();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  const bookmarkFolderTreeMutation = useCallback(() => {
+    mutateUserBookmarks();
+    mutateBookmarkInfo();
+    mutateBookmarkFolders();
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+
+  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+    const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') return;
+
+      toastSuccess(isCompletely ? t('deleted_pages_completely', { pathOrPathsToDelete }) : t('deleted_pages', { pathOrPathsToDelete }));
+
+      bookmarkFolderTreeMutation();
+    };
+    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+  }, [openDeleteModal, t, bookmarkFolderTreeMutation]);
+
+  /* TODO: update in bookmarks folder v2. */
+  // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
+  //   if (dragType === DRAG_ITEM_TYPE.FOLDER) {
+  //     try {
+  //       await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, null);
+  //       await mutateBookmarkData();
+  //       toastSuccess(t('toaster.update_successed', { target: t('bookmark_folder.bookmark_folder'), ns: 'commons' }));
+  //     }
+  //     catch (err) {
+  //       toastError(err);
+  //     }
+  //   }
+  //   else {
+  //     try {
+  //       await addBookmarkToFolder(item._id, null);
+  //       await mutateUserBookmarks();
+  //       toastSuccess(t('toaster.add_succeeded', { target: t('bookmark_folder.bookmark'), ns: 'commons' }));
+  //     }
+  //     catch (err) {
+  //       toastError(err);
+  //     }
+  //   }
+
+  // };
+  // const isDroppable = (item: DragItemDataType, dragType: string | null | symbol) => {
+  //   if (dragType === DRAG_ITEM_TYPE.FOLDER) {
+  //     const isRootFolder = item.level === 0;
+  //     return !isRootFolder;
+  //   }
+  //   const isRootBookmark = item.parentFolder == null;
+  //   return !isRootBookmark;
+
+  // };
+
+  return (
+    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}` } >
+      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
+        {bookmarkFolders?.map((bookmarkFolder) => {
+          return (
+            <BookmarkFolderItem
+              key={bookmarkFolder._id}
+              bookmarkFolder={bookmarkFolder}
+              isOpen={false}
+              level={0}
+              root={bookmarkFolder._id}
+              isUserHomePage={isUserHomePage}
+              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          );
+        })}
+        {userBookmarks?.map(userBookmark => (
+          <div key={userBookmark._id} className="grw-foldertree-item-container grw-root-bookmarks">
+            <BookmarkItem
+              key={userBookmark._id}
+              bookmarkedPage={userBookmark}
+              level={0}
+              parentFolder={null}
+              canMoveToRoot={false}
+              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          </div>
+        ))}
+      </ul>
+      {/* TODO: update in bookmarks folder v2. Also delete drop_item_here in translation.json, if don't need it. */}
+      {/* {bookmarkFolderData != null && bookmarkFolderData.length > 0 && (
+        <DragAndDropWrapper
+          useDropMode={true}
+          type={acceptedTypes}
+          onDropItem={itemDropHandler}
+          isDropable={isDroppable}
+        >
+          <div className="grw-drop-item-area">
+            <div className="d-flex flex-column align-items-center">
+              {t('bookmark_folder.drop_item_here')}
+            </div>
+          </div>
+        </DragAndDropWrapper>
+      )} */}
+    </div>
+  );
+};

+ 162 - 0
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -0,0 +1,162 @@
+import React, { useCallback, useState } from 'react';
+
+import nodePath from 'path';
+
+import { DevidedPagePath, pathUtils } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
+
+import { unbookmark } from '~/client/services/page-operation';
+import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
+import { ValidationTarget } from '~/client/util/input-validator';
+import { toastError } from '~/client/util/toastr';
+import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+import ClosableTextInput from '../Common/ClosableTextInput';
+import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import { PageListItemS } from '../PageList/PageListItemS';
+
+import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
+import { DragAndDropWrapper } from './DragAndDropWrapper';
+
+type Props = {
+  bookmarkedPage: IPageHasId,
+  level: number,
+  parentFolder: BookmarkFolderItems | null,
+  canMoveToRoot: boolean,
+  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  bookmarkFolderTreeMutation: () => void
+}
+
+export const BookmarkItem = (props: Props): JSX.Element => {
+  const BASE_FOLDER_PADDING = 15;
+  const BASE_BOOKMARK_PADDING = 20;
+
+  const { t } = useTranslation();
+
+  const {
+    bookmarkedPage, onClickDeleteBookmarkHandler,
+    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+  } = props;
+
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+
+  const { data: fetchedPageInfo } = useSWRxPageInfo(bookmarkedPage._id);
+
+  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
+  const { latter: pageTitle, former: formerPagePath } = dPagePath;
+  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
+  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level + 1));
+  const dragItem: Partial<DragItemDataType> = {
+    ...bookmarkedPage, parentFolder,
+  };
+
+  const onClickMoveToRootHandler = useCallback(async() => {
+    try {
+      await addBookmarkToFolder(bookmarkedPage._id, null);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
+
+  const bookmarkMenuItemClickHandler = useCallback(async() => {
+    await unbookmark(bookmarkedPage._id);
+    bookmarkFolderTreeMutation();
+  }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
+
+  const renameMenuItemClickHandler = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const pressEnterForRenameHandler = useCallback(async(inputText: string) => {
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const newPagePath = nodePath.resolve(parentPath, inputText);
+    if (newPagePath === bookmarkedPage.path) {
+      setRenameInputShown(false);
+      return;
+    }
+
+    try {
+      setRenameInputShown(false);
+      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      bookmarkFolderTreeMutation();
+    }
+    catch (err) {
+      setRenameInputShown(true);
+      toastError(err);
+    }
+  }, [bookmarkedPage, bookmarkFolderTreeMutation]);
+
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+      throw Error('_id and path must not be null.');
+    }
+
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: bookmarkedPage._id,
+        revision: bookmarkedPage.revision as string,
+        path: bookmarkedPage.path,
+      },
+      meta: pageInfo,
+    };
+
+    onClickDeleteBookmarkHandler(pageToDelete);
+  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
+
+  return (
+    <DragAndDropWrapper
+      item={dragItem}
+      type={[DRAG_ITEM_TYPE.BOOKMARK]}
+      useDragMode={true}
+    >
+      <li
+        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 mr-auto d-flex align-items-center"
+        key={bookmarkedPage._id}
+        id={bookmarkItemId}
+        style={{ paddingLeft }}
+      >
+        { isRenameInputShown ? (
+          <ClosableTextInput
+            value={nodePath.basename(bookmarkedPage.path ?? '')}
+            placeholder={t('Input page name')}
+            onClickOutside={() => { setRenameInputShown(false) }}
+            onPressEnter={pressEnterForRenameHandler}
+            validationTarget={ValidationTarget.PAGE}
+          />
+        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+        <div className='grw-foldertree-control'>
+          <PageItemControl
+            pageId={bookmarkedPage._id}
+            isEnableActions
+            pageInfo={fetchedPageInfo}
+            forceHideMenuItems={[MenuItemType.DUPLICATE]}
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            additionalMenuItemOnTopRenderer={canMoveToRoot
+              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
+              : undefined}
+          >
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+              <i className="icon-options fa fa-rotate-90 p-1"></i>
+            </DropdownToggle>
+          </PageItemControl>
+        </div>
+        <UncontrolledTooltip
+          modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
+          autohide={false}
+          placement="right"
+          target={bookmarkItemId}
+          fade={false}
+        >
+          {formerPagePath !== null ? `${formerPagePath}/` : '/'}
+        </UncontrolledTooltip>
+      </li>
+    </DragAndDropWrapper>
+  );
+};

+ 23 - 0
apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { DropdownItem } from 'reactstrap';
+
+export const BookmarkMoveToRootBtn: React.FC<{
+  pageId: string
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+}> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
+  const { t } = useTranslation();
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMoveToRootHandler(pageId)}
+      className="grw-page-control-dropdown-item"
+      data-testid="add-remove-bookmark-btn"
+    >
+      <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+      {t('bookmark_folder.move_to_root')}
+    </DropdownItem>
+  );
+});
+BookmarkMoveToRootBtn.displayName = 'BookmarkMoveToRootBtn';

+ 73 - 0
apps/app/src/components/Bookmarks/DragAndDropWrapper.tsx

@@ -0,0 +1,73 @@
+import React, { ReactNode } from 'react';
+
+import { useDrag, useDrop } from 'react-dnd';
+
+import { DragItemDataType } from '~/interfaces/bookmark-info';
+
+type DragAndDropWrapperProps = {
+  item?: Partial<DragItemDataType>
+  type: string[]
+  children: ReactNode
+  useDragMode?: boolean
+  useDropMode?: boolean
+  onDropItem?:(item: DragItemDataType, type: string | null | symbol) => Promise<void>
+  isDropable?:(item: Partial<DragItemDataType>, type: string | null | symbol) => boolean
+}
+
+export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element => {
+  const {
+    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+  } = props;
+
+
+  const acceptedTypes = type;
+  const sourcetype: string | symbol = type[0];
+
+
+  const [, dragRef] = useDrag({
+    type: sourcetype,
+    item,
+    collect: monitor => ({
+      isDragging: monitor.isDragging(),
+      canDrag: monitor.canDrag(),
+    }),
+  });
+
+  const [{ isOver }, dropRef] = useDrop(() => ({
+    accept: acceptedTypes,
+    drop: (item: DragItemDataType, monitor) => {
+      const itemType: string | null | symbol = monitor.getItemType();
+      if (onDropItem != null) {
+        onDropItem(item, itemType);
+      }
+    },
+    canDrop: (item, monitor) => {
+      const itemType: string | null | symbol = monitor.getItemType();
+      if (isDropable != null) {
+        return isDropable(item, itemType);
+      }
+      return false;
+    },
+    collect: monitor => ({
+      isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
+    }),
+  }));
+
+
+  const getRef = (c: HTMLDivElement | null) => {
+    if (useDragMode && useDropMode) {
+      return [dragRef(c), dropRef(c)];
+    } if (useDragMode) {
+      return dragRef(c);
+    } if (useDropMode) {
+      return dropRef(c);
+    }
+    return null;
+  };
+
+  return (
+    <div ref={c => getRef(c)} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}` }>
+      {children}
+    </div>
+  );
+};

+ 9 - 16
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -4,40 +4,33 @@ import React, {
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-export const AlertType = {
-  WARNING: 'warning',
-  ERROR: 'error',
-} as const;
-
-export type AlertType = typeof AlertType[keyof typeof AlertType];
-
-export type AlertInfo = {
-  type?: AlertType
-  message?: string
-}
+import { AlertInfo, AlertType, inputValidator } from '~/client/util/input-validator';
 
 
 type ClosableTextInputProps = {
 type ClosableTextInputProps = {
   value?: string
   value?: string
   placeholder?: string
   placeholder?: string
-  inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
+  validationTarget?: string,
   onPressEnter?(inputText: string | null): void
   onPressEnter?(inputText: string | null): void
   onClickOutside?(): void
   onClickOutside?(): void
 }
 }
 
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const inputRef = useRef<HTMLInputElement>(null);
+  const { validationTarget } = props;
 
 
+  const inputRef = useRef<HTMLInputElement>(null);
   const [inputText, setInputText] = useState(props.value);
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
   const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
   const [isComposing, setComposing] = useState(false);
   const [isComposing, setComposing] = useState(false);
 
 
+
   const createValidation = async(inputText: string) => {
   const createValidation = async(inputText: string) => {
-    if (props.inputValidator != null) {
-      const alertInfo = await props.inputValidator(inputText);
-      setAlertInfo(alertInfo);
+    const alertInfo = await inputValidator(inputText, validationTarget);
+    if (alertInfo && alertInfo.message != null && alertInfo.target != null) {
+      alertInfo.message = t(alertInfo.message, { target: t(alertInfo.target) });
     }
     }
+    setAlertInfo(alertInfo);
   };
   };
 
 
   const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
   const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {

+ 3 - 1
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,4 +1,6 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
 
 
 import { getCustomModifiers } from '@growi/ui/dist/utils';
 import { getCustomModifiers } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';

+ 70 - 0
apps/app/src/components/DeleteBookmarkFolderModal.tsx

@@ -0,0 +1,70 @@
+
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody, ModalFooter, ModalHeader,
+} from 'reactstrap';
+
+import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import { FolderIcon } from '~/components/Icons/FolderIcon';
+import { useBookmarkFolderDeleteModal } from '~/stores/modal';
+
+
+const DeleteBookmarkFolderModal: FC = () => {
+  const { t } = useTranslation();
+  const { data: deleteBookmarkFolderModalData, close: closeBookmarkFolderDeleteModal } = useBookmarkFolderDeleteModal();
+  const isOpened = deleteBookmarkFolderModalData?.isOpened ?? false;
+
+  async function deleteBookmark() {
+    if (deleteBookmarkFolderModalData == null || deleteBookmarkFolderModalData.bookmarkFolder == null) {
+      return;
+    }
+    if (deleteBookmarkFolderModalData.bookmarkFolder != null) {
+      try {
+        await deleteBookmarkFolder(deleteBookmarkFolderModalData.bookmarkFolder._id);
+        const onDeleted = deleteBookmarkFolderModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(deleteBookmarkFolderModalData.bookmarkFolder._id);
+        }
+        closeBookmarkFolderDeleteModal();
+      }
+      catch (err) {
+        toastError(err);
+      }
+    }
+  }
+  async function onClickDeleteButton() {
+    await deleteBookmark();
+  }
+
+  return (
+    <Modal size="md" isOpen={isOpened} toggle={closeBookmarkFolderDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeBookmarkFolderDeleteModal} className="bg-danger text-light">
+        <i className="icon-fw icon-trash"></i>
+        {t('bookmark_folder.delete_modal.modal_header_label')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group pb-1">
+          <label>{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
+          <FolderIcon isOpen={false}/> {deleteBookmarkFolderModalData?.bookmarkFolder?.name}
+        </div>
+        {t('bookmark_folder.delete_modal.modal_body_alert')}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={onClickDeleteButton}
+        >
+          <i className="mr-1 icon-trash" aria-hidden="true"></i>
+          {t('bookmark_folder.delete_modal.modal_footer_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export { DeleteBookmarkFolderModal };

+ 17 - 0
apps/app/src/components/Icons/CompressIcon.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+export const CompressIcon = ():JSX.Element => {
+  return (
+    <svg xmlns="http://www.w3.org/2000/svg"
+      width="18"
+      height="18"
+      viewBox="0 0 45 45"
+    >
+      <path
+        fill="currentColor"
+        d="M22.45 44v-7.9l-3.85 3.8-2.1-2.1 7.45-7.4 7.35 7.4-2.1
+            2.1-3.75-3.8V44ZM8.05 27.5v-3H40v3Zm0-6.05v-3H40v3Zm15.9-5.85-7.4-7.4 2.1-2.1
+            3.75 3.8V2h3v7.9l3.85-3.8 2.1 2.1Z"/>
+    </svg>
+  );
+};

+ 18 - 0
apps/app/src/components/Icons/ExpandIcon.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+export const ExpandIcon = (): JSX.Element => {
+  return (
+    <svg xmlns="http://www.w3.org/2000/svg"
+      width="18"
+      height="18"
+      viewBox="0 0 45 45"
+    >
+      <path
+        fill="currentColor"
+        d="M8.1 44v-3h31.8v3Zm16-4.5-7.6-7.6 2.15-2.15
+            3.95 3.95V14.3l-3.95 3.95-2.15-2.15 7.6-7.6 7.6 7.6-2.15
+            2.15-3.95-3.95v19.4l3.95-3.95 2.15 2.15ZM8.1 7V4h31.8v3Z"
+      />
+    </svg>
+  );
+};

+ 37 - 0
apps/app/src/components/Icons/FolderIcon.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+
+type Props = {
+  isOpen: boolean
+}
+export const FolderIcon = (props: Props): JSX.Element => {
+  const { isOpen } = props;
+
+  return (
+    <>
+      {!isOpen ? (
+        <svg
+          width ="20"
+          height ="20"
+          viewBox="0 0 24 24"
+        >
+          <path fill="currentColor"
+            d="M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z" />
+        </svg>
+      ) : (
+        <svg
+          width="20"
+          height="20"
+          viewBox="0 0 24 24"
+        >
+          <path
+            fill="currentColor"
+            d="M6.1,10L4,18V8H21A2,2 0 0,0 19,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,
+            20H19C19.9,20 20.7,19.4 20.9,18.5L23.2,10H6.1M19,18H6L7.6,12H20.6L19,18Z"
+          />
+        </svg>
+      )
+      }
+    </>
+  );
+
+};

+ 16 - 0
apps/app/src/components/Icons/FolderPlusIcon.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+
+export const FolderPlusIcon = (): JSX.Element => (
+  <svg
+    width="18"
+    height="18"
+    viewBox="0 0 24 24"
+  >
+    <path
+      fill="currentColor"
+      d="M13 19C13 19.34 13.04 19.67 13.09 20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.89 4 4 4H10L12 6H20C21.1 6 22
+      6.89 22 8V13.81C21.39 13.46 20.72 13.22 20 13.09V8H4V18H13.09C13.04 18.33 13 18.66 13 19M20 18V15H18V18H15V20H18V23H20V20H23V18H20Z"
+    />
+
+  </svg>
+);

+ 1 - 3
apps/app/src/components/Icons/TriangleIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
-const TriangleIcon = (): JSX.Element => (
+export const TriangleIcon = (): JSX.Element => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     width="12"
     width="12"
@@ -13,5 +13,3 @@ const TriangleIcon = (): JSX.Element => (
     </g>
     </g>
   </svg>
   </svg>
 );
 );
-
-export default TriangleIcon;

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -22,6 +22,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 // Fab
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
 
@@ -55,6 +56,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageDeleteModal />
         <PageDeleteModal />
         <PageRenameModal />
         <PageRenameModal />
         <PageAccessoriesModal />
         <PageAccessoriesModal />
+        <DeleteBookmarkFolderModal />
       </DndProvider>
       </DndProvider>
 
 
       <PagePresentationModal />
       <PagePresentationModal />

+ 5 - 20
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
 import {
 import {
-  toggleBookmark, toggleLike, toggleSubscribe,
+  toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import {
 import {
@@ -16,7 +16,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
-import BookmarkButtons from '../BookmarkButtons';
+import { BookmarkButtons } from '../BookmarkButtons';
 import {
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
   PageItemControl,
   PageItemControl,
@@ -94,7 +94,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
-  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+  const { data: bookmarkInfo } = useSWRBookmarkInfo(pageId);
 
 
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
@@ -128,19 +128,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     mutatePageInfo();
     mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
 
-  const bookmarkClickHandler = useCallback(async() => {
-    if (isGuestUser ?? true) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo)) {
-      return;
-    }
-
-    await toggleBookmark(pageId, pageInfo.isBookmarked);
-    mutatePageInfo();
-    mutateBookmarkInfo();
-  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
-
   const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
   const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDuplicateMenuItem == null || path == null) {
     if (onClickDuplicateMenuItem == null || path == null) {
       return;
       return;
@@ -215,7 +202,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }
   }
 
 
   const {
   const {
-    sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
+    sumOfLikers, sumOfSeenUsers, isLiked,
   } = pageInfo;
   } = pageInfo;
 
 
   const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
   const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
@@ -241,10 +228,8 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       {revisionId != null && (
       {revisionId != null && (
         <BookmarkButtons
         <BookmarkButtons
           hideTotalNumber={isCompactMode}
           hideTotalNumber={isCompactMode}
-          bookmarkCount={bookmarkCount}
-          isBookmarked={isBookmarked}
           bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
           bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-          onBookMarkClicked={bookmarkClickHandler}
+          bookmarkInfo={bookmarkInfo}
         />
         />
       )}
       )}
       {revisionId != null && !isCompactMode && (
       {revisionId != null && !isCompactMode && (

+ 1 - 2
apps/app/src/components/NotFoundPage.tsx

@@ -8,7 +8,6 @@ import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
 import { PageTimeline } from './PageTimeline';
 
 
-
 type NotFoundPageProps = {
 type NotFoundPageProps = {
   path: string,
   path: string,
 }
 }
@@ -22,7 +21,7 @@ const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
     return {
     return {
       pagelist: {
       pagelist: {
         Icon: PageListIcon,
         Icon: PageListIcon,
-        Content: () => <DescendantsPageList path={path} />,
+        Content: () => <DescendantsPageList path={path}/>,
         i18n: t('page_list'),
         i18n: t('page_list'),
       },
       },
       timeLine: {
       timeLine: {

+ 8 - 0
apps/app/src/components/PageEditor.tsx

@@ -517,6 +517,14 @@ const PageEditor = React.memo((): JSX.Element => {
     }
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
 
+  // when transitioning to a different page, if the initialValue is the same,
+  // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  useEffect(() => {
+    if (currentPagePath != null) {
+      editorRef.current?.setValue(initialValue);
+    }
+  }, [currentPagePath, initialValue]);
+
   if (!isEditable) {
   if (!isEditable) {
     return <></>;
     return <></>;
   }
   }

+ 1 - 1
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -571,7 +571,7 @@ class CodeMirrorEditor extends AbstractEditor {
 
 
   changeHandler(editor, data, value) {
   changeHandler(editor, data, value) {
     if (this.props.onChange != null) {
     if (this.props.onChange != null) {
-      const isClean = data.origin == null || editor.isClean();
+      const isClean = data.origin == null || editor.isClean() || value === this.props.value;
       this.props.onChange(value, isClean);
       this.props.onChange(value, isClean);
     }
     }
 
 

+ 0 - 79
apps/app/src/components/PageList/BookmarkList.tsx

@@ -1,79 +0,0 @@
-import React, { useState, useCallback, useEffect } from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/toastr';
-import { MyBookmarkList } from '~/interfaces/bookmark-info';
-import loggerFactory from '~/utils/logger';
-
-import PaginationWrapper from '../PaginationWrapper';
-
-import { PageListItemS } from './PageListItemS';
-
-const logger = loggerFactory('growi:BookmarkList');
-
-type BookmarkListProps = {
-  userId: string
-}
-
-export const BookmarkList = (props: BookmarkListProps): JSX.Element => {
-  const { userId } = props;
-
-  const { t } = useTranslation();
-  const [pages, setPages] = useState<MyBookmarkList>([]);
-  const [activePage, setActivePage] = useState(1);
-  const [totalItemsCount, setTotalItemsCount] = useState(0);
-  const [pagingLimit, setPagingLimit] = useState(10);
-
-  const setPageNumber = (selectedPageNumber) => {
-    setActivePage(selectedPageNumber);
-  };
-
-  const getMyBookmarkList = useCallback(async() => {
-    const page = activePage;
-
-    try {
-      const res = await apiv3Get(`/bookmarks/${userId}`, { page });
-      const { paginationResult } = res.data;
-
-      setPages(paginationResult.docs);
-      setTotalItemsCount(paginationResult.totalDocs);
-      setPagingLimit(paginationResult.limit);
-    }
-    catch (error) {
-      logger.error('failed to fetch data', error);
-      toastError(error);
-    }
-  }, [activePage, userId]);
-
-  useEffect(() => {
-    getMyBookmarkList();
-  }, [getMyBookmarkList]);
-
-  return (
-    <div className="bookmarks-list-container">
-      {pages.length === 0 ? t('No bookmarks yet') : (
-        <>
-          <ul className="page-list-ul page-list-ul-flat mb-3">
-
-            {pages.map(page => (
-              <li key={`my-bookmarks:${page._id}`} className="mt-4">
-                <PageListItemS page={page.page} />
-              </li>
-            ))}
-
-          </ul>
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={setPageNumber}
-            totalItemsCount={totalItemsCount}
-            pagingLimit={pagingLimit}
-            align="center"
-            size="sm"
-          />
-        </>
-      )}
-    </div>
-  );
-};

+ 5 - 1
apps/app/src/components/PageList/PageListItemL.tsx

@@ -24,6 +24,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import {
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
@@ -88,7 +89,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
-
+  const { mutate: mutateCurrentUserBookmark } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
 
@@ -125,6 +127,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
     await bookmarkOperation(_pageId);
+    mutateCurrentUserBookmark();
+    mutateBookmarkInfo();
   };
   };
 
 
   const duplicateMenuItemClickHandler = useCallback(() => {
   const duplicateMenuItemClickHandler = useCallback(() => {

+ 5 - 2
apps/app/src/components/PageList/PageListItemS.tsx

@@ -10,13 +10,16 @@ import { IPageHasId } from '~/interfaces/page';
 type PageListItemSProps = {
 type PageListItemSProps = {
   page: IPageHasId,
   page: IPageHasId,
   noLink?: boolean,
   noLink?: boolean,
+  pageTitle?: string
 }
 }
 
 
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 
 
-  const { page, noLink = false } = props;
+  const { page, noLink = false, pageTitle } = props;
 
 
-  let pagePathElement = <PagePathLabel path={page.path} additionalClassNames={['mx-1']} />;
+  const path = pageTitle != null ? pageTitle : page.path;
+
+  let pagePathElement = <PagePathLabel path={path} additionalClassNames={['mx-1']} />;
   if (!noLink) {
   if (!noLink) {
     pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
     pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
   }
   }

+ 7 - 1
apps/app/src/components/PrivateLegacyPages.tsx

@@ -436,7 +436,13 @@ const PrivateLegacyPages = (): JSX.Element => {
         ref={searchPageBaseRef}
         ref={searchPageBaseRef}
         pages={data?.data}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
-        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT, MenuItemType.PATH_RECOVERY]}
+        forceHideMenuItems={[
+          MenuItemType.BOOKMARK,
+          MenuItemType.RENAME,
+          MenuItemType.DUPLICATE,
+          MenuItemType.REVERT,
+          MenuItemType.PATH_RECOVERY,
+        ]}
         // Components
         // Components
         searchControl={searchControl}
         searchControl={searchControl}
         searchResultListHead={searchResultListHead}
         searchResultListHead={searchResultListHead}

+ 28 - 0
apps/app/src/components/Sidebar/Bookmarks.tsx

@@ -0,0 +1,28 @@
+
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser } from '~/stores/context';
+
+import { BookmarkContents } from './Bookmarks/BookmarkContents';
+
+export const Bookmarks = () : JSX.Element => {
+  const { t } = useTranslation();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3">
+        <h3 className="mb-0">{t('Bookmarks')}</h3>
+      </div>
+      {isGuestUser ? (
+        <h4 className="pl-3">
+          { t('Not available for guest') }
+        </h4>
+      ) : (
+        <BookmarkContents />
+      )}
+    </>
+  );
+};

+ 59 - 0
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -0,0 +1,59 @@
+import React, { useCallback, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { BookmarkFolderNameInput } from '~/components/Bookmarks/BookmarkFolderNameInput';
+import { BookmarkFolderTree } from '~/components/Bookmarks/BookmarkFolderTree';
+import { FolderPlusIcon } from '~/components/Icons/FolderPlusIcon';
+import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+
+export const BookmarkContents = (): JSX.Element => {
+
+  const { t } = useTranslation();
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+
+  const onClickNewBookmarkFolder = useCallback(() => {
+    setIsCreateAction(true);
+  }, []);
+
+  const onClickonClickOutsideHandler = useCallback(() => {
+    setIsCreateAction(false);
+  }, []);
+
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
+      await mutateBookmarkFolders();
+      setIsCreateAction(false);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateBookmarkFolders]);
+
+  return (
+    <>
+      <div className="col-8 mb-2 ">
+        <button
+          className="btn btn-block btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
+          onClick={onClickNewBookmarkFolder}
+        >
+          <FolderPlusIcon />
+          <span className="mx-2 ">{t('bookmark_folder.new_folder')}</span>
+        </button>
+      </div>
+      {isCreateAction && (
+        <div className="col-12 mb-2 ">
+          <BookmarkFolderNameInput
+            onClickOutside={onClickonClickOutsideHandler}
+            onPressEnter={onPressEnterHandlerForCreate}
+          />
+        </div>
+      )}
+      <BookmarkFolderTree />
+    </>
+  );
+};

+ 15 - 20
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -14,19 +14,21 @@ import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import { ValidationTarget } from '~/client/util/input-validator';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { TriangleIcon } from '~/components/Icons/TriangleIcon';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import {
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 
-import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import ClosableTextInput from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
 import CountBadge from '../../Common/CountBadge';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 
 
@@ -63,12 +65,6 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
   });
   });
 };
 };
 
 
-
-const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
-  const bookmarkOperation = _newValue ? bookmark : unbookmark;
-  await bookmarkOperation(_pageId);
-};
-
 /**
 /**
  * Return new page path after the droppedPagePath is moved under the newParentPagePath
  * Return new page path after the droppedPagePath is moved under the newParentPagePath
  * @param droppedPagePath
  * @param droppedPagePath
@@ -126,6 +122,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isCreating, setCreating] = useState(false);
   const [isCreating, setCreating] = useState(false);
 
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id);
 
 
   // descendantCount
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
   const { getDescCount } = usePageTreeDescCountMap();
@@ -258,6 +256,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
     }
   }, [hasDescendants]);
   }, [hasDescendants]);
 
 
+  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+    const bookmarkOperation = _newValue ? bookmark : unbookmark;
+    await bookmarkOperation(_pageId);
+    mutateCurrentUserBookmarks();
+    mutateBookmarkInfo();
+  };
+
   const duplicateMenuItemClickHandler = useCallback((): void => {
   const duplicateMenuItemClickHandler = useCallback((): void => {
     if (onClickDuplicateMenuItem == null) {
     if (onClickDuplicateMenuItem == null) {
       return;
       return;
@@ -365,16 +370,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
     }
   };
   };
 
 
-  const inputValidator = (title: string | null): AlertInfo | null => {
-    if (title == null || title === '' || title.trim() === '') {
-      return {
-        type: AlertType.WARNING,
-        message: t('form_validation.title_required'),
-      };
-    }
-
-    return null;
-  };
 
 
   /**
   /**
    * Users do not need to know if all pages have been renamed.
    * Users do not need to know if all pages have been renamed.
@@ -455,7 +450,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                   placeholder={t('Input page name')}
                   placeholder={t('Input page name')}
                   onClickOutside={() => { setRenameInputShown(false) }}
                   onClickOutside={() => { setRenameInputShown(false) }}
                   onPressEnter={onPressEnterForRenameHandler}
                   onPressEnter={onPressEnterForRenameHandler}
-                  inputValidator={inputValidator}
+                  validationTarget={ValidationTarget.PAGE}
                 />
                 />
               </NotDraggableForClosableTextInput>
               </NotDraggableForClosableTextInput>
             </div>
             </div>
@@ -529,7 +524,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               placeholder={t('Input page name')}
               placeholder={t('Input page name')}
               onClickOutside={() => { setNewPageInputShown(false) }}
               onClickOutside={() => { setNewPageInputShown(false) }}
               onPressEnter={onPressEnterForCreateHandler}
               onPressEnter={onPressEnterForCreateHandler}
-              inputValidator={inputValidator}
+              validationTarget={ValidationTarget.PAGE}
             />
             />
           </NotDraggableForClosableTextInput>
           </NotDraggableForClosableTextInput>
         </div>
         </div>

+ 4 - 0
apps/app/src/components/Sidebar/SidebarContents.tsx

@@ -3,6 +3,7 @@ import React, { memo } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 
+import { Bookmarks } from './Bookmarks';
 import CustomSidebar from './CustomSidebar';
 import CustomSidebar from './CustomSidebar';
 import PageTree from './PageTree';
 import PageTree from './PageTree';
 import RecentChanges from './RecentChanges';
 import RecentChanges from './RecentChanges';
@@ -22,6 +23,9 @@ export const SidebarContents = memo(() => {
     case SidebarContentsType.TAG:
     case SidebarContentsType.TAG:
       Contents = Tag;
       Contents = Tag;
       break;
       break;
+    case SidebarContentsType.BOOKMARKS:
+      Contents = Bookmarks;
+      break;
     default:
     default:
       Contents = PageTree;
       Contents = PageTree;
   }
   }

+ 1 - 0
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -105,6 +105,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
         <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
         <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* eslint-enable max-len */}
         {/* eslint-enable max-len */}
+        <PrimaryItem contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmark" onItemSelected={onItemSelected} />
       </div>
       </div>
       <div className="grw-sidebar-nav-secondary-container">
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}

+ 96 - 0
apps/app/src/components/UsersHomePageFooter.module.scss

@@ -1,11 +1,107 @@
 @use '@growi/ui/src/styles/molecules/page_list';
 @use '@growi/ui/src/styles/molecules/page_list';
+@use '~/styles/variables' as var;
+$grw-sidebar-content-header-height: 58px;
+$grw-sidebar-content-footer-height: 50px;
 
 
 .user-page-footer :global {
 .user-page-footer :global {
   .grw-user-page-list-m {
   .grw-user-page-list-m {
+    .list-group{
+      .list-group-item {
+        .grw-visible-on-hover {
+          display: none;
+        }
+
+        &:hover {
+          .grw-visible-on-hover {
+            display: block;
+          }
+        }
+        .grw-triangle-container{
+          svg {
+            width: 12px;
+            height: 12px;
+          }
+        }
+        svg{
+          width: 20px;
+          height: 20px;
+        }
+        min-height: 40px;
+        border-radius: 0px;
+
+
+        &.grw-bookmark-item-list {
+          .picture {
+            width: 16px;
+            height: 16px;
+            vertical-align: text-bottom;
+
+            &.picture-md {
+              width: 20px;
+              height: 20px;
+            }
+          }
+          svg{
+            width: 14px;
+            height: 14px;
+          }
+          .grw-foldertree-control{
+            margin-left: 1rem;
+          }
+        }
+      }
+
+
+    }
+
+    .grw-foldertree-item-container {
+      input {
+        max-width: 25%;
+      }
+    }
+    .grw-foldertree-title-anchor{
+      width: fit-content !important;
+      margin-right: 20px;
+    }
     svg {
     svg {
       width: 35px;
       width: 35px;
       height: 35px;
       height: 35px;
       margin-bottom: 6px;
       margin-bottom: 6px;
     }
     }
+    .new-bookmark-folder{
+      max-height: 30px;
+      svg {
+        width: 18px;
+        height: 18px;
+      }
+    }
+    .grw-expand-compress-btn {
+      max-height: 40px;
+      svg {
+        width: 18px;
+        height: 18px;
+        margin-bottom: 3px;
+      }
+    }
+    .grw-folder-tree-container {
+      .grw-drop-item-area {
+        padding: 1rem;
+        .grw-accept-drop-item {
+          padding: 0.5rem;
+          border-style: dashed;
+          border-width: 0.15rem;
+        }
+      }
+    }
   }
   }
 }
 }
+
+.grw-bookarks-contents-compressed {
+  max-height: calc(70vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+  overflow-y: scroll;
+}
+
+.grw-bookarks-contents-expanded {
+  max-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+  overflow-y: scroll;
+}

+ 21 - 5
apps/app/src/components/UsersHomePageFooter.tsx

@@ -1,12 +1,15 @@
-import React from 'react';
+import React, { useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-import { BookmarkList } from '~/components/PageList/BookmarkList';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import styles from '~/components/UsersHomePageFooter.module.scss';
 import styles from '~/components/UsersHomePageFooter.module.scss';
 
 
+import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
+import { CompressIcon } from './Icons/CompressIcon';
+import { ExpandIcon } from './Icons/ExpandIcon';
+
 export type UsersHomePageFooterProps = {
 export type UsersHomePageFooterProps = {
   creatorId: string,
   creatorId: string,
 }
 }
@@ -14,16 +17,29 @@ export type UsersHomePageFooterProps = {
 export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Element => {
 export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { creatorId } = props;
   const { creatorId } = props;
+  const [isExpanded, setIsExpanded] = useState<boolean>(false);
 
 
   return (
   return (
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
       <div className="grw-user-page-list-m d-edit-none">
       <div className="grw-user-page-list-m d-edit-none">
-        <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3">
+        <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
           <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
           <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
           {t('footer.bookmarks')}
           {t('footer.bookmarks')}
+          <span className="ml-auto pl-2 ">
+            <button
+              className={`btn btn-sm grw-expand-compress-btn ${isExpanded ? 'active' : ''}`}
+              onClick={() => setIsExpanded(!isExpanded)}
+            >
+              { isExpanded
+                ? <ExpandIcon/>
+                : <CompressIcon />
+              }
+            </button>
+          </span>
         </h2>
         </h2>
-        <div id="user-bookmark-list" className={`page-list ${styles['page-list']}`}>
-          <BookmarkList userId={creatorId} />
+        {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
+        <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
+          <BookmarkFolderTree isUserHomePage={true} />
         </div>
         </div>
       </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">
       <div className="grw-user-page-list-m mt-5 d-edit-none">

+ 32 - 0
apps/app/src/interfaces/bookmark-info.ts

@@ -17,3 +17,35 @@ type BookmarkedPage = {
 }
 }
 
 
 export type MyBookmarkList = BookmarkedPage[]
 export type MyBookmarkList = BookmarkedPage[]
+
+export interface IBookmarkFolder {
+  name: string
+  owner: Ref<IUser>
+  parent?: Ref<this>
+}
+
+export interface BookmarkFolderItems {
+  _id: string
+  name: string
+  parent: string
+  children: this[]
+  bookmarks: BookmarkedPage[]
+}
+
+export const DRAG_ITEM_TYPE = {
+  FOLDER: 'FOLDER',
+  BOOKMARK: 'BOOKMARK',
+} as const;
+
+type BookmarkDragItem = {
+  bookmarkFolder: BookmarkFolderItems
+  level: number
+  root: string
+}
+
+export type DragItemDataType = BookmarkDragItem & {
+  parentFolder: BookmarkFolderItems | null
+} & IPageHasId
+
+
+export type DragItemType = typeof DRAG_ITEM_TYPE[keyof typeof DRAG_ITEM_TYPE];

+ 2 - 0
apps/app/src/interfaces/ui.ts

@@ -5,6 +5,7 @@ export const SidebarContentsType = {
   RECENT: 'recent',
   RECENT: 'recent',
   TREE: 'tree',
   TREE: 'tree',
   TAG: 'tag',
   TAG: 'tag',
+  BOOKMARKS: 'bookmarks',
 } as const;
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];
@@ -24,3 +25,4 @@ export type OnDeletedFunction = (idOrPaths: string | string[], isRecursively: Nu
 export type OnRenamedFunction = (path: string) => void;
 export type OnRenamedFunction = (path: string) => void;
 export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;
 export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;
 export type OnPutBackedFunction = (path: string) => void;
 export type OnPutBackedFunction = (path: string) => void;
+export type onDeletedBookmarkFolderFunction = (bookmarkFolderId: string) => void;

+ 232 - 0
apps/app/src/server/models/bookmark-folder.ts

@@ -0,0 +1,232 @@
+import { objectIdUtils } from '@growi/core';
+import monggoose, {
+  Types, Document, Model, Schema,
+} from 'mongoose';
+
+import { IBookmarkFolder, BookmarkFolderItems, MyBookmarkList } from '~/interfaces/bookmark-info';
+import { IPageHasId } from '~/interfaces/page';
+
+import loggerFactory from '../../utils/logger';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import { InvalidParentBookmarkFolderError } from './errors';
+
+
+const logger = loggerFactory('growi:models:bookmark-folder');
+const Bookmark = monggoose.model('Bookmark');
+
+export interface BookmarkFolderDocument extends Document {
+  _id: Types.ObjectId
+  name: string
+  owner: Types.ObjectId
+  parent?: Types.ObjectId | undefined
+  bookmarks?: Types.ObjectId[],
+  children?: BookmarkFolderDocument[]
+}
+
+export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
+  createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>
+  findFolderAndChildren(user: Types.ObjectId | string, parentId?: Types.ObjectId | string): Promise<BookmarkFolderItems[]>
+  deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}>
+  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null): Promise<BookmarkFolderDocument>
+  insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null): Promise<BookmarkFolderDocument | null>
+  findUserRootBookmarksItem(userId: Types.ObjectId| string): Promise<MyBookmarkList>
+  updateBookmark(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId| string): Promise<BookmarkFolderDocument | null>
+}
+
+const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderModel>({
+  name: { type: String },
+  owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  parent: {
+    type: Schema.Types.ObjectId,
+    ref: 'BookmarkFolder',
+    required: false,
+  },
+  bookmarks: {
+    type: [{
+      type: Schema.Types.ObjectId, ref: 'Bookmark', required: false,
+    }],
+    required: false,
+    default: [],
+  },
+}, {
+  toObject: { virtuals: true },
+});
+
+bookmarkFolderSchema.virtual('children', {
+  ref: 'BookmarkFolder',
+  localField: '_id',
+  foreignField: 'parent',
+});
+
+bookmarkFolderSchema.statics.createByParameters = async function(params: IBookmarkFolder): Promise<BookmarkFolderDocument> {
+  const { name, owner, parent } = params;
+  let bookmarkFolder: BookmarkFolderDocument;
+
+  if (parent == null) {
+    bookmarkFolder = await this.create({ name, owner });
+  }
+  else {
+    // Check if parent folder id is valid and parent folder exists
+    const isParentFolderIdValid = objectIdUtils.isValidObjectId(parent as string);
+
+    if (!isParentFolderIdValid) {
+      throw new InvalidParentBookmarkFolderError('Parent folder id is invalid');
+    }
+    const parentFolder = await this.findById(parent);
+    if (parentFolder == null) {
+      throw new InvalidParentBookmarkFolderError('Parent folder not found');
+    }
+    bookmarkFolder = await this.create({ name, owner, parent:  parentFolder._id });
+  }
+
+  return bookmarkFolder;
+};
+
+bookmarkFolderSchema.statics.findFolderAndChildren = async function(
+    userId: Types.ObjectId | string,
+    parentId?: Types.ObjectId | string,
+): Promise<BookmarkFolderItems[]> {
+  const folderItems: BookmarkFolderItems[] = [];
+
+  const folders = await this.find({ owner: userId, parent: parentId })
+    .populate('children')
+    .populate({
+      path: 'bookmarks',
+      model: 'Bookmark',
+      populate: {
+        path: 'page',
+        model: 'Page',
+      },
+    });
+
+  const promises = folders.map(async(folder) => {
+    const children = await this.findFolderAndChildren(userId, folder._id);
+    const {
+      _id, name, owner, bookmarks, parent,
+    } = folder;
+
+    const res = {
+      _id: _id.toString(),
+      name,
+      owner,
+      bookmarks,
+      children,
+      parent,
+    };
+    return res;
+  });
+
+  const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
+  folderItems.push(...results);
+  return folderItems;
+};
+
+bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}> {
+  const bookmarkFolder = await this.findById(bookmarkFolderId);
+  // Delete parent and all children folder
+  let deletedCount = 0;
+  if (bookmarkFolder != null) {
+    // Delete Bookmarks
+    const bookmarks = bookmarkFolder?.bookmarks;
+    if (bookmarks && bookmarks.length > 0) {
+      await Bookmark.deleteMany({ _id: { $in: bookmarks } });
+    }
+    // Delete all child recursively and update deleted count
+    const childFolders = await this.find({ parent: bookmarkFolder._id });
+    await Promise.all(childFolders.map(async(child) => {
+      const deletedChildFolder = await this.deleteFolderAndChildren(child._id);
+      deletedCount += deletedChildFolder.deletedCount;
+    }));
+    const deletedChild = await this.deleteMany({ parent: bookmarkFolder._id });
+    deletedCount += deletedChild.deletedCount + 1;
+    bookmarkFolder.delete();
+  }
+  return { deletedCount };
+};
+
+bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolderId: string, name: string, parentId: string | null):
+ Promise<BookmarkFolderDocument> {
+  const updateFields: {name: string, parent: Types.ObjectId | null} = {
+    name: '',
+    parent: null,
+  };
+
+  updateFields.name = name;
+  const parentFolder = parentId ? await this.findById(parentId) : null;
+  updateFields.parent = parentFolder?._id ?? null;
+
+  // Maximum folder hierarchy of 2 levels
+  // If the destination folder (parentFolder) has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
+  // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
+  if (parentId != null) {
+    if (parentFolder?.parent != null) {
+      throw new Error('Update bookmark folder failed');
+    }
+    const bookmarkFolder = await this.findById(bookmarkFolderId).populate('children');
+    if (bookmarkFolder?.children?.length !== 0) {
+      throw new Error('Update bookmark folder failed');
+    }
+  }
+
+  const bookmarkFolder = await this.findByIdAndUpdate(bookmarkFolderId, { $set: updateFields }, { new: true });
+  if (bookmarkFolder == null) {
+    throw new Error('Update bookmark folder failed');
+  }
+  return bookmarkFolder;
+
+};
+
+bookmarkFolderSchema.statics.insertOrUpdateBookmarkedPage = async function(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null):
+Promise<BookmarkFolderDocument | null> {
+
+  // Create bookmark or update existing
+  const bookmarkedPage = await Bookmark.findOneAndUpdate({ page: pageId, user: userId }, { page: pageId, user: userId }, { new: true, upsert: true });
+
+  // Remove existing bookmark in bookmark folder
+  await this.updateMany({}, { $pull: { bookmarks:  bookmarkedPage._id } });
+
+  // Insert bookmark into bookmark folder
+  if (folderId != null) {
+    const bookmarkFolder = await this.findByIdAndUpdate(folderId, { $addToSet: { bookmarks: bookmarkedPage } }, { new: true, upsert: true });
+    return bookmarkFolder;
+  }
+
+  return null;
+};
+
+bookmarkFolderSchema.statics.findUserRootBookmarksItem = async function(userId: Types.ObjectId | string): Promise<MyBookmarkList> {
+  const bookmarkIdsInFolders = await this.distinct('bookmarks', { owner: userId });
+  const userRootBookmarks: MyBookmarkList = await Bookmark.find({
+    _id: { $nin: bookmarkIdsInFolders },
+  }).populate({
+    path: 'page',
+    model: 'Page',
+    populate: {
+      path: 'lastUpdateUser',
+      model: 'User',
+    },
+  });
+  return userRootBookmarks;
+};
+
+bookmarkFolderSchema.statics.updateBookmark = async function(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId | string):
+Promise<BookmarkFolderDocument | null> {
+  // If isBookmarked
+  if (status) {
+    const bookmarkedPage = await Bookmark.findOne({ page: pageId });
+    const bookmarkFolder = await this.findOne({ owner: userId, bookmarks: { $in: [bookmarkedPage?._id] } });
+    if (bookmarkFolder != null) {
+      await this.updateOne({ owner: userId, _id: bookmarkFolder._id }, { $pull: { bookmarks:  bookmarkedPage?._id } });
+    }
+
+    if (bookmarkedPage) {
+      await bookmarkedPage.delete();
+    }
+    return bookmarkFolder;
+  }
+  // else , Add bookmark
+  await Bookmark.create({ page: pageId, user: userId });
+  return null;
+};
+export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);

+ 3 - 0
apps/app/src/server/models/errors.ts

@@ -16,3 +16,6 @@ export class PathAlreadyExistsError extends ExtensibleCustomError {
 * User Authentication
 * User Authentication
 */
 */
 export class NullUsernameToBeRegisteredError extends ExtensibleCustomError {}
 export class NullUsernameToBeRegisteredError extends ExtensibleCustomError {}
+
+// Invalid Parent bookmark folder error
+export class InvalidParentBookmarkFolderError extends ExtensibleCustomError {}

+ 126 - 0
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -0,0 +1,126 @@
+import { ErrorV3 } from '@growi/core';
+import { body } from 'express-validator';
+
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
+import loggerFactory from '~/utils/logger';
+
+import BookmarkFolder from '../../models/bookmark-folder';
+
+const logger = loggerFactory('growi:routes:apiv3:bookmark-folder');
+
+const express = require('express');
+
+const router = express.Router();
+
+const validator = {
+  bookmarkFolder: [
+    body('name').isString().withMessage('name must be a string'),
+    body('parent').isMongoId().optional({ nullable: true })
+      .custom(async(parent: string) => {
+        const parentFolder = await BookmarkFolder.findById(parent);
+        if (parentFolder == null || parentFolder.parent != null) {
+          throw new Error('Maximum folder hierarchy of 2 levels');
+        }
+      }),
+  ],
+  bookmarkPage: [
+    body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
+    body('folderId').optional({ nullable: true }).isMongoId().withMessage('Folder ID must be a valid mongo ID'),
+  ],
+  bookmark: [
+    body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
+    body('status').isBoolean().withMessage('status must be one of true or false'),
+  ],
+};
+
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  // Create new bookmark folder
+  router.post('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
+    const owner = req.user?._id;
+    const { name, parent } = req.body;
+    const params = {
+      name, owner, parent,
+    };
+
+    try {
+      const bookmarkFolder = await BookmarkFolder.createByParameters(params);
+      logger.debug('bookmark folder created', bookmarkFolder);
+      return res.apiv3({ bookmarkFolder });
+    }
+    catch (err) {
+      if (err instanceof InvalidParentBookmarkFolderError) {
+        return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
+      }
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  // List bookmark folders and child
+  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+
+    try {
+      const bookmarkFolderItems = await BookmarkFolder.findFolderAndChildren(req.user?._id);
+
+      return res.apiv3({ bookmarkFolderItems });
+    }
+    catch (err) {
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  // Delete bookmark folder and children
+  router.delete('/:id', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const { id } = req.params;
+    try {
+      const result = await BookmarkFolder.deleteFolderAndChildren(id);
+      const { deletedCount } = result;
+      return res.apiv3({ deletedCount });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
+    const { bookmarkFolderId, name, parent } = req.body;
+    try {
+      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent);
+      return res.apiv3({ bookmarkFolder });
+    }
+    catch (err) {
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.post('/add-boookmark-to-folder', accessTokenParser, loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator, async(req, res) => {
+    const userId = req.user?._id;
+    const { pageId, folderId } = req.body;
+
+    try {
+      const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
+      logger.debug('bookmark added to folder', bookmarkFolder);
+      return res.apiv3({ bookmarkFolder });
+    }
+    catch (err) {
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  router.put('/update-bookmark', accessTokenParser, loginRequiredStrictly, validator.bookmark, async(req, res) => {
+    const { pageId, status } = req.body;
+    const userId = req.user?._id;
+    try {
+      const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
+      return res.apiv3({ bookmarkFolder });
+    }
+    catch (err) {
+      return res.apiv3Err(err, 500);
+    }
+  });
+  return router;
+};

+ 6 - 30
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -3,7 +3,7 @@ import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
+import BookmarkFolder from '../../models/bookmark-folder';
 
 
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 
@@ -192,47 +192,23 @@ module.exports = (crowi) => {
    */
    */
   validator.userBookmarkList = [
   validator.userBookmarkList = [
     param('userId').isMongoId().withMessage('userId is required'),
     param('userId').isMongoId().withMessage('userId is required'),
-    query('page').isInt({ min: 1 }),
-    query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
   ];
 
 
   router.get('/:userId', accessTokenParser, loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
   router.get('/:userId', accessTokenParser, loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
     const { userId } = req.params;
     const { userId } = req.params;
-    const page = req.query.page;
-    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM') || 30;
 
 
     if (userId == null) {
     if (userId == null) {
       return res.apiv3Err('User id is not found or forbidden', 400);
       return res.apiv3Err('User id is not found or forbidden', 400);
     }
     }
-    if (limit == null) {
-      return res.apiv3Err('Could not catch page limit', 400);
-    }
     try {
     try {
-      const paginationResult = await Bookmark.paginate(
-        {
-          user: { $in: userId },
-        },
-        {
-          populate: {
-            path: 'page',
-            model: 'Page',
-            populate: {
-              path: 'lastUpdateUser',
-              model: 'User',
-            },
-          },
-          page,
-          limit,
-        },
-      );
-
-      paginationResult.docs.forEach((doc) => {
-        if (doc.page.lastUpdateUser != null && doc.page.lastUpdateUser instanceof User) {
-          doc.page.lastUpdateUser = serializeUserSecurely(doc.page.lastUpdateUser);
+      const userRootBookmarks = await BookmarkFolder.findUserRootBookmarksItem(userId);
+      userRootBookmarks.forEach((bookmark) => {
+        if (bookmark.page.lastUpdateUser != null && bookmark.page.lastUpdateUser instanceof User) {
+          bookmark.page.lastUpdateUser = serializeUserSecurely(bookmark.page.lastUpdateUser);
         }
         }
       });
       });
 
 
-      return res.apiv3({ paginationResult });
+      return res.apiv3({ userRootBookmarks });
     }
     }
     catch (err) {
     catch (err) {
       logger.error('get-bookmark-failed', err);
       logger.error('get-bookmark-failed', err);

+ 1 - 1
apps/app/src/server/routes/apiv3/index.js

@@ -112,8 +112,8 @@ module.exports = (crowi, app) => {
 
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
+  router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
 
 
-
   return [router, routerForAdmin, routerForAuth];
   return [router, routerForAdmin, routerForAuth];
 };
 };

+ 15 - 0
apps/app/src/stores/bookmark-folder.ts

@@ -0,0 +1,15 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+
+export const useSWRxBookmarkFolderAndChild = (): SWRResponse<BookmarkFolderItems[], Error> => {
+
+  return useSWRImmutable(
+    '/bookmark-folder/list',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data.bookmarkFolderItems;
+    }),
+  );
+};

+ 20 - 0
apps/app/src/stores/bookmark.ts

@@ -1,9 +1,13 @@
+import { IUserHasId } from '@growi/core';
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
+import { IPageHasId } from '~/interfaces/page';
+
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
+import { useCurrentUser } from './context';
 
 
 export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
 export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
@@ -17,3 +21,19 @@ export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRRespon
     }),
     }),
   );
   );
 };
 };
+
+export const useSWRxCurrentUserBookmarks = (): SWRResponse<IPageHasId[], Error> => {
+  const { data: currentUser } = useCurrentUser();
+  const user = currentUser as IUserHasId;
+  return useSWRImmutable(
+    currentUser != null ? `/bookmarks/${user._id}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      const { userRootBookmarks } = response.data;
+      return userRootBookmarks.map((item) => {
+        return {
+          ...item.page,
+        };
+      });
+    }),
+  );
+};

+ 45 - 4
apps/app/src/stores/modal.tsx

@@ -3,9 +3,10 @@ import { useCallback, useMemo } from 'react';
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import {
 import {
-  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
+  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction,
 } from '~/interfaces/ui';
 } from '~/interfaces/ui';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -177,7 +178,7 @@ type RenameModalStatusUtils = {
   open(
   open(
     page?: IPageToRenameWithMeta,
     page?: IPageToRenameWithMeta,
     opts?: IRenameModalOption
     opts?: IRenameModalOption
-    ): Promise<RenameModalStatus | undefined>
+  ): Promise<RenameModalStatus | undefined>
   close(): Promise<RenameModalStatus | undefined>
   close(): Promise<RenameModalStatus | undefined>
 }
 }
 
 
@@ -220,8 +221,8 @@ type PutBackPageModalUtils = {
   open(
   open(
     page?: IPageForPagePutBackModal,
     page?: IPageForPagePutBackModal,
     opts?: IPutBackPageModalOption,
     opts?: IPutBackPageModalOption,
-    ): Promise<PutBackPageModalStatus | undefined>
-  close():Promise<PutBackPageModalStatus | undefined>
+  ): Promise<PutBackPageModalStatus | undefined>
+  close(): Promise<PutBackPageModalStatus | undefined>
 }
 }
 
 
 export const usePutBackPageModal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
 export const usePutBackPageModal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
@@ -582,6 +583,46 @@ export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Err
   });
   });
 };
 };
 
 
+/*
+* BookmarkFolderDeleteModal
+*/
+export type IDeleteBookmarkFolderModalOption = {
+  onDeleted?: onDeletedBookmarkFolderFunction,
+}
+
+type DeleteBookmarkFolderModalStatus = {
+  isOpened: boolean,
+  bookmarkFolder?: BookmarkFolderItems,
+  opts?: IDeleteBookmarkFolderModalOption,
+}
+
+type DeleteModalBookmarkFolderStatusUtils = {
+  open(
+    bookmarkFolder?: BookmarkFolderItems,
+    opts?: IDeleteBookmarkFolderModalOption,
+  ): Promise<DeleteBookmarkFolderModalStatus | undefined>,
+  close(): Promise<DeleteBookmarkFolderModalStatus | undefined>,
+}
+
+export const useBookmarkFolderDeleteModal = (status?: DeleteBookmarkFolderModalStatus):
+  SWRResponse<DeleteBookmarkFolderModalStatus, Error> & DeleteModalBookmarkFolderStatusUtils => {
+  const initialData: DeleteBookmarkFolderModalStatus = {
+    isOpened: false,
+  };
+  const swrResponse = useStaticSWR<DeleteBookmarkFolderModalStatus, Error>('deleteBookmarkFolderModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (
+        bookmarkFolder?: BookmarkFolderItems,
+        opts?: IDeleteBookmarkFolderModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, bookmarkFolder, opts,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
 /*
 /*
  * TemplateModal
  * TemplateModal
  */
  */

+ 56 - 2
apps/app/src/styles/theme/_apply-colors-dark.scss

@@ -30,6 +30,8 @@
   $bgcolor-dropdown-link-hover: var(--bgcolor-dropdown-link-hover,hsl.lighten(var(--bgcolor-global), 15%));
   $bgcolor-dropdown-link-hover: var(--bgcolor-dropdown-link-hover,hsl.lighten(var(--bgcolor-global), 15%));
   $color-dropdown-link-active: var(--color-dropdown-link-active,var(--light));
   $color-dropdown-link-active: var(--color-dropdown-link-active,var(--light));
   $bgcolor-dropdown-link-active: var(--bgcolor-dropdown-link-active,var(--primary));
   $bgcolor-dropdown-link-active: var(--bgcolor-dropdown-link-active,var(--primary));
+  $body-bg: var(--bgcolor-global);
+  $body-color: var(--color-global);
 
 
   // override bootstrap variables
   // override bootstrap variables
   $text-muted: $gray-550;
   $text-muted: $gray-550;
@@ -348,7 +350,7 @@
       $bgcolor-list-active
       $bgcolor-list-active
     );
     );
     // Pagetree
     // Pagetree
-    .grw-pagetree {
+    .grw-pagetree, .grw-foldertree {
       @include override-list-group-item-for-pagetree(
       @include override-list-group-item-for-pagetree(
         var(--color-sidebar-context),
         var(--color-sidebar-context),
         hsl.lighten(var(--bgcolor-sidebar-context),8%),
         hsl.lighten(var(--bgcolor-sidebar-context),8%),
@@ -358,7 +360,7 @@
         hsl.lighten(var(--bgcolor-sidebar-context),18%),
         hsl.lighten(var(--bgcolor-sidebar-context),18%),
         hsl.lighten(var(--bgcolor-sidebar-context),24%)
         hsl.lighten(var(--bgcolor-sidebar-context),24%)
       );
       );
-      .grw-pagetree-triangle-btn {
+      .grw-pagetree-triangle-btn, .grw-foldertree-triangle-btn {
         @include mixins-buttons.button-outline-svg-icon-variant(var(--secondary), $gray-200);
         @include mixins-buttons.button-outline-svg-icon-variant(var(--secondary), $gray-200);
       }
       }
       .btn-page-item-control {
       .btn-page-item-control {
@@ -373,6 +375,15 @@
         box-shadow: none !important;
         box-shadow: none !important;
       }
       }
     }
     }
+
+    // bookmarks
+    .grw-folder-tree-container {
+      .grw-drop-item-area , .grw-foldertree-item-container {
+        .grw-accept-drop-item {
+          border-color: hsl.lighten(var(--bgcolor-sidebar-context), 30%) !important;
+        }
+      }
+    }
     .private-legacy-pages-link {
     .private-legacy-pages-link {
       &:hover {
       &:hover {
         background: var(--bgcolor-list-hover);
         background: var(--bgcolor-list-hover);
@@ -393,6 +404,49 @@
     box-shadow: none !important;
     box-shadow: none !important;
   }
   }
 
 
+  // Bookmark item on user page
+  .grw-user-page-list-m {
+    @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
+    .grw-foldertree {
+      @include override-list-group-item-for-pagetree(
+        $body-color,
+        hsl.lighten($body-bg, 8%),
+        hsl.lighten($body-bg, 15%),
+        hsl.darken($body-color, 15%),
+        hsl.darken($body-color, 10%),
+        hsl.lighten($body-bg, 18%),
+        hsl.lighten($body-bg, 24%)
+      );
+      .grw-foldertree-triangle-btn {
+        @include mixins-buttons.button-outline-svg-icon-variant($secondary, $gray-200);
+      }
+    }
+    .grw-folder-tree-container {
+      .grw-drop-item-area , .grw-foldertree-item-container {
+        .grw-accept-drop-item {
+          border-color: hsl.lighten(var($body-bg), 30%) !important;
+        }
+      }
+    }
+  }
+
+  // Bookmark dropdown menu
+  .grw-bookmark-folder-dropdown  {
+    .grw-bookmark-folder-menu {
+      .form-control{
+        &:focus {
+          color: $body-color
+        }
+      }
+      .grw-bookmark-folder-menu-item  {
+        @include mixins-buttons.button-outline-svg-icon-variant($secondary, $gray-200);
+        .grw-bookmark-folder-menu-item-title {
+          color: $body-color
+        }
+      }
+    }
+  }
+
   /*
   /*
   * Popover
   * Popover
   */
   */

+ 58 - 2
apps/app/src/styles/theme/_apply-colors-light.scss

@@ -30,6 +30,8 @@
   $color-dropdown-link-active: var(--color-dropdown-link-active,var(--color-reversal));
   $color-dropdown-link-active: var(--color-dropdown-link-active,var(--color-reversal));
   $bgcolor-dropdown-link-hover: hsl.darken(var(--bgcolor-global),15%);
   $bgcolor-dropdown-link-hover: hsl.darken(var(--bgcolor-global),15%);
   $bgcolor-dropdown-link-active: var(--bgcolor-dropdown-link-active,var(--primary));
   $bgcolor-dropdown-link-active: var(--bgcolor-dropdown-link-active,var(--primary));
+  $body-bg: var(--bgcolor-global);
+  $body-color: var(--color-global);
 
 
   // override bootstrap variables
   // override bootstrap variables
   $text-muted: $gray-500;
   $text-muted: $gray-500;
@@ -225,7 +227,7 @@
       box-shadow: 0px 0px 3px rgba(black, 0.24);
       box-shadow: 0px 0px 3px rgba(black, 0.24);
     }
     }
     // Pagetree
     // Pagetree
-    .grw-pagetree {
+    .grw-pagetree, .grw-foldertree {
       @include override-list-group-item-for-pagetree(
       @include override-list-group-item-for-pagetree(
         var(--color-sidebar-context),
         var(--color-sidebar-context),
         hsl.darken(var(--bgcolor-sidebar-context),5%),
         hsl.darken(var(--bgcolor-sidebar-context),5%),
@@ -236,10 +238,20 @@
         hsl.darken(var(--bgcolor-sidebar-context),24%)
         hsl.darken(var(--bgcolor-sidebar-context),24%)
       );
       );
 
 
-      .grw-pagetree-triangle-btn {
+      .grw-pagetree-triangle-btn, .grw-foldertree-triangle-btn {
         @include mixins-buttons.button-outline-svg-icon-variant($gray-400, var(--primary));
         @include mixins-buttons.button-outline-svg-icon-variant($gray-400, var(--primary));
       }
       }
     }
     }
+
+    // bookmark
+    .grw-folder-tree-container {
+      .grw-drop-item-area, .grw-foldertree-item-container  {
+        .grw-accept-drop-item {
+          border-color: hsl.darken(var(--bgcolor-sidebar-context), 30%) !important;
+        }
+      }
+    }
+
     .private-legacy-pages-link {
     .private-legacy-pages-link {
       &:hover {
       &:hover {
         background: $bgcolor-list-hover;
         background: $bgcolor-list-hover;
@@ -262,6 +274,50 @@
     box-shadow: none !important;
     box-shadow: none !important;
   }
   }
 
 
+
+  // Bookmark item on user page
+  .grw-user-page-list-m {
+    @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
+    .grw-foldertree {
+      @include override-list-group-item-for-pagetree(
+        $body-color,
+        hsl.darken($body-bg, 5%),
+        hsl.darken($body-bg, 12%),
+        hsl.lighten($body-color, 10%),
+        hsl.lighten($body-color, 8%),
+        hsl.darken($body-bg, 15%),
+        hsl.darken($body-bg, 24%)
+      );
+      .grw-foldertree-triangle-btn {
+        @include mixins-buttons.button-outline-svg-icon-variant($gray-400, $primary);
+      }
+    }
+    .grw-folder-tree-container {
+      .grw-drop-item-area, .grw-foldertree-item-container  {
+        .grw-accept-drop-item {
+          border-color: hsl.darken(var($body-bg), 30%) !important;
+        }
+      }
+    }
+  }
+
+  // Bookmark dropdown menu
+  .grw-bookmark-folder-dropdown  {
+    .grw-bookmark-folder-menu {
+      .form-control{
+        &:focus {
+          color: $body-color
+        }
+      }
+      .grw-bookmark-folder-menu-item {
+        @include mixins-buttons.button-outline-svg-icon-variant($gray-400, $primary);
+        .grw-bookmark-folder-menu-item-title {
+          color: $body-color
+        }
+      }
+    }
+  }
+
   /*
   /*
   * GROWI page list
   * GROWI page list
   */
   */

+ 17 - 2
apps/app/src/styles/theme/apply-colors.scss

@@ -279,13 +279,14 @@ ul.pagination {
     }
     }
   }
   }
 
 
-  .grw-pagetree {
+  .grw-pagetree, .grw-foldertree {
     .list-group-item {
     .list-group-item {
-      .grw-pagetree-title-anchor {
+      .grw-pagetree-title-anchor, .grw-foldertree-title-anchor {
         color: inherit;
         color: inherit;
       }
       }
     }
     }
   }
   }
+
   .grw-pagetree-footer {
   .grw-pagetree-footer {
     .h5.grw-private-legacy-pages-anchor {
     .h5.grw-private-legacy-pages-anchor {
       color: inherit;
       color: inherit;
@@ -311,6 +312,7 @@ ul.pagination {
       }
       }
     }
     }
   }
   }
+
 }
 }
 
 
 /*
 /*
@@ -696,6 +698,19 @@ Emoji picker modal
   background-color: transparent !important;
   background-color: transparent !important;
 }
 }
 
 
+/*
+Expand / compress button bookmark list on users page
+*/
+.grw-user-page-list-m {
+  .grw-expand-compress-btn {
+    color: $body-color;
+    background-color: $body-bg;
+    &.active {
+      background-color: hsl.darken($body-bg, 12%),
+    }
+  }
+}
+
 /*
 /*
  * Questionnaire modal
  * Questionnaire modal
  */
  */

+ 2 - 1
apps/app/src/styles/theme/mixins/_list-group.scss

@@ -61,7 +61,8 @@
         background-color: $bgcolor-active;
         background-color: $bgcolor-active;
       }
       }
     }
     }
-    .grw-pagetree-title-anchor {
+    .grw-pagetree-title-anchor,
+    .grw-foldertree-title-anchor {
       .grw-sidebar-text-muted {
       .grw-sidebar-text-muted {
         // color: rgba(desaturate($color, 50%), 0.6);
         // color: rgba(desaturate($color, 50%), 0.6);
       }
       }

+ 6 - 4
apps/app/test/cypress/integration/20-basic-features/20-basic-features--click-page-icons.spec.ts

@@ -93,8 +93,8 @@ context('Click page icons button', () => {
     cy.collapseSidebar(true);
     cy.collapseSidebar(true);
 
 
     // bookmark
     // bookmark
-    cy.get('#bookmark-button').click({force: true});
-    cy.get('#bookmark-button').should('have.class', 'active');
+    cy.get('#bookmark-dropdown-btn').click({force: true});
+    cy.get('#bookmark-dropdown-btn').should('have.class', 'active');
 
 
     // position of the element is not fixed to be displayed, so the element is removed
     // position of the element is not fixed to be displayed, so the element is removed
     cy.get('body').then($body => {
     cy.get('body').then($body => {
@@ -120,8 +120,10 @@ context('Click page icons button', () => {
     cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}8-bookmarks-counter`) });
     cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}8-bookmarks-counter`) });
 
 
     // unbookmark
     // unbookmark
-    cy.get('#bookmark-button').click({force: true});
-    cy.get('#bookmark-button').should('not.have.class', 'active');
+    cy.get('#bookmark-dropdown-btn').click({force: true});
+    cy.get('.grw-bookmark-folder-menu').should('be.visible');
+    cy.get('.grw-bookmark-folder-menu-item').first().click({force: true});
+    cy.get('#bookmark-dropdown-btn').should('not.have.class', 'active');
 
 
     // position of the element is not fixed to be displayed, so the element is removed
     // position of the element is not fixed to be displayed, so the element is removed
     cy.get('body').then($body => {
     cy.get('body').then($body => {

+ 6 - 0
packages/preset-themes/src/styles/island.scss

@@ -159,5 +159,11 @@
         @include mixins-buttons.button-outline-svg-icon-variant($gray-400, #0d3955);
         @include mixins-buttons.button-outline-svg-icon-variant($gray-400, #0d3955);
       }
       }
     }
     }
+    // Foldertree
+    .grw-foldertree {
+      .grw-foldertree-triangle-btn {
+        @include mixins-buttons.button-outline-svg-icon-variant($gray-400, var(--bgcolor-sidebar));
+      }
+    }
   }
   }
 }
 }