Просмотр исходного кода

Merge pull request #6843 from weseek/feat/gw7895-create-new-folder-from-sidebar-bookamarks

feat: gw7895 create new folder from sidebar bookamarks
Kaori Tokashiki 3 лет назад
Родитель
Сommit
3b0a089058
23 измененных файлов с 903 добавлено и 97 удалено
  1. 13 1
      packages/app/public/static/locales/en_US/translation.json
  2. 13 1
      packages/app/public/static/locales/ja_JP/translation.json
  3. 13 1
      packages/app/public/static/locales/zh_CN/translation.json
  4. 39 0
      packages/app/src/components/Icons/FolderIcon.tsx
  5. 18 0
      packages/app/src/components/Icons/FolderPlusIcon.tsx
  6. 8 62
      packages/app/src/components/Sidebar/Bookmarks.tsx
  7. 73 0
      packages/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  8. 238 0
      packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderItem.tsx
  9. 50 0
      packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderItemControl.tsx
  10. 43 0
      packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderNameInput.tsx
  11. 145 0
      packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderTree.module.scss
  12. 81 0
      packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderTree.tsx
  13. 4 4
      packages/app/src/components/Sidebar/Bookmarks/BookmarkItem.tsx
  14. 53 0
      packages/app/src/components/Sidebar/Bookmarks/DeleteBookmarkFolderModal.tsx
  15. 6 0
      packages/app/src/components/Theme/ThemeIsland.global.scss
  16. 7 0
      packages/app/src/interfaces/bookmark-info.ts
  17. 16 12
      packages/app/src/server/models/bookmark-folder.ts
  18. 11 14
      packages/app/src/server/routes/apiv3/bookmark-folder.ts
  19. 16 0
      packages/app/src/stores/bookmark-folder.ts
  20. 27 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  21. 16 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  22. 9 0
      packages/app/src/styles/theme/_apply-colors.scss
  23. 4 2
      packages/app/src/styles/theme/mixins/_list-group.scss

+ 13 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -540,7 +540,8 @@
     "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"
+    "failed_to_reset_password":"Failed to reset password",
+    "delete_succeeded": "Succeeded to delete {{target}}"
   },
   "template": {
     "modal_label": {
@@ -866,5 +867,16 @@
   "footer": {
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
+  },
+  "bookmark_folder":{
+    "bookmark_folder": "bookmark folder",
+    "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"
   }
 }

+ 13 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -531,7 +531,8 @@
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
     "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
-    "failed_to_reset_password":"パスワードのリセットに失敗しました"
+    "failed_to_reset_password":"パスワードのリセットに失敗しました",
+    "delete_succeeded": "{{target}} の削除に成功しました"
   },
   "template": {
     "modal_label": {
@@ -857,5 +858,16 @@
   "footer": {
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
+  },
+  "bookmark_folder":{
+    "bookmark_folder": "ブックマークフォルダ",
+    "delete_modal": {
+      "modal_header_label": "ブックマークフォルダを削除",
+      "modal_body_description": "このブックマーク フォルダとその内容を削除する",
+      "modal_body_alert": "削除されたフォルダとその内容は復元できません",
+      "modal_footer_button": "フォルダを削除"
+    },
+    "input_placeholder": "フォルダ名を入力してください`",
+    "new_folder": "新しいフォルダ"
   }
 }

+ 13 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -510,7 +510,8 @@
 		"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"
+    "failed_to_reset_password":"Failed to reset password",
+    "delete_succeeded": "Succeeded to delete {{target}}"
   },
 	"template": {
 		"modal_label": {
@@ -913,5 +914,16 @@
   "footer": {
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
+  },
+  "bookmark_folder":{
+    "bookmark_folder": "书签文件夹",
+    "delete_modal": {
+      "modal_header_label": "删除书签文件夹",
+      "modal_body_description": "删除此书签文件夹及其内容",
+      "modal_body_alert": "已删除的文件夹及其内容无法恢复",
+      "modal_footer_button": "删除文件夹"
+    },
+    "input_placeholder": "输入文件夹名称",
+    "new_folder": "新建文件夹"
   }
 }

+ 39 - 0
packages/app/src/components/Icons/FolderIcon.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+
+type Props = {
+  isOpen: boolean
+}
+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>
+      )
+      }
+    </>
+  );
+
+};
+
+export default FolderIcon;

