web: new sidebar ui

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2024-12-24 15:47:49 +05:00
committed by Ammar Ahmed
parent 93c8a1827c
commit 9978532f57
14 changed files with 903 additions and 569 deletions

View File

@@ -151,6 +151,11 @@ function DesktopAppContents({ show, setShow }: DesktopAppContentsProps) {
const isTablet = useTablet();
const [isNarrow, setIsNarrow] = useState(isTablet || false);
const navPane = useRef<SplitPaneImperativeHandle>(null);
const [isSideMenuOpen, setIsSideMenuOpen] = useState(false);
useEffect(() => {
AppEventManager.subscribe(AppEvents.toggleSideMenu, setIsSideMenuOpen);
});
useEffect(() => {
if (isTablet) navPane.current?.collapse(0);
@@ -165,6 +170,40 @@ function DesktopAppContents({ show, setShow }: DesktopAppContentsProps) {
overflow: "hidden"
}}
>
{isTablet && (
<Flex
sx={{
position: "absolute",
left: 0,
right: 0,
zIndex: 1000,
width: isSideMenuOpen ? "100%" : 0,
height: "100%",
background: "transparent",
transition: "0.15s width ease-out"
}}
>
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
left: 0,
background: "rgba(0,0,0,0.5)"
}}
onClick={() => {
AppEventManager.publish(AppEvents.toggleSideMenu);
}}
/>
<Flex sx={{ width: 300 }}>
<NavigationMenu
toggleNavigationContainer={() => {}}
isTablet={false}
/>
</Flex>
</Flex>
)}
<SplitPane
className="global-split-pane"
ref={navPane}
@@ -177,17 +216,17 @@ function DesktopAppContents({ show, setShow }: DesktopAppContentsProps) {
{isFocusMode ? null : (
<Pane
id="nav-pane"
initialSize={180}
initialSize={isTablet ? 0 : 250}
className={`nav-pane`}
minSize={50}
snapSize={120}
maxSize={300}
minSize={isTablet ? 0 : 200}
// snapSize={200}
maxSize={isTablet ? 0 : 300}
>
<NavigationMenu
toggleNavigationContainer={(state) => {
setShow(state || !show);
}}
isTablet={isNarrow}
isTablet={false}
/>
</Pane>
)}

View File

@@ -17,8 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, Flex } from "@theme-ui/components";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Flex, Image, Text } from "@theme-ui/components";
import {
Note,
Notebook as NotebookIcon,
@@ -36,7 +36,11 @@ import {
Circle,
Icon,
Reminders,
User
User,
Home,
Pro,
Documentation,
Logout
} from "../icons";
import NavigationItem, { SortableNavigationItem } from "./navigation-item";
import { hardNavigate, hashNavigate, navigate } from "../../navigation";
@@ -72,6 +76,15 @@ import { handleDrop } from "../../common/drop-handler";
import { Menu } from "../../hooks/use-menu";
import { RenameColorDialog } from "../../dialogs/item-dialog";
import { strings } from "@notesnook/intl";
import Tags from "../../views/tags";
import NotebookTree from "./notebook-tree";
import { UserProfile } from "../../dialogs/settings/components/user-profile";
import { SUBSCRIPTION_STATUS } from "../../common/constants";
import { ConfirmDialog, showLogoutConfirmation } from "../../dialogs/confirm";
import { CREATE_BUTTON_MAP, createBackup } from "../../common";
import { TaskManager } from "../../common/task-manager";
import { showToast } from "../../utils/toast";
import { useStore } from "../../stores/note-store";
type Route = {
id: string;
@@ -79,28 +92,21 @@ type Route = {
path: string;
icon: Icon;
tag?: string;
count?: number;
};
const navigationHistory = new Map();
function shouldSelectNavItem(route: string, pin: Notebook | Tag) {
return route.endsWith(pin.id);
}
const routes: Route[] = [
const routesInit: Route[] = [
{ id: "notes", title: strings.routes.Notes(), path: "/notes", icon: Note },
{
id: "notebooks",
title: strings.routes.Notebooks(),
path: "/notebooks",
icon: NotebookIcon
},
{
id: "favorites",
title: strings.routes.Favorites(),
path: "/favorites",
icon: StarOutline
},
{ id: "tags", title: strings.routes.Tags(), path: "/tags", icon: TagIcon },
{
id: "reminders",
title: strings.routes.Reminders(),
@@ -116,12 +122,26 @@ const routes: Route[] = [
{ id: "trash", title: strings.routes.Trash(), path: "/trash", icon: Trash }
];
const settings: Route = {
const tabs = [
{
id: "home",
icon: Home,
title: strings.routes.Home()
},
{
id: "notebook",
icon: NotebookIcon,
title: strings.routes.Notebooks()
},
{ id: "tag", icon: TagIcon, title: strings.routes.Tags() }
] as const;
const settings = {
id: "settings",
title: strings.routes.Settings(),
path: "/settings",
icon: Settings
};
} as const;
type NavigationMenuProps = {
toggleNavigationContainer: (toggleState?: boolean) => void;
@@ -130,19 +150,13 @@ type NavigationMenuProps = {
function NavigationMenu(props: NavigationMenuProps) {
const { toggleNavigationContainer, isTablet } = props;
const [location, previousLocation, state] = useLocation();
const [routes, setRoutes] = useState(routesInit);
const [location] = useLocation();
const isFocusMode = useAppStore((store) => store.isFocusMode);
const colors = useAppStore((store) => store.colors);
const shortcuts = useAppStore((store) => store.shortcuts);
const refreshNavItems = useAppStore((store) => store.refreshNavItems);
const isLoggedIn = useUserStore((store) => store.isLoggedIn);
const profile = useSettingStore((store) => store.profile);
const isMobile = useMobile();
const theme = useThemeStore((store) => store.colorScheme);
const toggleNightMode = useThemeStore((store) => store.toggleColorScheme);
const setFollowSystemTheme = useThemeStore(
(store) => store.setFollowSystemTheme
);
const [hiddenRoutes, setHiddenRoutes] = usePersistentState(
"sidebarHiddenItems:routes",
db.settings.getSideBarHiddenItems("routes")
@@ -151,26 +165,52 @@ function NavigationMenu(props: NavigationMenuProps) {
"sidebarHiddenItems:colors",
db.settings.getSideBarHiddenItems("colors")
);
const [currentTab, setCurrentTab] = useState<(typeof tabs)[number]["id"]>(
tabs.find((tab) => location.includes(tab.id))?.id || "home"
);
const notes = useStore((store) => store.notes);
useEffect(() => {
const setCounts = async () => {
const totalNotes = await db.notes.all.count();
const totalFavorites = await db.notes.favorites.count();
const totalReminders = await db.reminders.all.count();
const totalTrash = (await db.trash.all()).length;
const totalMonographs = await db.monographs.all.count();
setRoutes((routes) => {
return routes.map((route) => {
switch (route.id) {
case "notes":
return { ...route, count: totalNotes };
case "favorites":
return { ...route, count: totalFavorites };
case "reminders":
return { ...route, count: totalReminders };
case "trash":
return { ...route, count: totalTrash };
case "monographs":
return { ...route, count: totalMonographs };
default:
return route;
}
});
});
};
setCounts();
}, [notes]);
const dragTimeout = useRef(0);
const _navigate = useCallback(
(path: string) => {
toggleNavigationContainer(true);
const nestedRoute = findNestedRoute(path);
navigate(!nestedRoute || nestedRoute === location ? path : nestedRoute);
navigate(path);
},
[location, toggleNavigationContainer]
);
useEffect(() => {
if (state === "forward" || state === "neutral")
navigationHistory.set(location, true);
else if (state === "same" && location !== previousLocation) {
navigationHistory.delete(previousLocation);
navigationHistory.set(location, true);
} else navigationHistory.delete(previousLocation);
}, [location, previousLocation, state]);
const getSidebarItems = useCallback(async () => {
return [
{
@@ -226,6 +266,69 @@ function NavigationMenu(props: NavigationMenuProps) {
borderRight: "1px solid var(--separator)"
}}
>
<Flex
sx={{
flexDirection: isTablet ? "column" : "row",
alignItems: "center",
justifyContent: isTablet ? "center" : "space-between"
}}
>
<Flex
sx={{
flexDirection: isTablet ? "column" : "row",
alignItems: "center",
padding: 20,
gap: 2
}}
>
<svg
style={{
width: isTablet ? 20 : isMobile ? 30 : 30,
height: isTablet ? 20 : isMobile ? 30 : 30
}}
>
<use href="#full-logo" />
</svg>
<Text
variant="heading"
sx={{
fontSize: 16,
display: isTablet ? "none" : "block"
}}
>
<b>Notesnook</b>
</Text>
</Flex>
<NavigationDropdown
toggleNavigationContainer={toggleNavigationContainer}
/>
</Flex>
<Flex
sx={{
flexDirection: isTablet ? "column" : "row",
justifyContent: "center"
}}
>
{tabs.map((tab) => (
<NavigationItem
key={tab.id}
id={tab.id}
isTablet={isTablet}
title={tab.title}
icon={tab.icon}
selected={currentTab === tab.id}
onClick={() => setCurrentTab(tab.id)}
showTitle={false}
/>
))}
</Flex>
{isTablet && (
<Box
bg="separator"
my={1}
sx={{ width: "85%", height: "0.8px", alignSelf: "center" }}
/>
)}
<Flex
id="navigation-menu"
data-test-id="navigation-menu"
@@ -234,7 +337,9 @@ function NavigationMenu(props: NavigationMenuProps) {
flex: 1,
overflow: "hidden",
flexDirection: "column",
justifyContent: "space-between"
justifyContent: "space-between",
paddingLeft: isTablet ? 0 : 20,
paddingRight: isTablet ? 0 : 20
}}
px={0}
onContextMenu={async (e) => {
@@ -242,299 +347,419 @@ function NavigationMenu(props: NavigationMenuProps) {
Menu.openMenu(await getSidebarItems());
}}
>
<FlexScrollContainer
style={{
flexDirection: "column",
display: "flex"
}}
trackStyle={() => ({
width: 3
})}
thumbStyle={() => ({ width: 3 })}
suppressScrollX={true}
>
<Flex sx={{ flexDirection: "column" }}>
<ReorderableList
items={routes.filter((r) => !hiddenRoutes.includes(r.id))}
orderKey={`sidebarOrder:routes`}
order={() => db.settings.getSideBarOrder("routes")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("routes", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
title={item.title}
icon={item.icon}
tag={item.tag}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
/>
)}
renderItem={({ item }) => (
<SortableNavigationItem
key={item.id}
id={item.id}
isTablet={isTablet}
title={item.title}
icon={item.icon}
tag={item.tag}
onDragEnter={() => {
if (["/notebooks", "/tags"].includes(item.path))
dragTimeout.current = setTimeout(
() => _navigate(item.path),
1000
) as unknown as number;
}}
onDragLeave={() => clearTimeout(dragTimeout.current)}
onDrop={async (e) => {
clearTimeout(dragTimeout.current);
await handleDrop(e.dataTransfer, {
type:
item.path === "/trash"
? "trash"
: item.path === "/favorites"
? "favorites"
: item.path === "/notebooks"
? "notebooks"
: undefined
});
}}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
onClick={() => {
if (!isMobile && location === item.path)
return toggleNavigationContainer();
_navigate(item.path);
}}
menuItems={[
{
type: "lazy-loader",
key: "sidebar-items-loader",
items: getSidebarItems
}
]}
/>
)}
/>
<ReorderableList
items={colors.filter((c) => !hiddenColors.includes(c.id))}
orderKey={`sidebarOrder:colors`}
order={() => db.settings.getSideBarOrder("colors")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("colors", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
title={item.title}
icon={Circle}
color={item.colorCode}
selected={location === `/colors/${item.id}`}
/>
)}
renderItem={({ item: color }) => (
<SortableNavigationItem
id={color.id}
isTablet={isTablet}
key={color.id}
title={color.title}
icon={Circle}
selected={location === `/colors/${color.id}`}
color={color.colorCode}
onClick={() => {
_navigate(`/colors/${color.id}`);
}}
onDrop={(e) => handleDrop(e.dataTransfer, color)}
menuItems={[
{
type: "button",
key: "rename-color",
title: strings.renameColor(),
onClick: () => RenameColorDialog.show(color)
},
{
type: "button",
key: "remove-color",
title: strings.removeColor(),
onClick: async () => {
await db.colors.remove(color.id);
await refreshNavItems();
}
},
{
type: "separator",
key: "sep"
},
{
type: "lazy-loader",
key: "sidebar-items-loader",
items: getSidebarItems
}
]}
/>
)}
/>
<Box
bg="separator"
my={1}
sx={{ width: "85%", height: "0.8px", alignSelf: "center" }}
/>
<ReorderableList
items={shortcuts}
orderKey={`sidebarOrder:shortcuts`}
order={() => db.settings.getSideBarOrder("shortcuts")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("shortcuts", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
key={item.id}
title={item.title}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
isShortcut
selected={shouldSelectNavItem(location, item)}
/>
)}
renderItem={({ item }) => (
<SortableNavigationItem
id={item.id}
isTablet={isTablet}
key={item.id}
title={item.title}
menuItems={[
{
type: "button",
key: "removeshortcut",
title: strings.doActions.remove.shortcut(1),
onClick: async () => {
await db.shortcuts.remove(item.id);
refreshNavItems();
}
}
]}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
isShortcut
selected={shouldSelectNavItem(location, item)}
onDrop={(e) => handleDrop(e.dataTransfer, item)}
onClick={async () => {
if (item.type === "notebook") {
const root = (await db.notebooks.breadcrumbs(item.id)).at(
0
);
if (root && root.id !== item.id)
_navigate(`/notebooks/${root.id}/${item.id}`);
else _navigate(`/notebooks/${item.id}`);
} else if (item.type === "tag") {
_navigate(`/tags/${item.id}`);
}
}}
/>
)}
/>
</Flex>
</FlexScrollContainer>
<Flex sx={{ flexDirection: "column" }}>
{isLoggedIn === false && (
<NavigationItem
id="login"
isTablet={isTablet}
title={strings.login()}
icon={Login}
onClick={() => hardNavigate("/login")}
/>
)}
{isTablet && (
<NavigationItem
id="change-theme"
isTablet={isTablet}
title={theme === "dark" ? strings.light() : strings.dark()}
icon={theme === "dark" ? LightMode : DarkMode}
onClick={() => {
setFollowSystemTheme(false);
toggleNightMode();
}}
/>
)}
<NavigationItem
id={settings.id}
isTablet={isTablet}
key={settings.path}
title={profile?.fullName || settings.title}
icon={profile?.fullName ? User : settings.icon}
image={profile?.fullName ? profile?.profilePicture : undefined}
onClick={() => {
if (!isMobile && location === settings.path)
return toggleNavigationContainer();
hashNavigate("/settings");
}}
selected={location.startsWith(settings.path)}
{currentTab === "notebook" ? (
<NotebookTree />
) : currentTab === "tag" ? (
<Flex
sx={{ height: "100vh", gap: 2, my: 1, flexDirection: "column" }}
>
{isTablet ? null : (
<Button
variant={"icon"}
title={strings.toggleDarkLightMode()}
sx={{ borderLeft: "1px solid var(--separator)" }}
onClick={() => {
setFollowSystemTheme(false);
toggleNightMode();
}}
>
{theme === "dark" ? (
<LightMode size={16} />
) : (
<DarkMode size={16} />
<Button
onClick={CREATE_BUTTON_MAP.tags.onClick}
variant="secondary"
>
{CREATE_BUTTON_MAP.tags.title}
</Button>
<Tags location="sidebar" />
</Flex>
) : (
<FlexScrollContainer
style={{
flexDirection: "column",
display: "flex"
}}
trackStyle={() => ({
width: 3
})}
thumbStyle={() => ({ width: 3 })}
suppressScrollX={true}
>
<Flex sx={{ flexDirection: "column" }}>
<ReorderableList
items={routes.filter((r) => !hiddenRoutes.includes(r.id))}
orderKey={`sidebarOrder:routes`}
order={() => db.settings.getSideBarOrder("routes")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("routes", order)
}
renderOverlay={({ item }) => (
<NavigationItem
count={item.count}
id={item.id}
isTablet={isTablet}
title={item.title}
icon={item.icon}
tag={item.tag}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
/>
)}
</Button>
)}
</NavigationItem>
</Flex>
renderItem={({ item }) => (
<SortableNavigationItem
key={item.id}
id={item.id}
isTablet={isTablet}
title={item.title}
icon={item.icon}
tag={item.tag}
count={item.count}
onDragEnter={() => {
if (["/notebooks", "/tags"].includes(item.path))
dragTimeout.current = setTimeout(
() => _navigate(item.path),
1000
) as unknown as number;
}}
onDragLeave={() => clearTimeout(dragTimeout.current)}
onDrop={async (e) => {
clearTimeout(dragTimeout.current);
await handleDrop(e.dataTransfer, {
type:
item.path === "/trash"
? "trash"
: item.path === "/favorites"
? "favorites"
: item.path === "/notebooks"
? "notebooks"
: undefined
});
}}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
onClick={() => {
if (!isMobile && location === item.path)
return toggleNavigationContainer();
_navigate(item.path);
}}
menuItems={[
{
type: "lazy-loader",
key: "sidebar-items-loader",
items: getSidebarItems
}
]}
/>
)}
/>
<ReorderableList
items={colors.filter((c) => !hiddenColors.includes(c.id))}
orderKey={`sidebarOrder:colors`}
order={() => db.settings.getSideBarOrder("colors")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("colors", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
title={item.title}
icon={Circle}
color={item.colorCode}
count={item.count}
selected={location === `/colors/${item.id}`}
/>
)}
renderItem={({ item: color }) => (
<SortableNavigationItem
id={color.id}
isTablet={isTablet}
key={color.id}
title={color.title}
count={color.count}
icon={Circle}
selected={location === `/colors/${color.id}`}
color={color.colorCode}
onClick={() => {
_navigate(`/colors/${color.id}`);
}}
onDrop={(e) => handleDrop(e.dataTransfer, color)}
menuItems={[
{
type: "button",
key: "rename-color",
title: strings.renameColor(),
onClick: () => RenameColorDialog.show(color)
},
{
type: "button",
key: "remove-color",
title: strings.removeColor(),
onClick: async () => {
await db.colors.remove(color.id);
await refreshNavItems();
}
},
{
type: "separator",
key: "sep"
},
{
type: "lazy-loader",
key: "sidebar-items-loader",
items: getSidebarItems
}
]}
/>
)}
/>
<Box
bg="separator"
my={1}
sx={{ width: "85%", height: "0.8px", alignSelf: "center" }}
/>
<ReorderableList
items={shortcuts}
orderKey={`sidebarOrder:shortcuts`}
order={() => db.settings.getSideBarOrder("shortcuts")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("shortcuts", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
key={item.id}
title={item.title}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
isShortcut
selected={shouldSelectNavItem(location, item)}
/>
)}
renderItem={({ item }) => (
<SortableNavigationItem
id={item.id}
isTablet={isTablet}
key={item.id}
title={item.title}
menuItems={[
{
type: "button",
key: "removeshortcut",
title: strings.doActions.remove.shortcut(1),
onClick: async () => {
await db.shortcuts.remove(item.id);
refreshNavItems();
}
}
]}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
isShortcut
selected={shouldSelectNavItem(location, item)}
onDrop={(e) => handleDrop(e.dataTransfer, item)}
onClick={async () => {
if (item.type === "notebook") {
const root = (
await db.notebooks.breadcrumbs(item.id)
).at(0);
if (root && root.id !== item.id)
_navigate(`/notebooks/${root.id}/${item.id}`);
else _navigate(`/notebooks/${item.id}`);
} else if (item.type === "tag") {
_navigate(`/tags/${item.id}`);
}
}}
/>
)}
/>
</Flex>
</FlexScrollContainer>
)}
</Flex>
</ScopedThemeProvider>
);
}
export default NavigationMenu;
function findNestedRoute(location: string) {
let level = location.split("/").length;
let nestedRoute = undefined;
const history = Array.from(navigationHistory.keys());
for (let i = history.length - 1; i >= 0; --i) {
const route = history[i];
if (!navigationHistory.get(route)) continue;
type NavigationDropdownProps = {
toggleNavigationContainer: NavigationMenuProps["toggleNavigationContainer"];
};
const routeLevel = route.split("/").length;
if (route.startsWith(location) && routeLevel > level) {
level = routeLevel;
nestedRoute = route;
}
}
return nestedRoute;
function NavigationDropdown({
toggleNavigationContainer
}: NavigationDropdownProps) {
const isMobile = useMobile();
const [location] = useLocation();
const user = useUserStore((store) => store.user);
const profile = useSettingStore((store) => store.profile);
const theme = useThemeStore((store) => store.colorScheme);
const toggleNightMode = useThemeStore((store) => store.toggleColorScheme);
const setFollowSystemTheme = useThemeStore(
(store) => store.setFollowSystemTheme
);
const { isPro } = useMemo(() => {
const type = user?.subscription?.type;
const expiry = user?.subscription?.expiry;
if (!expiry) return { isBasic: true, remainingDays: 0 };
return {
isTrial: type === SUBSCRIPTION_STATUS.TRIAL,
isBasic: type === SUBSCRIPTION_STATUS.BASIC,
isBeta: type === SUBSCRIPTION_STATUS.BETA,
isPro: type === SUBSCRIPTION_STATUS.PREMIUM,
isProCancelled: type === SUBSCRIPTION_STATUS.PREMIUM_CANCELED,
isProExpired: type === SUBSCRIPTION_STATUS.PREMIUM_EXPIRED
};
}, [user]);
const notLoggedIn = Boolean(!user || !user.id);
return (
<Flex sx={{ flexDirection: "row", alignItems: "center", gap: 1 }}>
<Flex
onClick={(e) => {
e.preventDefault();
Menu.openMenu(
[
{
type: "popup",
component: () => <UserProfile minimal />,
key: "profile"
},
{
type: "separator",
key: "sep"
},
{
type: "button",
title: strings.login(),
icon: Login.path,
key: "login",
isHidden: !notLoggedIn,
onClick: () => hardNavigate("/login")
},
{
type: "button",
title: strings.toggleDarkLightMode(),
key: "toggle-theme-mode",
icon: theme === "dark" ? LightMode.path : DarkMode.path,
onClick: () => {
setFollowSystemTheme(false);
toggleNightMode();
}
},
{
type: "button",
title: strings.upgradeToPro(),
icon: Pro.path,
key: "upgrade",
isHidden: notLoggedIn || isPro
},
{
type: "button",
title: settings.title,
key: settings.id,
icon: settings.icon.path,
onClick: () => {
if (!isMobile && location === settings.path) {
return toggleNavigationContainer();
}
hashNavigate(settings.path);
}
},
{
type: "button",
title: strings.helpAndSupport(),
icon: Documentation.path,
key: "help-and-support",
onClick: () => {
window.open("https://help.notesnook.com/", "_blank");
}
},
{
type: "button",
title: strings.logout(),
icon: Logout.path,
key: "logout",
isHidden: notLoggedIn,
onClick: async () => {
const result = await showLogoutConfirmation();
if (!result) return;
if (result.backup) {
try {
await createBackup({ mode: "partial" });
} catch (e) {
logger.error(e, "Failed to take backup before logout");
if (
!(await ConfirmDialog.show({
title: strings.failedToTakeBackup(),
message: strings.failedToTakeBackupMessage(),
negativeButtonText: strings.no(),
positiveButtonText: strings.yes()
}))
)
return;
}
}
await TaskManager.startTask({
type: "modal",
title: strings.loggingOut(),
subtitle: strings.pleaseWait(),
action: () => db.user.logout(true)
});
showToast("success", strings.loggedOut());
}
}
],
{
position: {
target: e.currentTarget,
location: "below",
yOffset: 5
}
}
);
}}
variant="columnCenter"
sx={{
bg: "shade",
mr: 2,
size: 35,
borderRadius: 80,
cursor: "pointer",
position: "relative",
outline: "1px solid var(--accent)",
":hover": {
outline: "2px solid var(--accent)"
}
}}
>
{!user || !user.id || !profile?.profilePicture ? (
<User size={30} />
) : (
<Image
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 80
}}
src={profile.profilePicture}
/>
)}
</Flex>
</Flex>
);
}
type ReorderableListProps<T> = {

View File

@@ -32,7 +32,7 @@ type NavigationItemProps = {
icon?: Icon;
image?: string;
color?: SchemeColors;
title: string;
title?: string;
isTablet?: boolean;
isLoading?: boolean;
isShortcut?: boolean;
@@ -41,6 +41,7 @@ type NavigationItemProps = {
onClick?: () => void;
count?: number;
menuItems?: MenuItem[];
showTitle?: boolean;
};
function NavigationItem(
@@ -64,6 +65,7 @@ function NavigationItem(
count,
sx,
containerRef,
showTitle = true,
...restProps
} = props;
const isMobile = useMobile();
@@ -130,7 +132,7 @@ function NavigationItem(
/>
) : Icon ? (
<Icon
size={isTablet ? 16 : 15}
size={isTablet ? 16 : 20}
color={color || (selected ? "icon-selected" : "icon")}
rotate={isLoading}
/>
@@ -144,22 +146,22 @@ function NavigationItem(
/>
)}
<Text
variant="body"
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: selected ? "bold" : "normal",
color: selected ? "paragraph-selected" : "paragraph",
fontSize: "subtitle",
display: isTablet ? "none" : "block"
}}
ml={1}
data-test-id="title"
>
{title}
{/* {tag && (
{showTitle && (
<Text
variant="body"
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: selected ? "bold" : "normal",
color: selected ? "paragraph-selected" : "paragraph",
display: isTablet ? "none" : "block"
}}
ml={1}
data-test-id="title"
>
{title}
{/* {tag && (
<Text
variant="subBody"
as="span"
@@ -174,7 +176,8 @@ function NavigationItem(
{tag}
</Text>
)} */}
</Text>
</Text>
)}
</Button>
{children ? (
children

View File

@@ -0,0 +1,176 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Notebook } from "@notesnook/core";
import { Button, Flex } from "@theme-ui/components";
import { useEffect, useRef, useState } from "react";
import { CREATE_BUTTON_MAP } from "../../common";
import { db } from "../../common/db";
import { store, useStore } from "../../stores/notebook-store";
import { useStore as useSelectionStore } from "../../stores/selection-store";
import Placeholder from "../placeholders";
import SubNotebook from "../sub-notebook";
import {
TreeNode,
VirtualizedTree,
VirtualizedTreeHandle
} from "../virtualized-tree";
import { ListLoader } from "../loaders/list-loader";
function NotebookTree() {
const notebooks = useStore((state) => state.notebooks);
const createButton = CREATE_BUTTON_MAP.notebooks;
useEffect(() => {
store.get().refresh();
}, []);
if (!notebooks) return <ListLoader />;
if (notebooks.length === 0) {
return (
<Flex variant="columnCenterFill" data-test-id="list-placeholder">
<Placeholder context="notebooks" />
</Flex>
);
}
return (
<Flex sx={{ gap: 2, flexDirection: "column", my: 1 }}>
<Button variant="secondary" onClick={createButton.onClick}>
{createButton.title}
</Button>
<div style={{ height: "100vh" }}>
<Tree />
</div>
</Flex>
);
}
export default NotebookTree;
function Tree() {
const treeRef =
useRef<VirtualizedTreeHandle<{ notebook: Notebook; totalNotes: number }>>(
null
);
const setSelectedItems = useSelectionStore((store) => store.setSelectedItems);
const isSelected = useSelectionStore((store) => store.isSelected);
const selectItem = useSelectionStore((store) => store.selectItem);
const deselectItem = useSelectionStore((store) => store.deselectItem);
const toggleSelection = useSelectionStore(
(store) => store.toggleSelectionMode
);
const notebooks = useStore((store) => store.notebooks);
const [notebookIds, setNotebookIds] = useState<string[]>([]);
useEffect(() => {
notebooks?.ids().then((ids) => setNotebookIds(ids));
treeRef.current?.refresh();
}, [notebooks]);
return (
<Flex
id="notebook-tree"
variant="columnFill"
sx={{
height: "100%"
}}
>
{notebookIds.length > 0 && (
<VirtualizedTree
rootId={"root"}
itemHeight={30}
treeRef={treeRef}
deselectAll={() => toggleSelection(false)}
bulkSelect={setSelectedItems}
isSelected={isSelected}
onDeselect={deselectItem}
onSelect={selectItem}
saveKey="notebook-tree"
getChildNodes={async (id, depth) => {
const nodes: TreeNode<{
notebook: Notebook;
totalNotes: number;
}>[] = [];
if (id === "root") {
for (const id of notebookIds) {
const notebook = (await db.notebooks.notebook(id))!;
const totalNotes = await db.relations
.from(notebook, "note")
.count();
const children = await db.relations
.from(notebook, "notebook")
.count();
nodes.push({
data: { notebook, totalNotes },
depth: depth + 1,
hasChildren: children > 0,
id,
parentId: "root"
});
}
return nodes;
}
const subNotebooks = await db.relations
.from({ type: "notebook", id }, "notebook")
.resolve();
for (const notebook of subNotebooks) {
const hasChildren =
(await db.relations.from(notebook, "notebook").count()) > 0;
const totalNotes = await db.relations
.from(notebook, "note")
.count();
nodes.push({
parentId: id,
id: notebook.id,
data: { notebook, totalNotes },
depth: depth + 1,
hasChildren
});
}
return nodes;
}}
renderItem={({ collapse, expand, expanded, index, item: node }) => (
<SubNotebook
depth={node.depth}
isExpandable={node.hasChildren}
item={node.data.notebook}
isExpanded={expanded}
rootId={node.parentId}
totalNotes={node.data.totalNotes}
refresh={async () => {
const notebook = await db.notebooks.notebook(node.id);
const totalNotes = await db.relations
.from(node.data.notebook, "note")
.count();
treeRef.current?.refreshItem(
index,
notebook ? { notebook, totalNotes } : undefined
);
}}
collapse={collapse}
expand={expand}
/>
)}
/>
)}
</Flex>
);
}

View File

@@ -154,7 +154,7 @@ function Header(props: RouteContainerProps) {
>
<Menu
sx={{
display: ["block", "none", "none"],
display: ["block", "block", "none"],
size: 23
}}
size={24}

View File

@@ -73,6 +73,10 @@ function SubNotebook(props: SubNotebookProps) {
item,
totalNotes
});
if (rootId === "root") {
navigate(`/notebooks/${item.id}`);
return;
}
navigate(`/notebooks/${rootId}/${item.id}`);
}, [expand, focus, isOpened, item, rootId, totalNotes]);

View File

@@ -85,8 +85,15 @@ export function VirtualizedTree<T>(props: TreeViewProps<T>) {
treeRef,
() => ({
async refresh() {
const children = await getChildNodes(rootId, -1);
setNodes(children);
// const children = await getChildNodes(rootId, -1);
// setNodes(children);
const nodes = await fetchChildren(
rootId,
-1,
expandedIds,
getChildNodes
);
setNodes(nodes);
},
async refreshItem(index, item) {
const node = nodes[index];
@@ -103,6 +110,11 @@ export function VirtualizedTree<T>(props: TreeViewProps<T>) {
return;
}
// TODO: double check
if (node.hasChildren) {
expandedIds[node.id] = true;
}
const children = await fetchChildren(
node.id,
node.depth,

View File

@@ -32,7 +32,11 @@ import { EditProfilePictureDialog } from "../../edit-profile-picture-dialog";
import { PromptDialog } from "../../prompt";
import { strings } from "@notesnook/intl";
export function UserProfile() {
type Props = {
minimal?: boolean;
};
export function UserProfile({ minimal }: Props) {
const user = useUserStore((store) => store.user);
const profile = useSettingStore((store) => store.profile);
@@ -67,7 +71,7 @@ export function UserProfile() {
alignItems: "center",
bg: "var(--background-secondary)",
p: 2,
mb: 4
mb: minimal ? 0 : 4
}}
>
<Flex
@@ -96,7 +100,7 @@ export function UserProfile() {
justifyContent: "space-between",
bg: "var(--background-secondary)",
p: 2,
mb: 4
mb: minimal ? 0 : 4
}}
>
<Flex sx={{ alignItems: "center" }}>
@@ -110,7 +114,7 @@ export function UserProfile() {
overflow: "hidden",
position: "relative",
":hover #profile-picture-edit": {
visibility: "visible"
visibility: minimal ? "hidden" : "visible"
}
}}
>
@@ -165,35 +169,43 @@ export function UserProfile() {
<Text variant={"title"}>
{profile?.fullName || strings.yourFullName()}{" "}
<Edit
sx={{ display: "inline-block", cursor: "pointer" }}
size={12}
title={strings.editFullName()}
onClick={async () => {
const fullName = await PromptDialog.show({
title: strings.editFullName(),
description: strings.setFullNameDesc(),
defaultValue: profile?.fullName
});
if (fullName === profile?.fullName) return;
try {
await db.settings.setProfile({
fullName: fullName || undefined
{minimal ? null : (
<Edit
sx={{ display: "inline-block", cursor: "pointer" }}
size={12}
title={strings.editFullName()}
onClick={async () => {
const fullName = await PromptDialog.show({
title: strings.editFullName(),
description: strings.setFullNameDesc(),
defaultValue: profile?.fullName
});
await useSettingStore.getState().refresh();
showToast("success", strings.fullNameUpdated());
} catch (e) {
showToast("error", (e as Error).message);
}
}}
/>
if (fullName === profile?.fullName) return;
try {
await db.settings.setProfile({
fullName: fullName || undefined
});
await useSettingStore.getState().refresh();
showToast("success", strings.fullNameUpdated());
} catch (e) {
showToast("error", (e as Error).message);
}
}}
/>
)}
</Text>
<Text variant={"subBody"}>
{user.email} {" "}
{strings.memberSince(
getFormattedDate(getObjectIdTimestamp(user.id), "date")
{user.email}
{minimal ? null : (
<>
{" "}
{" "}
{strings.memberSince(
getFormattedDate(getObjectIdTimestamp(user.id), "date")
)}
</>
)}
</Text>
</Flex>

View File

@@ -77,10 +77,6 @@ const routes = defineRoutes({
// ),
buttons: {
create: CREATE_BUTTON_MAP.notes,
back: {
title: strings.goBackToNotebooks(),
onClick: () => navigate("/notebooks")
},
search: {
title: strings.searchANote()
}
@@ -153,6 +149,9 @@ const routes = defineRoutes({
title: strings.routes.Tags(),
type: "tags",
component: Tags,
props: {
location: "middle-pane"
},
buttons: {
create: CREATE_BUTTON_MAP.tags,
search: {
@@ -178,10 +177,6 @@ const routes = defineRoutes({
component: Notes,
buttons: {
create: CREATE_BUTTON_MAP.notes,
back: {
title: strings.goBackToTags(),
onClick: () => navigate("/tags")
},
search: {
title: strings.searchANote()
}

View File

@@ -74,7 +74,7 @@ class AppStore extends BaseStore<AppStore> {
progress: null,
type: undefined
};
colors: Color[] = [];
colors: (Color & { count: number })[] = [];
notices: Notice[] = [];
shortcuts: (Notebook | Tag)[] = [];
lastSynced = 0;
@@ -166,9 +166,16 @@ class AppStore extends BaseStore<AppStore> {
refreshNavItems = async () => {
const shortcuts = await db.shortcuts.resolved();
const colors = await db.colors.all.items();
let newColors: (Color & { count: number })[] = [];
for (const color of colors) {
const count = await db.colors.count(color.id);
newColors.push({ ...color, count: count ?? 0 });
}
this.set((state) => {
state.shortcuts = shortcuts;
state.colors = colors;
state.colors = newColors;
});
};

View File

@@ -120,168 +120,11 @@ function Notebook(props: NotebookProps) {
}
/>
</Pane>
<Pane id="subnotebooks-pane" initialSize={250} minSize={30}>
<SubNotebooks
isCollapsed={isCollapsed}
rootId={rootId}
onClick={() => {
if (isCollapsed) pane.current?.expand(1);
else pane.current?.collapse(1);
}}
/>
</Pane>
</SplitPane>
);
}
export default Notebook;
type SubNotebooksProps = {
rootId: string;
isCollapsed: boolean;
onClick: () => void;
};
function SubNotebooks({ rootId, isCollapsed, onClick }: SubNotebooksProps) {
// sometimes the onClick event is triggered on dragEnd
// which shouldn't happen. To prevent that we make sure
// that onMouseDown & onMouseUp events got called.
const mouseEventCounter = useRef(0);
const treeRef = useRef<VirtualizedTreeHandle<SubNotebookTreeItem>>(null);
const setSelectedItems = useSelectionStore((store) => store.setSelectedItems);
const isSelected = useSelectionStore((store) => store.isSelected);
const selectItem = useSelectionStore((store) => store.selectItem);
const deselectItem = useSelectionStore((store) => store.deselectItem);
const toggleSelection = useSelectionStore(
(store) => store.toggleSelectionMode
);
const rootNotebook = usePromise(
() => db.notebooks.notebook(rootId),
[rootId]
);
const notebooks = useNotebookStore((store) => store.notebooks);
useEffect(() => {
treeRef.current?.refresh();
}, [notebooks]);
if (!rootId) return null;
return (
<Flex
id="subnotebooks"
variant="columnFill"
sx={{
height: "100%",
borderTop: "1px solid var(--border)"
}}
>
<Flex
sx={{
m: 1,
ml: 1,
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer"
}}
onMouseDown={() => {
mouseEventCounter.current = 1;
}}
onMouseUp={() => {
mouseEventCounter.current++;
}}
onClick={() => {
if (mouseEventCounter.current === 2) onClick();
mouseEventCounter.current = 0;
}}
>
<Flex sx={{ alignItems: "center" }}>
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<Text variant="subBody" sx={{ fontSize: 11 }}>
{rootNotebook.status === "fulfilled" && rootNotebook.value
? rootNotebook.value.title
: ""}
</Text>
</Flex>
<Flex sx={{ alignItems: "center" }}>
{/* <Button
variant="secondary"
data-test-id="subnotebooks-sort-button"
sx={{
p: "small",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={(e) => {
e.stopPropagation();
// showSortMenu("notebooks", () => refresh(selectedNotebook.id));
}}
>
<SortAsc size={15} />
</Button> */}
<Button
variant="secondary"
data-test-id="subnotebooks-action-button"
sx={{
p: "1px",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={async (e) => {
e.stopPropagation();
const context = useNotesStore.getState().context;
await AddNotebookDialog.show({
parentId: context?.type === "notebook" ? context.id : rootId
});
}}
>
<Plus size={20} />
</Button>
</Flex>
</Flex>
<VirtualizedTree
itemHeight={28}
getChildNodes={fetchChildren}
rootId={rootId}
deselectAll={() => toggleSelection(false)}
bulkSelect={setSelectedItems}
isSelected={isSelected}
onDeselect={deselectItem}
onSelect={selectItem}
treeRef={treeRef}
placeholder={() => (
<Text variant="subBody" sx={{ mx: 2 }}>
{strings.emptyPlaceholders("notebook")}
</Text>
)}
saveKey={`${rootId}-subnotebooks`}
testId="subnotebooks-list"
renderItem={({ collapse, expand, expanded, index, item: node }) => (
<SubNotebook
depth={node.depth}
isExpandable={node.hasChildren}
item={node.data.notebook}
isExpanded={expanded}
rootId={rootId}
totalNotes={node.data.totalNotes}
refresh={async () => {
const notebook = await db.notebooks.notebook(node.id);
const totalNotes = await db.relations
.from(node.data.notebook, "note")
.count();
treeRef.current?.refreshItem(
index,
notebook ? { notebook, totalNotes } : undefined
);
}}
collapse={collapse}
expand={expand}
/>
)}
/>
</Flex>
);
}
function NotebookHeader({
rootId,
context
@@ -341,7 +184,6 @@ function NotebookHeader({
ref={moreCrumbsRef}
variant="icon"
sx={{ p: 0, flexShrink: 0 }}
onClick={() => navigateCrumb("notebooks")}
title={strings.notebooks()}
>
<Notebook2 size={14} />

View File

@@ -17,42 +17,41 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import ListContainer from "../components/list-container";
import { useStore, store } from "../stores/notebook-store";
import { hashNavigate } from "../navigation";
import { navigate } from "../navigation";
import Placeholder from "../components/placeholders";
import { useEffect } from "react";
import { db } from "../common/db";
import { useSearch } from "../hooks/use-search";
import { ListLoader } from "../components/loaders/list-loader";
import { Flex } from "@theme-ui/components";
function Notebooks() {
const notebooks = useStore((state) => state.notebooks);
const refresh = useStore((state) => state.refresh);
const filteredItems = useSearch("notebooks", (query) =>
db.lookup.notebooks(query).sorted()
);
const isCompact = useStore((store) => store.viewMode === "compact");
useEffect(() => {
store.get().refresh();
}, []);
if (notebooks && notebooks.length > 0) {
notebooks.item(0).then((item) => {
if (item && item?.item) {
navigate(`/notebooks/${item.item.id}`);
}
});
}
}, [notebooks]);
if (!notebooks) return <ListLoader />;
return (
<>
<ListContainer
group="notebooks"
refresh={refresh}
items={filteredItems || notebooks}
placeholder={<Placeholder context="notebooks" />}
compact={isCompact}
button={{
onClick: () => hashNavigate("/notebooks/create")
}}
/>
</>
);
if (notebooks.length === 0) {
return (
<Flex variant="columnFill" sx={{ overflow: "hidden" }}>
<Flex variant="columnCenterFill">
<Placeholder context="notebooks" />
</Flex>
</Flex>
);
}
return null;
}
export default Notebooks;

View File

@@ -24,8 +24,14 @@ import Placeholder from "../components/placeholders";
import { useSearch } from "../hooks/use-search";
import { db } from "../common/db";
import { ListLoader } from "../components/loaders/list-loader";
import { useEffect } from "react";
import { navigate } from "../navigation";
function Tags() {
type Props = {
location: "middle-pane" | "sidebar";
};
function Tags({ location }: Props) {
useNavigate("tags", () => store.refresh());
const tags = useStore((store) => store.tags);
const refresh = useStore((store) => store.refresh);
@@ -33,10 +39,18 @@ function Tags() {
db.lookup.tags(query).sorted()
);
useEffect(() => {
if (location === "sidebar") return;
tags?.item(0).then((item) => {
if (item && item?.item) {
navigate(`/tags/${item.item.id}`);
}
});
}, [tags, location]);
if (!tags) return <ListLoader />;
return (
<ListContainer
group="tags"
refresh={refresh}
items={filteredItems || tags}
placeholder={<Placeholder context="tags" />}

View File

@@ -112,6 +112,12 @@ export class Colors implements ICollection {
);
}
async count(id: string) {
const color = await this.color(id);
if (!color) return;
return this.db.relations.from(color, "note").count();
}
async remove(...ids: string[]) {
await this.db.transaction(async () => {
await this.db.relations.unlinkOfType("color", ids);