2
0
Эх сурвалжийг харах

Merge pull request #7165 from weseek/feat/gw7910-add-bookmark-from-sub-navigation

feat :gw7910 add bookmark from sub navigation
Ryoji Shimizu 3 жил өмнө
parent
commit
8ed57a1163

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

@@ -14,7 +14,7 @@
     "create_failed": "Failed to create {{target}}",
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
     "update_failed": "Failed to update {{target}}",
-
+    "delete_succeeded": "Succeeded to delete {{target}}",
     "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"
     "remove_share_link": "Succeeded to remove {{count}} share links"
   },
   },

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

@@ -14,7 +14,7 @@
     "create_failed": "{{target}}の作成に失敗しました",
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_successed": "{{target}}を更新しました",
     "update_failed": "{{target}}の更新に失敗しました",
     "update_failed": "{{target}}の更新に失敗しました",
-
+    "delete_succeeded": "{{target}} の削除に成功しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました"
     "remove_share_link": "共有リンクを{{count}}件削除しました"
   },
   },

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

@@ -14,7 +14,7 @@
     "create_failed": "Failed to create {{target}}",
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
     "update_failed": "Failed to update {{target}}",
-
+    "delete_succeeded": "Succeeded to delete {{target}}",
     "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"
     "remove_share_link": "Succeeded to remove {{count}} share links"
   },
   },

+ 29 - 28
packages/app/src/components/BookmarkButtons.tsx

@@ -1,29 +1,34 @@
-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 { useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 
 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;
 }
 }
 
 
 const BookmarkButtons: FC<Props> = (props: Props) => {
 const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-
+  const { data } = useSWRxCurrentPage();
+  const { data: bookmarkInfo } = useSWRBookmarkInfo(data?._id);
   const {
   const {
-    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+    bookmarkedUsers, hideTotalNumber,
   } = props;
   } = props;
 
 
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -34,33 +39,29 @@ 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) {
+    if (isGuestUser) {
+      return 'Not available for guest';
+    }
+
+    if (bookmarkInfo?.isBookmarked) {
       return 'tooltip.cancel_bookmark';
       return 'tooltip.cancel_bookmark';
     }
     }
     return 'tooltip.bookmark';
     return 'tooltip.bookmark';
-  }, [isBookmarked]);
+  }, [isGuestUser, bookmarkInfo]);
+
 
 
   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 +71,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">