+ 18 - 0
packages/app/src/components/Icons/FolderPlusIcon.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+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>
+);
+
+export default FolderPlusIcon;

+ 8 - 62
packages/app/src/components/Sidebar/Bookmarks.tsx

@@ -3,80 +3,26 @@ import React from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { toastSuccess } from '~/client/util/apiNotification';
-import { IPageToDeleteWithMeta } from '~/interfaces/page';
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useIsGuestUser } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/modal';
-
-
-import BookmarkItem from './Bookmarks/BookmarkItem';
 
+import BookamrkContents from './Bookmarks/BookmarkContents';
 
 const Bookmarks = () : JSX.Element => {
   const { t } = useTranslation();
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: currentUserBookmarksData, mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
-  const { open: openDeleteModal } = usePageDeleteModal();
-
-  const deleteMenuItemClickHandler = (pageToDelete: IPageToDeleteWithMeta) => {
-    const pageDeletedHandler : OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
-      const path = pathOrPathsToDelete;
-
-      if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path }));
-      }
-      else {
-        toastSuccess(t('deleted_pages', { path }));
-      }
-      mutateCurrentUserBookmarks();
-    };
-    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  };
-
-  const renderBookmarkList = () => {
-    if (currentUserBookmarksData?.length === 0) {
-      return (
-        <h4 className="pl-3">
-          { t('No bookmarks yet') }
-        </h4>
-      );
-    }
-    return (
-      <ul className="grw-bookmarks-list list-group p-3">
-        <div className="grw-bookmarks-item-container">
-          { currentUserBookmarksData?.map((currentUserBookmark) => {
-            return (
-              <BookmarkItem
-                key={currentUserBookmark._id}
-                bookmarkedPage={currentUserBookmark}
-                onUnbookmarked={mutateCurrentUserBookmarks}
-                onRenamed={mutateCurrentUserBookmarks}
-                onClickDeleteMenuItem={deleteMenuItemClickHandler}
-              />
-            );
-          })}
-        </div>
-      </ul>
-    );
-  };
 
   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>
-        ) : renderBookmarkList()
-      }
+      {isGuestUser ? (
+        <h4 className="pl-3">
+          { t('Not available for guest') }
+        </h4>
+      ) : (
+        <BookamrkContents />
+      )}
     </>
   );
 };

+ 73 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -0,0 +1,73 @@
+import React, { useCallback, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import FolderPlusIcon from '~/components/Icons/FolderPlusIcon';
+import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+
+import BookmarkFolderNameInput from './BookmarkFolderNameInput';
+import BookmarkFolderTree from './BookmarkFolderTree';
+
+
+const BookmarkContents = (): JSX.Element => {
+
+  const { t } = useTranslation();
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+  const { mutate: mutateChildBookmarkData } = useSWRxBookamrkFolderAndChild(null);
+
+
+  const onClickNewBookmarkFolder = useCallback(() => {
+    setIsCreateAction(true);
+  }, []);
+
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+
+    try {
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
+      await mutateChildBookmarkData();
+      setIsCreateAction(false);
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateChildBookmarkData, t]);
+
+  const renderAddNewBookmarkFolder = useCallback(() => (
+    <>
+      <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={() => setIsCreateAction(false)}
+              onPressEnter={onPressEnterHandlerForCreate}
+            />
+          </div>
+        )
+      }
+    </>
+  ), [isCreateAction, onClickNewBookmarkFolder, onPressEnterHandlerForCreate, t]);
+
+  return (
+    <>
+      {
+        renderAddNewBookmarkFolder()
+      }
+      <BookmarkFolderTree />
+    </>
+  );
+};
+
+export default BookmarkContents;

+ 238 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderItem.tsx

