From 671a47bd5418a99caae9b7ffffcc3ed28c2e645e Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 20 Dec 2023 09:43:46 +0500 Subject: [PATCH] web: add support for reordering navigation menu items --- apps/web/src/common/db.ts | 8 +- .../src/components/navigation-menu/index.tsx | 363 +++++++++++++----- .../navigation-menu/navigation-item.tsx | 57 ++- packages/core/src/collections/settings.ts | 20 +- packages/core/src/types.ts | 8 +- 5 files changed, 331 insertions(+), 125 deletions(-) diff --git a/apps/web/src/common/db.ts b/apps/web/src/common/db.ts index 1ecf6d8c9..079986624 100644 --- a/apps/web/src/common/db.ts +++ b/apps/web/src/common/db.ts @@ -20,25 +20,21 @@ along with this program. If not, see . import { EventSourcePolyfill as EventSource } from "event-source-polyfill"; import { DatabasePersistence, NNStorage } from "../interfaces/storage"; import { logger } from "../utils/logger"; -import type Database from "@notesnook/core/dist/api"; import { showMigrationDialog } from "./dialog-controller"; // import { SQLocalKysely } from "sqlocal/kysely"; import { WaSqliteWorkerDriver } from "./sqlite/sqlite.kysely"; import { SqliteAdapter, SqliteQueryCompiler, SqliteIntrospector } from "kysely"; +import { database } from "@notesnook/common"; // import SQLiteESMFactory from "./sqlite/wa-sqlite-async"; // import * as SQLite from "./sqlite/sqlite-api"; // import { IDBBatchAtomicVFS } from "./sqlite/IDBBatchAtomicVFS"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -let db: Database = {}; +const db = database; async function initializeDatabase(persistence: DatabasePersistence) { logger.measure("Database initialization"); - const { database } = await import("@notesnook/common"); const { FileStorage } = await import("../interfaces/fs"); const { Compressor } = await import("../utils/compressor"); - db = database; // // const ss = wrap(new Worker()); // // await ss.init("test.db", false, uri); diff --git a/apps/web/src/components/navigation-menu/index.tsx b/apps/web/src/components/navigation-menu/index.tsx index e6c0a0485..6e056179c 100644 --- a/apps/web/src/components/navigation-menu/index.tsx +++ b/apps/web/src/components/navigation-menu/index.tsx @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Box, Button, Flex } from "@theme-ui/components"; import { Note, @@ -38,7 +38,7 @@ import { Reminders } from "../icons"; import { AnimatedFlex } from "../animated"; -import NavigationItem from "./navigation-item"; +import NavigationItem, { SortableNavigationItem } from "./navigation-item"; import { hardNavigate, hashNavigate, navigate } from "../../navigation"; import { db } from "../../common/db"; import useMobile from "../../hooks/use-mobile"; @@ -49,8 +49,28 @@ import { useStore as useThemeStore } from "../../stores/theme-store"; import useLocation from "../../hooks/use-location"; import { FlexScrollContainer } from "../scroll-container"; import { ScopedThemeProvider } from "../theme-provider"; +import { + closestCenter, + DndContext, + useSensor, + useSensors, + KeyboardSensor, + DragOverlay, + MeasuringStrategy, + MouseSensor +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy +} from "@dnd-kit/sortable"; +import { isUserPremium } from "../../hooks/use-is-user-premium"; +import { showToast } from "../../utils/toast"; +import { usePersistentState } from "../../hooks/use-persistent-state"; type Route = { + id: string; title: string; path: string; icon: Icon; @@ -68,33 +88,38 @@ function shouldSelectNavItem(route: string, pin: { type: string; id: string }) { } const routes: Route[] = [ - { title: "Notes", path: "/notes", icon: Note }, + { id: "notes", title: "Notes", path: "/notes", icon: Note }, { + id: "notebooks", title: "Notebooks", path: "/notebooks", icon: Notebook }, { + id: "favorites", title: "Favorites", path: "/favorites", icon: StarOutline }, - { title: "Tags", path: "/tags", icon: Tag }, - { - title: "Monographs", - path: "/monographs", - icon: Monographs - }, + { id: "tags", title: "Tags", path: "/tags", icon: Tag }, { + id: "reminders", title: "Reminders", path: "/reminders", icon: Reminders, tag: "Beta" }, - { title: "Trash", path: "/trash", icon: Trash } + { + id: "monographs", + title: "Monographs", + path: "/monographs", + icon: Monographs + }, + { id: "trash", title: "Trash", path: "/trash", icon: Trash } ]; const settings: Route = { + id: "settings", title: "Settings", path: "/settings", icon: Settings @@ -180,97 +205,163 @@ function NavigationMenu(props: NavigationMenuProps) { suppressScrollX={true} > - {routes.map((item) => ( - { - if (!isMobile && location === item.path) - return toggleNavigationContainer(); - _navigate(item.path); - }} - /> - ))} - {colors.map((color, index) => ( - { - _navigate(`/colors/${color.id}`); - }} - menuItems={[ - { - type: "button", - key: "rename", - title: "Rename color", - onClick: async () => { - await showRenameColorDialog(color.id); - } + db.settings.getSideBarOrder("builtin")} + onOrderChanged={(order) => + db.settings.setSideBarOrder("builtin", order) + } + renderOverlay={({ item }) => ( + - ))} + /> + )} + renderItem={({ item }) => ( + { + if (!isMobile && location === item.path) + return toggleNavigationContainer(); + _navigate(item.path); + }} + /> + )} + /> + + db.settings.getSideBarOrder("colors")} + onOrderChanged={(order) => + db.settings.setSideBarOrder("colors", order) + } + renderOverlay={({ item }) => ( + + )} + renderItem={({ item: color }) => ( + { + _navigate(`/colors/${color.id}`); + }} + menuItems={[ + { + type: "button", + key: "rename", + title: "Rename color", + onClick: async () => { + await showRenameColorDialog(color.id); + } + } + ]} + /> + )} + /> - {shortcuts.map((item, index) => ( - { - await db.shortcuts.remove(item.id); - refreshNavItems(); + db.settings.getSideBarOrder("shortcuts")} + onOrderChanged={(order) => + db.settings.setSideBarOrder("shortcuts", order) + } + renderOverlay={({ item }) => ( + + )} + renderItem={({ item }) => ( + { + await db.shortcuts.remove(item.id); + refreshNavItems(); + } } + ]} + icon={ + item.type === "notebook" + ? Notebook2 + : item.type === "tag" + ? Tag2 + : Topic } - ]} - icon={ - item.type === "notebook" - ? Notebook2 - : item.type === "tag" - ? Tag2 - : Topic - } - isShortcut - selected={shouldSelectNavItem(location, item)} - onClick={() => { - if (item.type === "notebook") { - _navigate(`/notebooks/${item.id}`); - } else if (item.type === "tag") { - _navigate(`/tags/${item.id}`); - } - }} - /> - ))} + isShortcut + selected={shouldSelectNavItem(location, item)} + onClick={() => { + if (item.type === "notebook") { + _navigate(`/notebooks/${item.id}`); + } else if (item.type === "tag") { + _navigate(`/tags/${item.id}`); + } + }} + /> + )} + /> {isLoggedIn === false && ( )} = { + orderKey: string; + items: T[]; + renderItem: (props: { item: T }) => JSX.Element; + renderOverlay: (props: { item: T }) => JSX.Element; + onOrderChanged: (newOrder: string[]) => void; + order: () => string[]; +}; + +function ReorderableList( + props: ReorderableListProps +) { + const { + orderKey, + items, + renderItem: Item, + renderOverlay: Overlay, + onOrderChanged, + order: _order + } = props; + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10 + } + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + const [activeItem, setActiveItem] = useState(); + const [order, setOrder] = usePersistentState(orderKey, _order()); + const orderedItems = orderItems(items, order); + + return ( + { + if (!isUserPremium()) { + showToast("error", "You need to be Pro to customize the sidebar."); + return; + } + setActiveItem(orderedItems.find((i) => i.id === event.active.id)); + }} + onDragEnd={(event) => { + const { active, over } = event; + + const overId = over?.id as string; + if (overId && active.id !== overId) { + const transitionOrder = + order.length === 0 ? orderedItems.map((i) => i.id) : order; + const newIndex = transitionOrder.indexOf(overId); + const oldIndex = transitionOrder.indexOf(active.id as string); + const newOrder = arrayMove(transitionOrder, oldIndex, newIndex); + setOrder(newOrder); + onOrderChanged(newOrder); + } + setActiveItem(undefined); + }} + measuring={{ + droppable: { strategy: MeasuringStrategy.Always } + }} + > + + {orderedItems.map((item) => ( + + ))} + + + {activeItem && } + + + + ); +} + +function orderItems(items: T[], order: string[]) { + const sorted: T[] = []; + order.forEach((id) => { + const item = items.find((i) => i.id === id); + if (!item) return; + sorted.push(item); + }); + sorted.push(...items.filter((i) => !order.includes(i.id))); + return sorted; +} diff --git a/apps/web/src/components/navigation-menu/navigation-item.tsx b/apps/web/src/components/navigation-menu/navigation-item.tsx index 95f9e37eb..22022c9b7 100644 --- a/apps/web/src/components/navigation-menu/navigation-item.tsx +++ b/apps/web/src/components/navigation-menu/navigation-item.tsx @@ -17,15 +17,16 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { Button, Text } from "@theme-ui/components"; +import { Button, Flex, FlexProps, Text } from "@theme-ui/components"; import { useStore as useAppStore } from "../../stores/app-store"; import { Menu } from "../../hooks/use-menu"; import useMobile from "../../hooks/use-mobile"; import { PropsWithChildren } from "react"; import { Icon, Shortcut } from "../icons"; -import { AnimatedFlex } from "../animated"; import { SchemeColors, createButtonVariant } from "@notesnook/theme"; import { MenuItem } from "@notesnook/ui"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; type NavigationItemProps = { icon: Icon; @@ -43,7 +44,11 @@ type NavigationItemProps = { menuItems?: MenuItem[]; }; -function NavigationItem(props: PropsWithChildren) { +function NavigationItem( + props: PropsWithChildren< + NavigationItemProps & { containerRef?: React.Ref } & FlexProps + > +) { const { icon: Icon, color, @@ -57,24 +62,17 @@ function NavigationItem(props: PropsWithChildren) { onClick, menuItems, count, - animate = false, - index = 0 + sx, + containerRef, + ...restProps } = props; const toggleSideMenu = useAppStore((store) => store.toggleSideMenu); const isMobile = useMobile(); return ( - ) { alignItems: "center", position: "relative", ":first-of-type": { mt: 1 }, - ":last-of-type": { mb: 1 } + ":last-of-type": { mb: 1 }, + ...sx // ":hover:not(:disabled)": { // bg: "hover", // filter: "brightness(100%)" @@ -185,7 +184,29 @@ function NavigationItem(props: PropsWithChildren) { {tag} ) : null} - + ); } export default NavigationItem; + +export function SortableNavigationItem( + props: PropsWithChildren<{ id: string } & NavigationItemProps> +) { + const { id, ...restProps } = props; + const { attributes, listeners, setNodeRef, transform, transition, active } = + useSortable({ id }); + + return ( + + ); +} diff --git a/packages/core/src/collections/settings.ts b/packages/core/src/collections/settings.ts index 9368b02a0..fc022ff4a 100644 --- a/packages/core/src/collections/settings.ts +++ b/packages/core/src/collections/settings.ts @@ -24,7 +24,8 @@ import { GroupingKey, SettingItem, SettingItemMap, - SideBarSectionKey, + SideBarHideableSection, + SideBarSection, ToolbarConfig, ToolbarConfigPlatforms, TrashCleanupInterval @@ -63,13 +64,12 @@ const defaultSettings: SettingItemMap = { "toolbarConfig:desktop": undefined, "toolbarConfig:mobile": undefined, + "sideBarOrder:builtin": [], "sideBarOrder:colors": [], - "sideBarOrder:menu": [], - "sideBarOrder:pinned": [], + "sideBarOrder:shortcuts": [], - "sideBarHiddenItems:colors": [], - "sideBarHiddenItems:menu": [], - "sideBarHiddenItems:pinned": [] + "sideBarHiddenItems:builtin": [], + "sideBarHiddenItems:colors": [] }; export class Settings implements ICollection { @@ -176,19 +176,19 @@ export class Settings implements ICollection { return this.set("timeFormat", format); } - getSideBarOrder(section: SideBarSectionKey) { + getSideBarOrder(section: SideBarSection) { return this.get(`sideBarOrder:${section}`); } - setSideBarOrder(section: SideBarSectionKey, order: string[]) { + setSideBarOrder(section: SideBarSection, order: string[]) { return this.set(`sideBarOrder:${section}`, order); } - getSideBarHiddenItems(section: SideBarSectionKey) { + getSideBarHiddenItems(section: SideBarHideableSection) { return this.get(`sideBarHiddenItems:${section}`); } - setSideBarHiddenItems(section: SideBarSectionKey, order: string[]) { + setSideBarHiddenItems(section: SideBarHideableSection, order: string[]) { return this.set(`sideBarHiddenItems:${section}`, order); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d5d0e21cb..405dfad54 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -33,8 +33,6 @@ export type SortOptions = { sortDirection: "desc" | "asc"; }; -export type SideBarSectionKey = "menu" | "colors" | "pinned"; - export type GroupOptions = SortOptions & { groupBy: "none" | "abc" | "year" | "month" | "week" | "default"; }; @@ -417,6 +415,8 @@ export interface LegacySettingsItem extends BaseItem<"settings"> { } export type ToolbarConfigPlatforms = "desktop" | "mobile"; +export type SideBarSection = "builtin" | "colors" | "shortcuts"; +export type SideBarHideableSection = "builtin" | "colors"; export type SettingItemMap = { trashCleanupInterval: TrashCleanupInterval; titleFormat: string; @@ -425,8 +425,8 @@ export type SettingItemMap = { defaultNotebook: string | undefined; } & Record<`groupOptions:${GroupingKey}`, GroupOptions> & Record<`toolbarConfig:${ToolbarConfigPlatforms}`, ToolbarConfig | undefined> & - Record<`sideBarOrder:${SideBarSectionKey}`, string[]> & - Record<`sideBarHiddenItems:${SideBarSectionKey}`, string[]>; + Record<`sideBarOrder:${SideBarSection}`, string[]> & + Record<`sideBarHiddenItems:${SideBarHideableSection}`, string[]>; export interface SettingItem< TKey extends keyof SettingItemMap = keyof SettingItemMap