+ 57 - 6
packages/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -5,16 +5,22 @@ import {
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { DropdownToggle } from 'reactstrap';
 import { DropdownToggle } from 'reactstrap';
 
 
-import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import CountBadge from '~/components/Common/CountBadge';
 import CountBadge from '~/components/Common/CountBadge';
 import FolderIcon from '~/components/Icons/FolderIcon';
 import FolderIcon from '~/components/Icons/FolderIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 
 import BookmarkFolderItemControl from './BookmarkFolderItemControl';
 import BookmarkFolderItemControl from './BookmarkFolderItemControl';
 import BookmarkFolderNameInput from './BookmarkFolderNameInput';
 import BookmarkFolderNameInput from './BookmarkFolderNameInput';
+import BookmarkItem from './BookmarkItem';
 import DeleteBookmarkFolderModal from './DeleteBookmarkFolderModal';
 import DeleteBookmarkFolderModal from './DeleteBookmarkFolderModal';
 
 
 
 
@@ -27,7 +33,7 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
-    name, _id: folderId, children, parent,
+    name, _id: folderId, children, parent, bookmarks,
   } = bookmarkFolder;
   } = bookmarkFolder;
   const [currentChildren, setCurrentChildren] = useState<BookmarkFolderItems[]>();
   const [currentChildren, setCurrentChildren] = useState<BookmarkFolderItems[]>();
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -37,6 +43,9 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
   const [isDeleteFolderModalShown, setIsDeleteFolderModalShown] = useState<boolean>(false);
   const [isDeleteFolderModalShown, setIsDeleteFolderModalShown] = useState<boolean>(false);
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { open: openDeleteModal } = usePageDeleteModal();
 
 
   const childCount = useMemo((): number => {
   const childCount = useMemo((): number => {
     if (currentChildren != null && currentChildren.length > children.length) {
     if (currentChildren != null && currentChildren.length > children.length) {
@@ -84,7 +93,7 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
       await apiv3Put('/bookmark-folder', { bookmarkFolderId: folderId, name: folderName, parent });
       await apiv3Put('/bookmark-folder', { bookmarkFolderId: folderId, name: folderName, parent });
       loadParent();
       loadParent();
       setIsRenameAction(false);
       setIsRenameAction(false);
-      toastSuccess(t('toaster.update_successed', { target: t('bookmark_folder.bookmark_folder') }));
+      toastSuccess(t('toaster.update_successed', { target: t('bookmark_folder.bookmark_folder'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -98,7 +107,7 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
       setIsOpen(true);
       setIsOpen(true);
       setIsCreateAction(false);
       setIsCreateAction(false);
       mutateChildBookmarkData();
       mutateChildBookmarkData();
-      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder'), ns: 'commons' }));
 
 
     }
     }
     catch (err) {
     catch (err) {
@@ -113,12 +122,13 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
       await apiv3Delete(`/bookmark-folder/${folderId}`);
       await apiv3Delete(`/bookmark-folder/${folderId}`);
       setIsDeleteFolderModalShown(false);
       setIsDeleteFolderModalShown(false);
       loadParent();
       loadParent();
-      toastSuccess(t('toaster.delete_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+      mutateBookmarkInfo();
+      toastSuccess(t('toaster.delete_succeeded', { target: t('bookmark_folder.bookmark_folder'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [folderId, loadParent, t]);
+  }, [folderId, loadParent, mutateBookmarkInfo, t]);
 
 
   const onClickPlusButton = useCallback(async(e) => {
   const onClickPlusButton = useCallback(async(e) => {
     e.stopPropagation();
     e.stopPropagation();
@@ -128,6 +138,30 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
     setIsCreateAction(true);
     setIsCreateAction(true);
   }, [hasChildren, isOpen]);
   }, [hasChildren, isOpen]);
 
 
+  const onClickDeleteBookmarkHandler = 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 }));
+      }
+      mutateParentBookmarkFolder();
+      mutateBookmarkInfo();
+    };
+    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+  }, [mutateBookmarkInfo, mutateParentBookmarkFolder, openDeleteModal, t]);
+
+  const onUnbookmarkHandler = useCallback(() => {
+    mutateParentBookmarkFolder();
+    mutateBookmarkInfo();
+  }, [mutateBookmarkInfo, mutateParentBookmarkFolder]);
+
   const renderChildFolder = () => {
   const renderChildFolder = () => {
     return isOpen && currentChildren?.map((childFolder) => {
     return isOpen && currentChildren?.map((childFolder) => {
       return (
       return (
@@ -141,6 +175,20 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
     });
     });
   };
   };
 
 
+  const renderBookmarkItem = () => {
+    return isOpen && bookmarks?.map((bookmark) => {
+      return (
+        <BookmarkItem
+          bookmarkedPage={bookmark.page}
+          key={bookmark._id}
+          onUnbookmarked={onUnbookmarkHandler}
+          onRenamed={mutateParentBookmarkFolder}
+          onClickDeleteMenuItem={onClickDeleteBookmarkHandler}
+        />
+      );
+    });
+  };
+
   const onClickRenameHandler = useCallback(() => {
   const onClickRenameHandler = useCallback(() => {
     setIsRenameAction(true);
     setIsRenameAction(true);
   }, []);
   }, []);
@@ -228,6 +276,9 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
       {
       {
         renderChildFolder()
         renderChildFolder()
       }
       }
+      {
+        renderBookmarkItem()
+      }
       <DeleteBookmarkFolderModal
       <DeleteBookmarkFolderModal
         bookmarkFolder={bookmarkFolder}
         bookmarkFolder={bookmarkFolder}
         isOpen={isDeleteFolderModalShown}
         isOpen={isDeleteFolderModalShown}

+ 149 - 0
packages/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -0,0 +1,149 @@
+import React, {
+  useCallback, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  DropdownItem, DropdownMenu, UncontrolledDropdown,
+} from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRBookmarkInfo } from '~/stores/bookmark';
+import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+import FolderIcon from '../Icons/FolderIcon';
+
+import BookmarkFolderMenuItem from './BookmarkFolderMenuItem';
+import BookmarkFolderNameInput from './BookmarkFolderNameInput';
+
+
+type Props = {
+  children?: React.ReactNode
+}
+
+const BookmarkFolderMenu = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { children } = props;
+  const [isCreateAction, setIsCreateAction] = useState(false);
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolderData } = useSWRxBookamrkFolderAndChild();
+  const [selectedItem, setSelectedItem] = useState<string | null>(null);
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+
+
+  const onClickNewBookmarkFolder = useCallback(() => {
+    setIsCreateAction(true);
+  }, []);
+
+  const toggleHandler = useCallback(() => {
+    mutateBookmarkFolderData();
+    bookmarkFolders?.forEach((bookmarkFolder) => {
+      bookmarkFolder.bookmarks.forEach((bookmark) => {
+        if (bookmark.page._id === currentPage?._id) {
+          setSelectedItem(bookmarkFolder._id);
+        }
+      });
+    });
+  }, [bookmarkFolders, currentPage?._id, mutateBookmarkFolderData]);
+
+  const isBookmarkFolderExists = useCallback((): boolean => {
+    if (bookmarkFolders && bookmarkFolders.length > 0) {
+      return true;
+    }
+    return false;
+  }, [bookmarkFolders]);
+
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
+      await mutateBookmarkFolderData();
+      setIsCreateAction(false);
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateBookmarkFolderData, t]);
+
+  const onMenuItemClickHandler = useCallback(async(itemId: string) => {
+    try {
+      await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId: currentPage?._id, folderId: itemId });
+
+      mutateBookmarkInfo();
+      toastSuccess('Bookmark added to bookmark folder successfully');
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    mutateBookmarkFolderData();
+    setSelectedItem(itemId);
+  }, [currentPage?._id, mutateBookmarkFolderData, mutateBookmarkInfo]);
+
+  const renderBookmarkMenuItem = useCallback(() => {
+    return (
+      <>
+        { isCreateAction ? (
+          <div className='mx-2'>
+            <BookmarkFolderNameInput
+              onClickOutside={() => setIsCreateAction(false)}
+              onPressEnter={onPressEnterHandlerForCreate}
+            />
+          </div>
+        ) : (
+          <DropdownItem toggle={false} onClick={onClickNewBookmarkFolder} className='grw-bookmark-folder-menu-item'>
+            <FolderIcon isOpen={false}/>
+            <span className="mx-2 ">{t('bookmark_folder.new_folder')}</span>
+          </DropdownItem>
+        )}
+        { isBookmarkFolderExists() && (
+          <>
+            <DropdownItem divider />
+            {bookmarkFolders?.map(folder => (
+              <div key={folder._id} >
+                {
+                  <div className='dropdown-item grw-bookmark-folder-menu-item' tabIndex={0} role="menuitem" onClick={() => onMenuItemClickHandler(folder._id)}>
+                    <BookmarkFolderMenuItem
+                      item={folder}
+                      isSelected={selectedItem === folder._id}
+                      onSelectedChild={() => setSelectedItem(null)}
+                    />
+                  </div>
+                }
+              </div>
+            ))}
+          </>
+        )}
+      </>
+    );
+  }, [bookmarkFolders,
+      isBookmarkFolderExists,
+      isCreateAction,
+      onClickNewBookmarkFolder,
+      onMenuItemClickHandler,
+      onPressEnterHandlerForCreate,
+      t,
+      selectedItem,
+  ]);
+
+  return (
+    <UncontrolledDropdown
+      onToggle={toggleHandler}
+      direction={ isBookmarkFolderExists() ? 'up' : 'down' }
+      className='grw-bookmark-folder-dropdown'>
+      {children}
+      <DropdownMenu
+        right
+        className='grw-bookmark-folder-menu'
+        positionFixed
+      >
+        { renderBookmarkMenuItem() }
+      </DropdownMenu>
+    </UncontrolledDropdown>
+  );
+};
+
+export default BookmarkFolderMenu;

+ 180 - 0
packages/app/src/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -0,0 +1,180 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  DropdownItem,
+  DropdownMenu, DropdownToggle, UncontrolledDropdown,
+} from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { useSWRBookmarkInfo } from '~/stores/bookmark';
+import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+import FolderIcon from '../Icons/FolderIcon';
+import TriangleIcon from '../Icons/TriangleIcon';
+
+import BookmarkFolderNameInput from './BookmarkFolderNameInput';
+
+
+type Props ={
+  item: BookmarkFolderItems
+  onSelectedChild: () => void
+  isSelected: boolean
+}
+const BookmarkFolderMenuItem = (props: Props):JSX.Element => {
+  const {
+    item, isSelected, onSelectedChild,
+  } = props;
+  const { t } = useTranslation();
+  const [isOpen, setIsOpen] = useState(false);
+  const [currentChildFolders, setCurrentChildFolders] = useState<BookmarkFolderItems[]>();
+  const { data: childFolders, mutate: mutateChildFolders } = useSWRxBookamrkFolderAndChild(item._id);
+  const { mutate: mutateParentFolders } = useSWRxBookamrkFolderAndChild(item.parent);
+  const [selectedItem, setSelectedItem] = useState<string | null>(null);
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: item._id });
+      await mutateChildFolders();
+      setIsCreateAction(false);
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [item, mutateChildFolders, t]);
+
+
+  useEffect(() => {
+    if (isOpen && childFolders != null) {
+      mutateChildFolders();
+      setCurrentChildFolders(childFolders);
+    }
+    currentChildFolders?.forEach((bookmarkFolder) => {
+      bookmarkFolder.bookmarks.forEach((bookmark) => {
+        if (bookmark.page._id === currentPage?._id) {
+          setSelectedItem(bookmarkFolder._id);
+        }
+      });
+    });
+
+  }, [childFolders, currentChildFolders, currentPage?._id, isOpen, item, mutateChildFolders, mutateParentFolders]);
+
+  const onClickNewBookmarkFolder = useCallback((e) => {
+    e.stopPropagation();
+    setIsCreateAction(true);
+  }, []);
+
+  const onMouseLeaveHandler = useCallback(() => {
+    setIsOpen(false);
+    setIsCreateAction(false);
+  }, []);
+
+  const onMouseEnterHandler = useCallback(() => {
+    setIsOpen(true);
+  }, []);
+
+  const toggleHandler = useCallback(() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+
+  const onClickChildMenuItemHandler = useCallback(async(e, item) => {
+    e.stopPropagation();
+    mutateBookmarkInfo();
+    onSelectedChild();
+    try {
+      await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId: currentPage?._id, folderId: item._id });
+      toastSuccess('Bookmark added to bookmark folder successfully');
+      mutateParentFolders();
+      mutateChildFolders();
+      setSelectedItem(item._id);
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+
+  }, [mutateBookmarkInfo, onSelectedChild, currentPage?._id, mutateParentFolders, mutateChildFolders]);
+
+  const renderBookmarkSubMenuItem = useCallback(() => {
+    return (
+      <>
+        { childFolders != null && (
+          <DropdownMenu className='m-0'>
+            { isCreateAction ? (
+              <div className='mx-2' onClick={e => e.stopPropagation()}>
+                <BookmarkFolderNameInput
+                  onClickOutside={() => setIsCreateAction(false)}
+                  onPressEnter={onPressEnterHandlerForCreate}
+                />
+              </div>
+            ) : (
+              <DropdownItem toggle={false} onClick={e => onClickNewBookmarkFolder(e) } className='grw-bookmark-folder-menu-item'>
+                <FolderIcon isOpen={false}/>
+                <span className="mx-2 ">{t('bookmark_folder.new_folder')}</span>
+              </DropdownItem>
+            )}
+
+            { currentChildFolders && currentChildFolders?.length > 0 && (<DropdownItem divider />)}
+
+            {currentChildFolders?.map(child => (
+              <div key={child._id} >
+
+                <div
+                  className='dropdown-item grw-bookmark-folder-menu-item'
+                  tabIndex={0} role="menuitem"
+                  onClick={e => onClickChildMenuItemHandler(e, child)}>
+                  <BookmarkFolderMenuItem
+                    onSelectedChild={() => setSelectedItem(null)}
+                    item={child}
+                    isSelected={selectedItem === child._id}
+                  />
+                </div>
+              </div>
+            ))}
+          </DropdownMenu>
+        )}
+      </>
+    );
+  }, [childFolders, currentChildFolders, isCreateAction, onClickChildMenuItemHandler, onClickNewBookmarkFolder, onPressEnterHandlerForCreate, selectedItem, t]);
+
+  return (
+    <UncontrolledDropdown
+      direction="right"
+      className='d-flex justify-content-between'
+      isOpen={isOpen}
+      toggle={toggleHandler}
+      onMouseLeave={onMouseLeaveHandler}
+    >
+      <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-${item._id}`}
+          onChange={e => e.stopPropagation()}
+          onClick={e => e.stopPropagation()}
+        />
+        <label htmlFor={`bookmark-folder-menu-item-${item._id}`} className='p-2 m-0'>
+          {item.name}
+        </label>
+      </div>
+      <DropdownToggle
+        color="transparent"
+        onClick={e => e.stopPropagation()}
+        onMouseEnter={onMouseEnterHandler}
+      >
+        { childFolders && childFolders?.length > 0 && <TriangleIcon/> }
+      </DropdownToggle>
+      { renderBookmarkSubMenuItem() }
+    </UncontrolledDropdown >
+  );
+};
+export default BookmarkFolderMenuItem;

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

@@ -1,6 +1,4 @@
 
 
-import { useTranslation } from 'next-i18next';
-
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 
 
 import BookmarkFolderItem from './BookmarkFolderItem';
 import BookmarkFolderItem from './BookmarkFolderItem';
@@ -9,7 +7,6 @@ import styles from './BookmarkFolderTree.module.scss';
 
 
 
 
 const BookmarkFolderTree = (): JSX.Element => {
 const BookmarkFolderTree = (): JSX.Element => {
-  const { t } = useTranslation();
   const { data: bookmarkFolderData } = useSWRxBookamrkFolderAndChild();
   const { data: bookmarkFolderData } = useSWRxBookamrkFolderAndChild();
 
 
   return (
   return (

+ 2 - 2
packages/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -7,8 +7,8 @@ import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 
 import { unbookmark } from '~/client/services/page-operation';
 import { unbookmark } from '~/client/services/page-operation';
-import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
 import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
 
 
 
 
@@ -96,7 +96,7 @@ const BookmarkItem = (props: Props): JSX.Element => {
 
 
   return (
   return (
     <div className="grw-foldertree-item-container" key={bookmarkedPage._id}>
     <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}>
+      <li className="bookmark-item-list list-group-item list-group-item-action border-0 py-0 pl-5 d-flex align-items-center" id={bookmarkItemId}>
         { isRenameInputShown ? (
         { isRenameInputShown ? (
           <ClosableTextInput
           <ClosableTextInput
             value={nodePath.basename(bookmarkedPage.path ?? '')}
             value={nodePath.basename(bookmarkedPage.path ?? '')}

+ 1 - 1
packages/app/src/components/Bookmarks/BookmarkItemList.tsx

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { toastSuccess } from '~/client/util/apiNotification';
+import { toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';

+ 0 - 16
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -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 == null || isGuestUser) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo)) {
-      return;
-    }
-
-    await toggleBookmark(pageId, pageInfo.isBookmarked);
-    mutatePageInfo();
-    mutateBookmarkInfo();
-    mutateCurrentUserBookmark();
-  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, mutateCurrentUserBookmark, 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) {
@@ -242,10 +229,7 @@ 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}
         />
         />
       )}
       )}
       {revisionId != null && !isCompactMode && (
       {revisionId != null && !isCompactMode && (

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

@@ -2,11 +2,10 @@ import React, { useCallback, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import BookmarkFolderNameInput from '~/components/Bookmarks/BookmarkFolderNameInput';
 import BookmarkFolderNameInput from '~/components/Bookmarks/BookmarkFolderNameInput';
 import BookmarkFolderTree from '~/components/Bookmarks/BookmarkFolderTree';
 import BookmarkFolderTree from '~/components/Bookmarks/BookmarkFolderTree';
-import BookmarkItemList from '~/components/Bookmarks/BookmarkItemList';
 import FolderPlusIcon from '~/components/Icons/FolderPlusIcon';
 import FolderPlusIcon from '~/components/Icons/FolderPlusIcon';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 
 
@@ -28,7 +27,7 @@ const BookmarkContents = (): JSX.Element => {
       await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
       await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
       await mutateChildBookmarkData();
       await mutateChildBookmarkData();
       setIsCreateAction(false);
       setIsCreateAction(false);
-      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -66,7 +65,6 @@ const BookmarkContents = (): JSX.Element => {
         renderAddNewBookmarkFolder()
         renderAddNewBookmarkFolder()
       }
       }
       <BookmarkFolderTree />
       <BookmarkFolderTree />
-      <BookmarkItemList />
     </>
     </>
   );
   );
 };
 };

+ 3 - 1
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -18,7 +18,7 @@ import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import {
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
-import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -118,6 +118,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
   const { mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id);
 
 
   // descendantCount
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
   const { getDescCount } = usePageTreeDescCountMap();
@@ -249,6 +250,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
     await bookmarkOperation(_pageId);
     mutateCurrentUserBookmarks();
     mutateCurrentUserBookmarks();
+    mutateBookmarkInfo();
   };
   };
 
 
   const duplicateMenuItemClickHandler = useCallback((): void => {
   const duplicateMenuItemClickHandler = useCallback((): void => {

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

@@ -29,4 +29,5 @@ export interface BookmarkFolderItems {
   name: string
   name: string
   parent: string
   parent: string
   children: this[]
   children: this[]
+  bookmarks: BookmarkedPage[]
 }
 }

+ 77 - 26
packages/app/src/server/models/bookmark-folder.ts

@@ -1,37 +1,49 @@
 import { isValidObjectId } from '@growi/core/src/utils/objectid-utils';
 import { isValidObjectId } from '@growi/core/src/utils/objectid-utils';
-import {
+import monggoose, {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
-import { IBookmarkFolder, BookmarkFolderItems } from '~/interfaces/bookmark-info';
 
 
+import { IBookmarkFolder, BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { IPageHasId } from '~/interfaces/page';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 import { InvalidParentBookmarkFolderError } from './errors';
 import { InvalidParentBookmarkFolderError } from './errors';
 
 
+
 const logger = loggerFactory('growi:models:bookmark-folder');
 const logger = loggerFactory('growi:models:bookmark-folder');
+const Bookmark = monggoose.model('Bookmark');
+
 
 
 export interface BookmarkFolderDocument extends Document {
 export interface BookmarkFolderDocument extends Document {
   _id: Types.ObjectId
   _id: Types.ObjectId
   name: string
   name: string
   owner: Types.ObjectId
   owner: Types.ObjectId
-  parent?: [this]
+  parent?: this[]
+  bookmarks?: Types.ObjectId[]
 }
 }
 
 
 export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
 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: Types.ObjectId | string): {deletedCount: number}
-  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string): BookmarkFolderDocument | null
+  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): Promise<BookmarkFolderDocument>
+  insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string): Promise<BookmarkFolderDocument>
 }
 }
 
 
 const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderModel>({
 const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderModel>({
   name: { type: String },
   name: { type: String },
   owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   parent: { type: Schema.Types.ObjectId, ref: 'BookmarkFolder', required: false },
   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 },
   toObject: { virtuals: true },
 });
 });
@@ -44,10 +56,10 @@ bookmarkFolderSchema.virtual('children', {
 
 
 bookmarkFolderSchema.statics.createByParameters = async function(params: IBookmarkFolder): Promise<BookmarkFolderDocument> {
 bookmarkFolderSchema.statics.createByParameters = async function(params: IBookmarkFolder): Promise<BookmarkFolderDocument> {
   const { name, owner, parent } = params;
   const { name, owner, parent } = params;
-  let bookmarkFolder;
+  let bookmarkFolder: BookmarkFolderDocument;
 
 
   if (parent == null) {
   if (parent == null) {
-    bookmarkFolder = await this.create({ name, owner }) as unknown as BookmarkFolderDocument;
+    bookmarkFolder = await this.create({ name, owner });
   }
   }
   else {
   else {
     // Check if parent folder id is valid and parent folder exists
     // Check if parent folder id is valid and parent folder exists
@@ -60,7 +72,7 @@ bookmarkFolderSchema.statics.createByParameters = async function(params: IBookma
     if (parentFolder == null) {
     if (parentFolder == null) {
       throw new InvalidParentBookmarkFolderError('Parent folder not found');
       throw new InvalidParentBookmarkFolderError('Parent folder not found');
     }
     }
-    bookmarkFolder = await this.create({ name, owner, parent:  parentFolder._id }) as unknown as BookmarkFolderDocument;
+    bookmarkFolder = await this.create({ name, owner, parent:  parentFolder._id });
   }
   }
 
 
   return bookmarkFolder;
   return bookmarkFolder;
@@ -71,23 +83,45 @@ bookmarkFolderSchema.statics.findFolderAndChildren = async function(
     parentId?: Types.ObjectId | string,
     parentId?: Types.ObjectId | string,
 ): Promise<BookmarkFolderItems[]> {
 ): Promise<BookmarkFolderItems[]> {
 
 
-  const parentFolder = await this.findById(parentId) as unknown as BookmarkFolderDocument;
-  const bookmarkFolders = await this.find({ owner: userId, parent: parentFolder })
-    .populate({ path: 'children' }).exec() as unknown as BookmarkFolderItems[];
+  let parentFolder: BookmarkFolderDocument | null;
+  let query = {};
+  // Load child bookmark folders
+  if (parentId != null) {
+    parentFolder = await this.findById(parentId);
+    if (parentFolder != null) {
+      query = { owner: userId, parent: parentFolder };
+    }
+    else {
+      throw new InvalidParentBookmarkFolderError('Parent folder not found');
+    }
+  }
+  // Load initial / root bookmark folders
+  else {
+    query = { owner: userId, parent: null };
+  }
+  const bookmarkFolders: BookmarkFolderItems[] = await this.find(query)
+    .populate({ path: 'children' })
+    .populate({
+      path: 'bookmarks',
+      model: 'Bookmark',
+      populate: {
+        path: 'page',
+        model: 'Page',
+      },
+    });
   return bookmarkFolders;
   return bookmarkFolders;
 };
 };
 
 
-bookmarkFolderSchema.statics.findChildFolderById = async function(parentFolderId: Types.ObjectId | string): Promise<BookmarkFolderDocument[]> {
-  const parentFolder = await this.findById(parentFolderId) as unknown as BookmarkFolderDocument;
-  const childFolders = await this.find({ parent: parentFolder });
-  return childFolders;
-};
-
-bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(boookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}> {
+bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}> {
+  const bookmarkFolder = await this.findById(bookmarkFolderId);
   // Delete parent and all children folder
   // Delete parent and all children folder
-  const bookmarkFolder = await this.findByIdAndDelete(boookmarkFolderId);
   let deletedCount = 0;
   let deletedCount = 0;
   if (bookmarkFolder != null) {
   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
     // Delete all child recursively and update deleted count
     const childFolders = await this.find({ parent: bookmarkFolder });
     const childFolders = await this.find({ parent: bookmarkFolder });
     await Promise.all(childFolders.map(async(child) => {
     await Promise.all(childFolders.map(async(child) => {
@@ -96,21 +130,38 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(boookmarkF
     }));
     }));
     const deletedChild = await this.deleteMany({ parent: bookmarkFolder });
     const deletedChild = await this.deleteMany({ parent: bookmarkFolder });
     deletedCount += deletedChild.deletedCount + 1;
     deletedCount += deletedChild.deletedCount + 1;
+    bookmarkFolder.delete();
   }
   }
   return { deletedCount };
   return { deletedCount };
 };
 };
 
 
-bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolderId: string, name: string, parent: string):
- Promise<BookmarkFolderDocument | null> {
-
-  const parentFolder = await this.findById(parent);
+bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolderId: string, name: string, parentId: string):
+ Promise<BookmarkFolderDocument> {
+  const parentFolder = await this.findById(parentId);
   const updateFields = {
   const updateFields = {
     name, parent: parentFolder?._id || null,
     name, parent: parentFolder?._id || null,
   };
   };
   const bookmarkFolder = await this.findByIdAndUpdate(bookmarkFolderId, { $set: updateFields }, { new: true });
   const bookmarkFolder = await this.findByIdAndUpdate(bookmarkFolderId, { $set: updateFields }, { new: true });
+  if (bookmarkFolder == null) {
+    throw new Error('Update bookmark folder failed');
+  }
   return bookmarkFolder;
   return bookmarkFolder;
 
 
 };
 };
 
 
+bookmarkFolderSchema.statics.insertOrUpdateBookmarkedPage = async function(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string):
+Promise<BookmarkFolderDocument> {
+
+  // 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
+  const bookmarkFolder = await this.findByIdAndUpdate(folderId, { $addToSet: { bookmarks: bookmarkedPage } }, { new: true, upsert: true });
+  return bookmarkFolder;
+};
+
 
 
 export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);
 export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);

+ 21 - 0
packages/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -18,6 +18,10 @@ const validator = {
     body('name').isString().withMessage('name must be a string'),
     body('name').isString().withMessage('name must be a string'),
     body('parent').isMongoId().optional({ nullable: true }),
     body('parent').isMongoId().optional({ nullable: true }),
   ],
   ],
+  bookmarkPage: [
+    body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
+    body('folderId').isMongoId().withMessage('Folder ID must be a valid mongo ID'),
+  ],
 };
 };
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
@@ -56,6 +60,7 @@ module.exports = (crowi) => {
         name: bookmarkFolder.name,
         name: bookmarkFolder.name,
         parent: bookmarkFolder.parent,
         parent: bookmarkFolder.parent,
         children: bookmarkFolder.children,
         children: bookmarkFolder.children,
+        bookmarks: bookmarkFolder.bookmarks,
       }));
       }));
       return res.apiv3({ bookmarkFolderItems });
       return res.apiv3({ bookmarkFolderItems });
     }
     }
@@ -88,5 +93,21 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
       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);
+    }
+  });
+
+
   return router;
   return router;
 };
 };

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

@@ -391,6 +391,23 @@ ul.pagination {
   }
   }
 }
 }
 
 
+// Bookmark dropdown menu
+.grw-bookmark-folder-dropdown  {
+  .grw-bookmark-folder-menu {
+    .form-control{
+      &:focus {
+        color: $body-color
+      }
+    }
+    .grw-bookmark-folder-menu-item  {
+      @include mixins.button-outline-svg-icon-variant($secondary, $gray-200);
+      .grw-bookmark-folder-menu-item-title {
+        color: $body-color
+      }
+    }
+  }
+}
+
 .btn.btn-page-item-control {
 .btn.btn-page-item-control {
   @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
   @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
   @include hover() {
   @include hover() {

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

@@ -269,6 +269,23 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
   }
 }
 }
 
 
+// Bookmark dropdown menu
+.grw-bookmark-folder-dropdown  {
+  .grw-bookmark-folder-menu {
+    .form-control{
+      &:focus {
+        color: $body-color
+      }
+    }
+    .grw-bookmark-folder-menu-item {
+      @include mixins.button-outline-svg-icon-variant($gray-400, $primary);
+      .grw-bookmark-folder-menu-item-title {
+        color: $body-color
+      }
+    }
+  }
+}
+
 .btn.btn-page-item-control {
 .btn.btn-page-item-control {
   @include button-outline-variant($gray-500, $primary, lighten($primary, 52%), transparent);
   @include button-outline-variant($gray-500, $primary, lighten($primary, 52%), transparent);
   @include hover() {
   @include hover() {

+ 1 - 1
packages/preset-themes/src/styles/island.scss

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