@@ -0,0 +1,238 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { DropdownToggle } from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
+import CountBadge from '~/components/Common/CountBadge';
+import FolderIcon from '~/components/Icons/FolderIcon';
+import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+
+import BookmarkFolderItemControl from './BookmarkFolderItemControl';
+import BookmarkFolderNameInput from './BookmarkFolderNameInput';
+import DeleteBookmarkFolderModal from './DeleteBookmarkFolderModal';
+
+
+type BookmarkFolderItemProps = {
+  bookmarkFolder: BookmarkFolderItems
+  isOpen?: boolean
+}
+const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+  const { bookmarkFolder, isOpen: _isOpen = false } = props;
+
+  const { t } = useTranslation();
+  const {
+    name, _id: folderId, children, parent,
+  } = bookmarkFolder;
+  const [currentChildren, setCurrentChildren] = useState<BookmarkFolderItems[]>();
+  const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+  const { data: childBookmarkFolderData, mutate: mutateChildBookmarkData } = useSWRxBookamrkFolderAndChild(targetFolder);
+  const { mutate: mutateParentBookmarkFolder } = useSWRxBookamrkFolderAndChild(parent);
+  const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+  const [isDeleteFolderModalShown, setIsDeleteFolderModalShown] = useState<boolean>(false);
+
+  const getChildCount = useCallback((): number => {
+    if (currentChildren != null && currentChildren.length > children.length) {
+      return currentChildren.length;
+    }
+    return children.length;
+  }, [children.length, currentChildren]);
+
+  useEffect(() => {
+    if (childBookmarkFolderData != null) {
+      mutateChildBookmarkData();
+      setCurrentChildren(childBookmarkFolderData);
+    }
+  }, [childBookmarkFolderData, mutateChildBookmarkData]);
+
+  const hasChildren = useCallback((): boolean => {
+    if (currentChildren != null && currentChildren.length > children.length) {
+      return currentChildren.length > 0;
+    }
+    return children.length > 0;
+  }, [children.length, currentChildren]);
+
+  const loadChildFolder = useCallback(async() => {
+    setIsOpen(!isOpen);
+    setTargetFolder(folderId);
+  }, [folderId, isOpen]);
+
+  const loadParent = useCallback(async() => {
+    if (!isRenameAction) {
+      if (parent != null) {
+        await mutateParentBookmarkFolder();
+      }
+      // Reload root folder structure
+      setTargetFolder(null);
+    }
+    else {
+      await mutateParentBookmarkFolder();
+    }
+
+  }, [isRenameAction, mutateParentBookmarkFolder, parent]);
+
+  // Rename  for bookmark folder handler
+  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Put('/bookmark-folder', { bookmarkFolderId: folderId, name: folderName, parent });
+      loadParent();
+      setIsRenameAction(false);
+      toastSuccess(t('toaster.update_successed', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [folderId, loadParent, parent, t]);
+
+  // Create new folder / subfolder handler
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: targetFolder });
+      setIsOpen(true);
+      setIsCreateAction(false);
+      mutateChildBookmarkData();
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateChildBookmarkData, t, targetFolder]);
+
+  // Delete Fodler handler
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    try {
+      await apiv3Delete(`/bookmark-folder/${folderId}`);
+      setIsDeleteFolderModalShown(false);
+      loadParent();
+      toastSuccess(t('toaster.delete_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [folderId, loadParent, t]);
+
+  const onClickPlusButton = useCallback(async() => {
+    if (!isOpen && hasChildren()) {
+      setIsOpen(true);
+    }
+    setIsCreateAction(true);
+  }, [hasChildren, isOpen]);
+
+  const renderChildFolder = () => {
+    return isOpen && currentChildren?.map((childFolder) => {
+      return (
+        <div key={childFolder._id} className="grw-foldertree-item-children">
+          <BookmarkFolderItem
+            key={childFolder._id}
+            bookmarkFolder={childFolder}
+          />
+        </div>
+      );
+    });
+  };
+
+  const onClickRenameHandler = useCallback(() => {
+    setIsRenameAction(true);
+  }, []);
+
+  const onClickDeleteHandler = useCallback(() => {
+    setIsDeleteFolderModalShown(true);
+  }, []);
+
+  const onDeleteFolderModalClose = useCallback(() => {
+    setIsDeleteFolderModalShown(false);
+  }, []);
+
+
+  return (
+    <div id={`bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container"
+    >
+      <li className="list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center">
+        <div className="grw-triangle-container d-flex justify-content-center">
+          {hasChildren() && (
+            <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 flex-grow-1 pl-2' onClick={loadChildFolder}>
+              <p className={'text-truncate m-auto '}>{name}</p>
+            </div>
+            {hasChildren() && (
+              <div className="grw-foldertree-count-wrapper">
+                <CountBadge count={ getChildCount() } />
+              </div>
+            )}
+          </>
+        )
+
+        }
+        <div className="grw-foldertree-control d-flex">
+          <BookmarkFolderItemControl
+            onClickRename={onClickRenameHandler}
+            onClickDelete={onClickDeleteHandler}
+          >
+            <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>
+          </BookmarkFolderItemControl>
+          <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>
+      {isCreateAction && (
+        <div className="flex-fill">
+          <BookmarkFolderNameInput
+            onClickOutside={() => setIsCreateAction(false)}
+            onPressEnter={onPressEnterHandlerForCreate}
+          />
+        </div>
+      )}
+      {
+        renderChildFolder()
+      }
+      <DeleteBookmarkFolderModal
+        bookmarkFolder={bookmarkFolder}
+        isOpen={isDeleteFolderModalShown}
+        onClickDeleteButton={onClickDeleteButtonHandler}
+        onModalClose={onDeleteFolderModalClose}/>
+    </div>
+  );
+};
+
+export default BookmarkFolderItem;

+ 50 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderItemControl.tsx

@@ -0,0 +1,50 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+} from 'reactstrap';
+
+
+type BookmarkFolderItemControlProps = {
+  children?: React.ReactNode
+  onClickRename: () => void
+  onClickDelete: () => void
+}
+const BookmarkFolderItemControl = (props: BookmarkFolderItemControlProps): JSX.Element => {
+  const { t } = useTranslation();
+  const { children, onClickRename, onClickDelete } = props;
+  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
+        positionFixed
+        modifiers={{ preventOverflow: { boundariesElement: undefined } }}
+        right={true}
+      >
+        <DropdownItem
+          onClick={onClickRename}
+        >
+          <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>
+  );
+};
+
+export default BookmarkFolderItemControl;

+ 43 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderNameInput.tsx

@@ -0,0 +1,43 @@
+import { useTranslation } from 'next-i18next';
+
+import ClosableTextInput, { AlertInfo, AlertType } from '~/components/Common/ClosableTextInput';
+
+
+type Props = {
+  onClickOutside: () => void
+  onPressEnter: (folderName: string) => void
+  value?: string
+}
+
+const BookmarkFolderNameInput = (props: Props): JSX.Element => {
+  const {
+    onClickOutside, onPressEnter, value,
+  } = props;
+  const { t } = useTranslation();
+
+
+  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;
+  };
+
+  return (
+    <div className="flex-fill">
+      <ClosableTextInput
+        value={ value }
+        placeholder={t('bookmark_folder.input_placeholder')}
+        onClickOutside={onClickOutside}
+        onPressEnter={onPressEnter}
+        inputValidator={inputValidator}
+      />
+    </div>
+  );
+};
+
+
+export default BookmarkFolderNameInput;

+ 145 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderTree.module.scss

@@ -0,0 +1,145 @@
+@use '~/styles/variables' as var;
+$grw-sidebar-content-header-height: 58px;
+$grw-sidebar-content-footer-height: 50px;
+$grw-foldertree-item-padding-left: 10px;
+
+.grw-foldertree {
+  :global {
+    min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+
+    .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-count-badge {
+          display: none;
+        }
+      }
+
+      .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-count-wrapper {
+        display: inline-block;
+
+        &:hover {
+          display: none;
+        }
+      }
+    }
+
+    .grw-foldertree-item-container {
+      .grw-triangle-container {
+        min-width: 35px;
+        height: 40px;
+      }
+      .bookmark-item-list{
+        min-width: 30px;
+        height: 35px;
+      }
+    }
+  }
+  &:global{
+    // To realize a hierarchical structure, set multiplied padding-left to each foldertree-item
+    > .grw-foldertree-item-container {
+      > .list-group-item {
+        padding-left: 0;
+      }
+      > .grw-foldertree-item-children {
+        > .grw-foldertree-item-container {
+          > .list-group-item {
+            padding-left: $grw-foldertree-item-padding-left;
+          }
+          > .grw-foldertree-item-children {
+            > .grw-foldertree-item-container {
+              > .list-group-item {
+                padding-left: $grw-foldertree-item-padding-left * 2;
+              }
+              > .grw-foldertree-item-children {
+                > .grw-foldertree-item-container {
+                  > .list-group-item {
+                    padding-left: $grw-foldertree-item-padding-left * 3;
+                  }
+                  > .grw-foldertree-item-children {
+                    > .grw-foldertree-item-container {
+                      > .list-group-item {
+                        padding-left: $grw-foldertree-item-padding-left * 4;
+                      }
+                      > .grw-foldertree-item-children {
+                        > .grw-foldertree-item-container {
+                          > .list-group-item {
+                            padding-left: $grw-foldertree-item-padding-left * 5;
+                          }
+                          > .grw-foldertree-item-children {
+                            > .grw-foldertree-item-container {
+                              > .list-group-item {
+                                padding-left: $grw-foldertree-item-padding-left * 6;
+                              }
+                              > .grw-foldertree-item-children {
+                                > .grw-foldertree-item-container {
+                                  > .list-group-item {
+                                    padding-left: $grw-foldertree-item-padding-left * 7;
+                                  }
+                                  > .grw-foldertree-item-children {
+                                    > .grw-foldertree-item-container {
+                                      > .list-group-item {
+                                        padding-left: $grw-foldertree-item-padding-left * 8;
+                                      }
+                                      > .grw-foldertree-item-children {
+                                        > .grw-foldertree-item-container {
+                                          > .list-group-item {
+                                            padding-left: $grw-foldertree-item-padding-left * 9;
+                                          }
+                                          .grw-foldertree-item-children {
+                                            > .grw-foldertree-item-container {
+                                              > .list-group-item {
+                                                padding-left: $grw-foldertree-item-padding-left * 10;
+                                              }
+                                            }
+                                          }
+                                        }
+                                      }
+                                    }
+                                  }
+                                }
+                              }
+                            }
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 81 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderTree.tsx

@@ -0,0 +1,81 @@
+
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess } from '~/client/util/apiNotification';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+import { usePageDeleteModal } from '~/stores/modal';
+
+import BookmarkFolderItem from './BookmarkFolderItem';
+import BookmarkItem from './BookmarkItem';
+
+import styles from './BookmarkFolderTree.module.scss';
+
+
+const BookmarkFolderTree = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentUserBookmarksData, mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { data: bookmarkFolderData } = useSWRxBookamrkFolderAndChild();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  const deleteMenuItemClickHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+    const pageDeletedHandler : OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
+      const path = pathOrPathsToDelete;
+
+      if (isCompletely) {
+        toastSuccess(t('deleted_pages_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_pages', { path }));
+      }
+      mutateCurrentUserBookmarks();
+    };
+    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+  }, [mutateCurrentUserBookmarks, openDeleteModal, t]);
+
+
+  return (
+    <>
+      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group p-3`}>
+        {bookmarkFolderData?.map((item) => {
+          return (
+            <BookmarkFolderItem
+              key={item._id}
+              bookmarkFolder={item}
+              isOpen={false}
+            />
+          );
+        })}
+        {currentUserBookmarksData?.length === 0 && (
+          <div className="pt-3">
+            <h5 className="pl-3">
+              { t('No bookmarks yet') }
+            </h5>
+          </div>
+        )}
+        { currentUserBookmarksData?.map((currentUserBookmark) => {
+          return (
+            <BookmarkItem
+              key={currentUserBookmark._id}
+              bookmarkedPage={currentUserBookmark}
+              onUnbookmarked={mutateCurrentUserBookmarks}
+              onRenamed={mutateCurrentUserBookmarks}
+              onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            />
+          );
+        })}
+      </ul>
+    </>
+  );
+
+
+};
+
+export default BookmarkFolderTree;

+ 4 - 4
packages/app/src/components/Sidebar/Bookmarks/BookmarkItem.tsx

@@ -96,8 +96,8 @@ const BookmarkItem = (props: Props): JSX.Element => {
   }, [bookmarkedPage, onClickDeleteMenuItem]);
 
   return (
-    <div className="d-flex justify-content-between" key={bookmarkedPage._id}>
-      <li className="list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center" id={bookmarkItemId}>
+    <div className="grw-foldertree-item-container" key={bookmarkedPage._id}>
+      <li className="bookmark-item-list list-group-item list-group-item-action border-0 py-0 pl-3 d-flex align-items-center" id={bookmarkItemId}>
         { isRenameInputShown ? (
           <ClosableTextInput
             value={nodePath.basename(bookmarkedPage.path ?? '')}
@@ -107,7 +107,7 @@ const BookmarkItem = (props: Props): JSX.Element => {
             inputValidator={inputValidator}
           />
         ) : (
-          <a href={`/${bookmarkedPage._id}`} className="grw-bookmarks-title-anchor flex-grow-1">
+          <a href={`/${bookmarkedPage._id}`} className="grw-foldertree-title-anchor flex-grow-1 pr-3">
             <p className={`text-truncate m-auto ${bookmarkedPage.isEmpty && 'grw-sidebar-text-muted'}`}>{pageTitle}</p>
           </a>
         )}
@@ -130,7 +130,7 @@ const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          { formerPagePath }
+          { formerPagePath !== null ? `${formerPagePath}/` : '/' }
         </UncontrolledTooltip>
       </li>
     </div>

+ 53 - 0
packages/app/src/components/Sidebar/Bookmarks/DeleteBookmarkFolderModal.tsx

@@ -0,0 +1,53 @@
+
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody, ModalFooter, ModalHeader,
+} from 'reactstrap';
+
+import FolderIcon from '~/components/Icons/FolderIcon';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+
+type DeleteBookmarkFolderModalProps = {
+  isOpen: boolean
+  bookmarkFolder: BookmarkFolderItems
+  onClickDeleteButton: () => void
+  onModalClose: () => void
+}
+
+const DeleteBookmarkFolderModal = (props: DeleteBookmarkFolderModalProps): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isOpen, onClickDeleteButton, bookmarkFolder, onModalClose,
+  } = props;
+
+  return (
+    <Modal size="md" isOpen={isOpen} toggle={onModalClose} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={onModalClose} 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}/> {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 default DeleteBookmarkFolderModal;

+ 6 - 0
packages/app/src/components/Theme/ThemeIsland.global.scss

@@ -127,5 +127,11 @@ $color-themelight: rgba(183, 226, 219, 1);
         @include mixins.button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
       }
     }
+    // Foldertree
+    .grw-foldertree {
+      .grw-foldertree-triangle-btn {
+        @include mixins.button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
+      }
+    }
   }
 }

+ 7 - 0
packages/app/src/interfaces/bookmark-info.ts

@@ -23,3 +23,10 @@ export interface IBookmarkFolder {
   owner: Ref<IUser>
   parent?: Ref<this>
 }
+
+export interface BookmarkFolderItems {
+  _id: string
+  name: string
+  parent: string
+  children: this[]
+}

+ 16 - 12
packages/app/src/server/models/bookmark-folder.ts

@@ -3,7 +3,7 @@ import {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { IBookmarkFolder } from '~/interfaces/bookmark-info';
+import { IBookmarkFolder, BookmarkFolderItems } from '~/interfaces/bookmark-info';
 
 
 import loggerFactory from '../../utils/logger';
@@ -13,11 +13,6 @@ import { InvalidParentBookmarkFolderError } from './errors';
 
 const logger = loggerFactory('growi:models:bookmark-folder');
 
-export interface BookmarkFolderItems {
-  _id: string
-  name: string
-  children: this[]
-}
 export interface BookmarkFolderDocument extends Document {
   _id: Types.ObjectId
   name: string
@@ -29,7 +24,7 @@ export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
   createByParameters(params: IBookmarkFolder): BookmarkFolderDocument
   findFolderAndChildren(user: Types.ObjectId | string, parentId?: Types.ObjectId | string): BookmarkFolderItems[]
   findChildFolderById(parentBookmarkFolder: Types.ObjectId | string): Promise<BookmarkFolderDocument[]>
-  deleteFolderAndChildren(bookmarkFolderId: string): {deletedCount: number}
+  deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): {deletedCount: number}
   updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string): BookmarkFolderDocument | null
 }
 
@@ -37,6 +32,8 @@ const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderMo
   name: { type: String },
   owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   parent: { type: Schema.Types.ObjectId, ref: 'BookmarkFolder', required: false },
+}, {
+  toObject: { virtuals: true },
 });
 
 bookmarkFolderSchema.virtual('children', {
@@ -73,9 +70,11 @@ bookmarkFolderSchema.statics.findFolderAndChildren = async function(
     userId: Types.ObjectId | string,
     parentId?: Types.ObjectId | string,
 ): Promise<BookmarkFolderItems[]> {
+
   const parentFolder = await this.findById(parentId) as unknown as BookmarkFolderDocument;
-  const bookmarks = await this.find({ owner: userId, parent: parentFolder }).populate({ path: 'children' }).exec() as unknown as BookmarkFolderItems[];
-  return bookmarks;
+  const bookmarkFolders = await this.find({ owner: userId, parent: parentFolder })
+    .populate({ path: 'children' }).exec() as unknown as BookmarkFolderItems[];
+  return bookmarkFolders;
 };
 
 bookmarkFolderSchema.statics.findChildFolderById = async function(parentFolderId: Types.ObjectId | string): Promise<BookmarkFolderDocument[]> {
@@ -89,8 +88,14 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(boookmarkF
   const bookmarkFolder = await this.findByIdAndDelete(boookmarkFolderId);
   let deletedCount = 0;
   if (bookmarkFolder != null) {
-    const childFolders = await this.deleteMany({ parent: bookmarkFolder?.id });
-    deletedCount = childFolders.deletedCount + 1;
+    // Delete all child recursively and update deleted count
+    const childFolders = await this.find({ parent: bookmarkFolder });
+    await Promise.all(childFolders.map(async(child) => {
+      const deletedChildFolder = await this.deleteFolderAndChildren(child._id);
+      deletedCount += deletedChildFolder.deletedCount;
+    }));
+    const deletedChild = await this.deleteMany({ parent: bookmarkFolder });
+    deletedCount += deletedChild.deletedCount + 1;
   }
   return { deletedCount };
 };
@@ -107,6 +112,5 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
 
 };
 
-bookmarkFolderSchema.set('toObject', { virtuals: true });
 
 export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);

+ 11 - 14
packages/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -45,22 +45,19 @@ module.exports = (crowi) => {
     }
   });
 
-  // List all main bookmark folders
-  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    try {
-      const bookmarkFolders = await BookmarkFolder.findFolderAndChildren(req.user?._id);
-      return res.apiv3({ bookmarkFolders });
-    }
-    catch (err) {
-      return res.apiv3Err(err, 500);
-    }
-  });
-
-  router.get('/list-child/:parentId', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  // List bookmark folders and child
+  router.get('/list/:parentId?', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     const { parentId } = req.params;
+    const _parentId = parentId ?? null;
     try {
-      const bookmarkFolders = await BookmarkFolder.findChildFolderById(parentId);
-      return res.apiv3({ bookmarkFolders });
+      const bookmarkFolders = await BookmarkFolder.findFolderAndChildren(req.user?._id, _parentId);
+      const bookmarkFolderItems = bookmarkFolders.map(bookmarkFolder => ({
+        _id: bookmarkFolder._id,
+        name: bookmarkFolder.name,
+        parent: bookmarkFolder.parent,
+        children: bookmarkFolder.children,
+      }));
+      return res.apiv3({ bookmarkFolderItems });
     }
     catch (err) {
       return res.apiv3Err(err, 500);

+ 16 - 0
packages/app/src/stores/bookmark-folder.ts

@@ -0,0 +1,16 @@
+import { Nullable } from '@growi/core';
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+
+export const useSWRxBookamrkFolderAndChild = (parentId?: Nullable<string>): SWRResponse<BookmarkFolderItems[], Error> => {
+  const _parentId = parentId == null ? '' : parentId;
+  return useSWRImmutable(
+    `/bookmark-folder/list/${_parentId}`,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data.bookmarkFolderItems;
+    }),
+  );
+};

+ 27 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -316,6 +316,33 @@ ul.pagination {
     }
   }
 
+  // Foldertree
+  .grw-foldertree {
+    @include override-list-group-item-for-pagetree(
+      $color-sidebar-context,
+      lighten($bgcolor-sidebar-context, 8%),
+      lighten($bgcolor-sidebar-context, 15%),
+      darken($color-sidebar-context, 15%),
+      darken($color-sidebar-context, 10%),
+      lighten($bgcolor-sidebar-context, 18%),
+      lighten($bgcolor-sidebar-context, 24%)
+    );
+    .grw-foldertree-triangle-btn {
+      @include mixins.button-outline-svg-icon-variant($secondary, $gray-200);
+    }
+    .btn-page-item-control {
+      @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
+      @include hover() {
+        background-color: lighten($bgcolor-sidebar-context, 20%);
+      }
+      &:not(:disabled):not(.disabled):active,
+      &:not(:disabled):not(.disabled).active {
+        background-color: lighten($bgcolor-sidebar-context, 34%);
+      }
+      box-shadow: none !important;
+    }
+  }
+
   // bookmarks
   .grw-bookmarks-list {
     @include override-list-group-item-for-pagetree(

+ 16 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -205,6 +205,22 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
     }
   }
 
+  // Foldertree
+  .grw-foldertree {
+    @include override-list-group-item-for-pagetree(
+      $color-sidebar-context,
+      darken($bgcolor-sidebar-context, 5%),
+      darken($bgcolor-sidebar-context, 12%),
+      lighten($color-sidebar-context, 10%),
+      lighten($color-sidebar-context, 8%),
+      darken($bgcolor-sidebar-context, 15%),
+      darken($bgcolor-sidebar-context, 24%)
+    );
+    .grw-foldertree-triangle-btn {
+      @include mixins.button-outline-svg-icon-variant($gray-400, $primary);
+    }
+  }
+
   // bookmark
   .grw-bookmarks-list {
     @include override-list-group-item-for-pagetree(

+ 9 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -292,6 +292,15 @@ ul.pagination {
       }
     }
   }
+
+  .grw-foldertree {
+    .list-group-item {
+      .grw-foldertree-title-anchor {
+        color: inherit;
+      }
+    }
+  }
+
   .grw-pagetree-footer {
     .h5.grw-private-legacy-pages-anchor {
       color: inherit;

+ 4 - 2
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -49,7 +49,8 @@
       }
     }
 
-    &.grw-pagetree-current-page-item {
+    &.grw-pagetree-current-page-item,
+    &.grw-foldertree-current-folder-item {
       background: $bgcolor-hover;
     }
 
@@ -61,7 +62,8 @@
         background-color: $bgcolor-active;
       }
     }
-    .grw-pagetree-title-anchor {
+    .grw-pagetree-title-anchor,
+    .grw-foldertree-title-anchor {
       .grw-sidebar-text-muted {
         color: rgba(desaturate($color, 50%), 0.6);
       }