diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 4ba9a4d8c..928951f83 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -60,6 +60,7 @@ "phone": "^3.1.14", "platform": "^1.3.6", "qclone": "^1.2.0", + "react-complex-tree": "^2.2.3", "react-dropzone": "^11.4.2", "react-hot-toast": "^2.2.0", "react-loading-skeleton": "^3.1.0", @@ -45575,6 +45576,14 @@ "node": ">=0.10.0" } }, + "node_modules/react-complex-tree": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-complex-tree/-/react-complex-tree-2.2.3.tgz", + "integrity": "sha512-hb4haCvf+Z9YyGibRHPoiw8IJ7QQMZDjQlEIJQRPGcF4tNzN39IrwTTOdysvh4mPw/xqyu5lt9VkleV+5Oc4sg==", + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-dom": { "version": "17.0.2", "license": "MIT", diff --git a/apps/web/package.json b/apps/web/package.json index 13e1007a3..27d0819f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,7 @@ "phone": "^3.1.14", "platform": "^1.3.6", "qclone": "^1.2.0", + "react-complex-tree": "^2.2.3", "react-dropzone": "^11.4.2", "react-hot-toast": "^2.2.0", "react-loading-skeleton": "^3.1.0", diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 8093f4092..edfa71c0c 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -215,3 +215,12 @@ textarea, .ms-thumb { background: var(--paragraph-secondary) !important; } + +.rct-tree-items-container { + margin: 0px; + margin-block-start: 0px !important; + margin-block-end: 0px !important; + margin-inline-end: 0px !important; + margin-inline-start: 0px !important; + padding: 0px !important; +} diff --git a/apps/web/src/common/dialog-controller.tsx b/apps/web/src/common/dialog-controller.tsx index 30ee4035d..7f4261fff 100644 --- a/apps/web/src/common/dialog-controller.tsx +++ b/apps/web/src/common/dialog-controller.tsx @@ -37,8 +37,7 @@ import { ConfirmDialogProps } from "../dialogs/confirm"; import { getFormattedDate } from "@notesnook/common"; import { downloadUpdate } from "../utils/updater"; import { ThemeMetadata } from "@notesnook/themes-server"; -import { clone } from "@notesnook/core/dist/utils/clone"; -import { Notebook, Reminder } from "@notesnook/core/dist/types"; +import { Reminder } from "@notesnook/core"; import { AuthenticatorType } from "@notesnook/core/dist/api/user-manager"; type DialogTypes = typeof Dialogs; @@ -91,20 +90,10 @@ export function showAddTagsDialog(noteIds: string[]) { )); } -export function showAddNotebookDialog() { +export function showAddNotebookDialog(parentId?: string) { return showDialog("AddNotebookDialog", (Dialog, perform) => ( ) => { - // add the notebook to db - const notebook = await db.notebooks.add({ ...nb }); - if (!notebook) return perform(false); - - notebookStore.refresh(); - - showToast("success", "Notebook added successfully!"); - perform(true); - }} + parentId={parentId} onClose={() => { perform(false); }} @@ -112,36 +101,13 @@ export function showAddNotebookDialog() { )); } -export function showEditNotebookDialog(notebookId: string) { - const notebook = db.notebooks.notebook(notebookId)?.data; +export async function showEditNotebookDialog(notebookId: string) { + const notebook = await db.notebooks.notebook(notebookId); if (!notebook) return; - return showDialog("AddNotebookDialog", (Dialog, perform) => ( + return await showDialog("AddNotebookDialog", (Dialog, perform) => ( { - // we remove the topics from notebook - // beforehand so we can add them manually, later - const topics = clone(nb.topics); - nb.topics = []; - - const notebookId = await db.notebooks.add(nb); - - // add or delete topics as required - const notebookTopics = notebookId && db.notebooks.topics(notebookId); - if (notebookTopics) { - await notebookTopics.add(...topics); - await notebookTopics.delete(...deletedTopics); - } - - notebookStore.refresh(); - noteStore.refresh(); - appStore.refreshNavItems(); - - showToast("success", "Notebook edited successfully!"); - perform(true); - }} onClose={() => { perform(false); }} @@ -469,50 +435,6 @@ export function showRecoveryKeyDialog() { )); } -export function showCreateTopicDialog() { - return showDialog("ItemDialog", (Dialog, perform) => ( - { - perform(false); - }} - onAction={async (topic: Record) => { - if (!topic) return; - const notebook = notebookStore.get().selectedNotebook; - if (!notebook) return; - await db.notebooks.topics(notebook.id).add(topic); - notebookStore.setSelectedNotebook(notebook.id); - showToast("success", "Topic created!"); - perform(true); - }} - /> - )); -} - -export function showEditTopicDialog(notebookId: string, topicId: string) { - const topic = db.notebooks.topics(notebookId).topic(topicId)?._topic; - if (!topic) return; - - return showDialog("ItemDialog", (Dialog, perform) => ( - perform(false)} - onAction={async (t: string) => { - await db.notebooks.topics(topic.notebookId).add({ ...topic, title: t }); - notebookStore.setSelectedNotebook(topic.notebookId); - appStore.refreshNavItems(); - showToast("success", "Topic edited!"); - perform(true); - }} - /> - )); -} - export function showCreateTagDialog() { return showDialog("ItemDialog", (Dialog, perform) => ( - + ))} diff --git a/apps/web/src/components/field/index.jsx b/apps/web/src/components/field/index.tsx similarity index 51% rename from apps/web/src/components/field/index.jsx rename to apps/web/src/components/field/index.tsx index 391b32f65..22bace8aa 100644 --- a/apps/web/src/components/field/index.jsx +++ b/apps/web/src/components/field/index.tsx @@ -18,65 +18,35 @@ along with this program. If not, see . */ import { useState } from "react"; -import { Button, Flex, Text } from "@theme-ui/components"; +import { Button, Flex, Text, InputProps } from "@theme-ui/components"; import { Input, Label } from "@theme-ui/components"; -import { PasswordVisible, PasswordInvisible, Check, Cross } from "../icons"; +import { ThemeUIStyleObject } from "@theme-ui/css"; +import { PasswordVisible, PasswordInvisible, Icon } from "../icons"; import { useStore as useThemeStore } from "../../stores/theme-store"; -const passwordValidationRules = [ - { - title: "8 characters", - validate: (password) => password.length >= 8 - } - // { - // title: "1 lowercase letter", - // validate: (password) => /[a-z]/.test(password), - // }, - // { - // title: "1 uppercase letter", - // validate: (password) => /[A-Z]/.test(password), - // }, - // { - // title: "1 digit", - // validate: (password) => /\d/.test(password), - // }, - // { - // title: "1 special character", - // validate: (password) => /\W/.test(password), - // }, -]; +type FieldProps = InputProps & { + label?: string; + helpText?: string; + inputRef?: React.Ref; + ["data-test-id"]?: string; + styles?: { + input?: ThemeUIStyleObject; + label?: ThemeUIStyleObject; + helpText?: ThemeUIStyleObject; + }; + action?: { + testId?: string; + onClick?: () => void; + disabled?: boolean; + icon?: Icon; + component?: JSX.Element; + }; +}; -function Field(props) { - const { - id, - label, - type, - sx, - styles = {}, - name, - required, - autoFocus, - autoComplete, - helpText, - action, - onKeyUp, - onKeyDown, - onChange, - inputRef, - disabled, - defaultValue, - value, - placeholder, - validatePassword, - onError, - inputMode, - pattern, - min, - variant = "input", - as = "input" - } = props; +function Field(props: FieldProps) { + const { label, styles, helpText, action, sx, id, type, ...inputProps } = + props; const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const [rules, setRules] = useState(passwordValidationRules); const colorScheme = useThemeStore((state) => state.colorScheme); return ( @@ -85,7 +55,6 @@ function Field(props) { m: "2px", mr: "2px", ...sx, - ...styles.container, flexDirection: "column" }} > @@ -97,7 +66,7 @@ function Field(props) { fontFamily: "body", color: "paragraph", flexDirection: "column", - ...styles.label + ...styles?.label }} > {label}{" "} @@ -107,7 +76,7 @@ function Field(props) { as="span" sx={{ fontWeight: "normal", - ...styles.helpText + ...styles?.helpText }} > {helpText} @@ -117,55 +86,21 @@ function Field(props) { { - if (validatePassword) { - const value = e.target.value; - const mapped = rules.map((rule) => { - return { ...rule, isValid: rule.validate(value) }; - }); - if (onError) { - onError(mapped.some((m) => !m.isValid)); - } - setRules(mapped); - } - if (onChange) onChange(e); - }} - onKeyUp={onKeyUp} - onKeyDown={onKeyDown} /> {type === "password" && ( { - const input = document.getElementById(id); - if (!input) return; - input.type = isPasswordVisible ? "password" : "text"; - setIsPasswordVisible((s) => !s); - }} + onClick={() => setIsPasswordVisible((s) => !s)} variant="rowCenter" sx={{ position: "absolute", @@ -205,22 +140,6 @@ function Field(props) { )} - {validatePassword && ( - - {rules.map((rule) => ( - - {rule.isValid ? ( - - ) : ( - - )} - - {rule.title} - - - ))} - - )} ); } diff --git a/apps/web/src/components/list-container/index.tsx b/apps/web/src/components/list-container/index.tsx index a59634dc7..517aa0bf7 100644 --- a/apps/web/src/components/list-container/index.tsx +++ b/apps/web/src/components/list-container/index.tsx @@ -163,8 +163,7 @@ function ListContainer(props: ListContainerProps) { }} itemContent={(index, item) => { if (isGroupHeader(item)) { - if (!group) - return
; + if (!group) return null; return ( . import Note from "../note"; import Notebook from "../notebook"; import Tag from "../tag"; -import Topic from "../topic"; import TrashItem from "../trash-item"; import { db } from "../../common/db"; import Reminder from "../reminder"; @@ -39,6 +38,7 @@ import { Reminder as ReminderItem } from "@notesnook/core"; import { useEffect, useRef, useState } from "react"; +import SubNotebook from "../sub-notebook"; const SINGLE_LINE_HEIGHT = 1.4; const DEFAULT_LINE_HEIGHT = @@ -100,6 +100,15 @@ export function ListItemWrapper(props: ListItemWrapperProps) { ); } case "notebook": + if (context?.type === "notebook") + return ( + + ); + return ( ; case "reminder": return ; - case "topic": - return ; case "tag": return ; default: @@ -146,7 +153,7 @@ function getDate(item: Item, groupType?: GroupingKey): number { ); } -async function resolveItems(ids: string[], items: Record) { +export async function resolveItems(ids: string[], items: Record) { const { type } = items[ids[0]]; if (type === "note") return resolveNotes(ids); else if (type === "notebook") { @@ -177,7 +184,13 @@ async function resolveNotes(ids: string[]) { ...(await db.relations.from({ type: "note", ids }, "reminder").get()) ]; console.timeEnd("relations"); - + console.log( + relations, + ids, + await db.relations + .from({ type: "notebook", id: "6549b4c373c7f3a40852f80c" }, "note") + .get() + ); const relationIds: { notebooks: Set; colors: Set; diff --git a/apps/web/src/components/list-container/types.ts b/apps/web/src/components/list-container/types.ts index 2a5a813f2..88b8ab21c 100644 --- a/apps/web/src/components/list-container/types.ts +++ b/apps/web/src/components/list-container/types.ts @@ -18,12 +18,18 @@ along with this program. If not, see . */ import { Notebook, Tag } from "@notesnook/core"; - +export type NotebookContext = { + type: "notebook"; + id: string; + item?: Notebook; + totalNotes?: number; +}; export type Context = | { - type: "notebook" | "tag" | "color"; + type: "tag" | "color"; id: string; } + | NotebookContext | { type: "favorite" | "monographs"; }; diff --git a/apps/web/src/components/list-item/index.tsx b/apps/web/src/components/list-item/index.tsx index 6243e9adb..bec3656ca 100644 --- a/apps/web/src/components/list-item/index.tsx +++ b/apps/web/src/components/list-item/index.tsx @@ -18,6 +18,7 @@ along with this program. If not, see . */ import { Box, Flex, Text } from "@theme-ui/components"; +import { ThemeUIStyleObject } from "@theme-ui/css"; import { store as selectionStore, useStore as useSelectionStore @@ -43,13 +44,16 @@ type ListItemProps = { onKeyPress?: (e: React.KeyboardEvent) => void; onClick?: () => void; + onSelect?: () => void; title: string | JSX.Element; header?: JSX.Element; body?: JSX.Element | string; footer?: JSX.Element; context?: TContext; - menuItems?: (item: TItem, items?: string[], context?: TContext) => MenuItem[]; + menuItems?: (item: TItem, ids?: string[], context?: TContext) => MenuItem[]; + + sx?: ThemeUIStyleObject; }; function ListItem( @@ -65,7 +69,9 @@ function ListItem( isCompact, isDisabled, isSimple, - item + item, + sx, + context } = props; const listItemRef = useRef(null); @@ -95,10 +101,9 @@ function ListItem( if (selectedItems.findIndex((i) => i === item.id) === -1) { selectedItems = []; - selectedItems.push(item); + selectedItems.push(item.id); } - - let menuItems = props.menuItems?.(item, selectedItems); + let menuItems = props.menuItems?.(item, selectedItems, context); if (selectedItems.length > 1) { title = `${selectedItems.length} items selected`; @@ -111,13 +116,13 @@ function ListItem( title }); }} - pl={1} - pr={2} - py={1} - mb={isCompact ? 0 : 0} tabIndex={-1} dir="auto" sx={{ + pl: 1, + pr: 2, + py: 1, + mb: "1px", height: "inherit", cursor: "pointer", position: "relative", @@ -146,7 +151,8 @@ function ListItem( outlineColor: accent === "accent" ? "accent" : alpha("accent", 0.7), backgroundColor: isSelected || isFocused ? "background-selected" : background - } + }, + ...sx }} onKeyPress={(e) => { if (e.key !== "Enter") { @@ -162,21 +168,27 @@ function ListItem( > {!isCompact && props.header} - - {props.title} - + {typeof props.title === "string" ? ( + + {props.title} + + ) : ( + props.title + )} {!isSimple && !isCompact && props.body && ( { if (state === "forward" || state === "neutral") navigationHistory.set(location, true); - else navigationHistory.delete(previousLocation); + else if (state === "same" && location !== previousLocation) { + navigationHistory.delete(previousLocation); + navigationHistory.set(location, true); + } else navigationHistory.delete(previousLocation); }, [location, previousLocation, state]); return ( diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index 353c180fe..22bcc603a 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -106,8 +106,17 @@ type NoteProps = { }; function Note(props: NoteProps) { - const { tags, color, notebooks, item, date, reminder, simplified, compact } = - props; + const { + tags, + color, + notebooks, + item, + date, + reminder, + simplified, + compact, + context + } = props; const note = item; const isOpened = useStore((store) => store.selectedNote === note.id); @@ -160,16 +169,17 @@ function Note(props: NoteProps) { - {notebooks?.items.map((notebook) => ( - { - navigate(`/notebooks/${notebook.id}`); - }} - text={notebook.title} - icon={Notebook} - /> - ))} + {context?.type !== "notebook" && + notebooks?.items.map((notebook) => ( + { + navigate(`/notebooks/${notebook.id}`); + }} + text={notebook.title} + icon={Notebook} + /> + ))} {reminder && isReminderActive(reminder) && ( { + onClick={async () => { + await useNotesStore + .getState() + .setContext({ type: "notebook", id: notebook.id, item, totalNotes }); navigate(`/notebooks/${notebook.id}`); }} title={notebook.title} body={notebook.description as string} - menuItems={menuItems} + menuItems={notebookMenuItems} footer={ <> {isCompact ? ( @@ -128,19 +130,10 @@ export default React.memo(Notebook, (prev, next) => { ); }); -const pin = (notebook: Notebook) => { - return store - .pin(notebook.id) - .then(() => { - if (notebook.pinned) showUnpinnedToast(notebook.id, "notebook"); - }) - .catch((error) => showToast("error", error.message)); -}; - -const menuItems: (notebook: Notebook, items?: Notebook[]) => MenuItem[] = ( - notebook, - items = [] -) => { +export const notebookMenuItems: ( + notebook: Notebook, + ids?: string[] +) => MenuItem[] = (notebook, ids = []) => { const defaultNotebook = db.settings.getDefaultNotebook(); return [ @@ -155,14 +148,13 @@ const menuItems: (notebook: Notebook, items?: Notebook[]) => MenuItem[] = ( type: "button", key: "set-as-default", title: "Set as default", - isChecked: defaultNotebook?.id === notebook.id && !defaultNotebook?.topic, + isChecked: defaultNotebook === notebook.id, icon: NotebookIcon.path, onClick: async () => { const defaultNotebook = db.settings.getDefaultNotebook(); - const isDefault = - defaultNotebook?.id === notebook.id && !defaultNotebook?.topic; + const isDefault = defaultNotebook === notebook.id; await db.settings.setDefaultNotebook( - isDefault ? undefined : { id: notebook.id } + isDefault ? undefined : notebook.id ); } }, @@ -172,7 +164,8 @@ const menuItems: (notebook: Notebook, items?: Notebook[]) => MenuItem[] = ( icon: Pin.path, title: "Pin", isChecked: notebook.pinned, - onClick: () => pin(notebook) + onClick: () => store.pin(!notebook.pinned, ...ids), + multiSelect: true }, { type: "button", @@ -194,13 +187,13 @@ const menuItems: (notebook: Notebook, items?: Notebook[]) => MenuItem[] = ( icon: Trash.path, onClick: async () => { const result = await confirm({ - title: `Delete ${pluralize(items.length, "notebook")}?`, + title: `Delete ${pluralize(ids.length, "notebook")}?`, positiveButtonText: `Yes`, negativeButtonText: "No", checks: { deleteContainingNotes: { text: `Move all notes in ${ - items.length > 1 ? "these notebooks" : "this notebook" + ids.length > 1 ? "these notebooks" : "this notebook" } to trash` } } @@ -208,18 +201,12 @@ const menuItems: (notebook: Notebook, items?: Notebook[]) => MenuItem[] = ( if (result) { if (result.deleteContainingNotes) { - const notes: Note[] = []; - for (const item of items) { - notes.push(...(db.relations.from(item, "note").resolved() || [])); - const topics = db.notebooks.topics(item.id); - if (!topics) return; - for (const topic of topics.all) { - notes.push(...(topics.topic(topic.id)?.all || [])); - } - } - await Multiselect.moveNotesToTrash(notes, false); + await Multiselect.moveNotesToTrash( + await db.notebooks.notes(notebook.id), + false + ); } - await Multiselect.moveNotebooksToTrash(items); + await Multiselect.moveNotebooksToTrash(ids); } }, multiSelect: true diff --git a/apps/web/src/components/placeholders/index.tsx b/apps/web/src/components/placeholders/index.tsx index de5237ee7..4f85c37fd 100644 --- a/apps/web/src/components/placeholders/index.tsx +++ b/apps/web/src/components/placeholders/index.tsx @@ -18,12 +18,12 @@ along with this program. If not, see . */ import { Button, Flex, Text } from "@theme-ui/components"; -import { Context, useTip } from "../../hooks/use-tip"; +import { TipContext, useTip } from "../../hooks/use-tip"; import { Info, Sync } from "../icons"; import { useStore as useAppStore } from "../../stores/app-store"; import { toTitleCase } from "@notesnook/common"; -type PlaceholderProps = { context: Context; text?: string }; +type PlaceholderProps = { context: TipContext; text?: string }; function Placeholder(props: PlaceholderProps) { const { context, text } = props; const tip = useTip(context); diff --git a/apps/web/src/components/sub-notebook/index.tsx b/apps/web/src/components/sub-notebook/index.tsx new file mode 100644 index 000000000..bff1dbfa5 --- /dev/null +++ b/apps/web/src/components/sub-notebook/index.tsx @@ -0,0 +1,163 @@ +/* +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 . +*/ + +import ListItem from "../list-item"; +import { Flex, Text } from "@theme-ui/components"; +import { useStore as useNotesStore } from "../../stores/note-store"; +import { Notebook } from "@notesnook/core"; +import { notebookMenuItems } from "../notebook"; +import { ChevronDown, ChevronRight, Plus } from "../icons"; +import { MenuItem } from "@notesnook/ui"; +import { showAddNotebookDialog } from "../../common/dialog-controller"; +import { navigate } from "../../navigation"; + +type SubNotebookProps = { + item: Notebook; + totalNotes: number; + isExpandable: boolean; + isExpanded: boolean; + expand: () => void; + collapse: () => void; + focus: () => void; + refresh?: () => void; + depth: number; + rootId: string; +}; +function SubNotebook(props: SubNotebookProps) { + const { + item, + totalNotes, + isExpandable, + isExpanded, + expand, + collapse, + focus, + refresh, + depth, + rootId + } = props; + const isOpened = useNotesStore( + (store) => + store.context?.type === "notebook" && store.context.id === item.id + ); + + return ( + { + if (isOpened) return; + focus(); + expand(); + await useNotesStore.getState().setContext({ + type: "notebook", + id: item.id, + item, + totalNotes + }); + navigate(`/notebooks/${rootId}/${item.id}`); + }} + onKeyPress={async (e) => { + if (e.code === "Space") { + if (isExpandable) isExpanded ? collapse() : expand(); + else if (!isOpened) { + focus(); + await useNotesStore.getState().setContext({ + type: "notebook", + id: item.id, + item, + totalNotes + }); + navigate(`/notebooks/${rootId}/${item.id}`); + } + } + }} + title={ + + {isExpandable ? ( + isExpanded ? ( + { + e.stopPropagation(); + collapse(); + }} + /> + ) : ( + { + e.stopPropagation(); + expand(); + }} + /> + ) + ) : null} + + {item.title} + + + } + footer={{totalNotes}} + menuItems={subNotebookMenuItems} + context={{ refresh }} + sx={{ + paddingLeft: isExpandable + ? `${depth * 5}px` + : depth === 0 + ? 2 + : `${depth * 10}px` + }} + /> + ); +} + +export default SubNotebook; + +const subNotebookMenuItems: ( + notebook: Notebook, + ids?: string[], + context?: { refresh?: () => void } +) => MenuItem[] = (notebook, ids = [], context) => { + const menuItems = notebookMenuItems(notebook, ids); + console.log("ITEMS", context); + return [ + { + type: "button", + key: "add", + title: "New notebook", + icon: Plus.path, + onClick: () => + showAddNotebookDialog(notebook.id).then(() => context?.refresh?.()) + }, + { type: "separator", key: "sepep2" }, + ...menuItems + ]; +}; diff --git a/apps/web/src/components/topic/index.tsx b/apps/web/src/components/topic/index.tsx deleted file mode 100644 index 4d03a34b4..000000000 --- a/apps/web/src/components/topic/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* -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 . -*/ - -import React from "react"; -import ListItem from "../list-item"; -import { db } from "../../common/db"; -import { store as appStore } from "../../stores/app-store"; -import { hashNavigate, navigate } from "../../navigation"; -import { Text } from "@theme-ui/components"; -import { Edit, Topic as TopicIcon, Shortcut, Trash } from "../icons"; -import { Multiselect } from "../../common/multi-select"; -import { confirm } from "../../common/dialog-controller"; -import { useStore as useNotesStore } from "../../stores/note-store"; -import { pluralize } from "@notesnook/common"; -import { MenuItem } from "@notesnook/ui"; -import { Note, Topic } from "@notesnook/core/dist/types"; - -type TopicProps = { item: Topic }; -function Topic(props: TopicProps) { - const { item: topic } = props; - const isOpened = useNotesStore( - (store) => store.context?.value?.topic === topic.id - ); - - return ( - navigate(`/notebooks/${topic.notebookId}/${topic.id}`)} - title={topic.title} - footer={0} // getTotalNotes(topic)} - menuItems={menuItems} - /> - ); -} - -export default React.memo(Topic, (prev, next) => { - return prev?.item?.title === next?.item?.title; -}); - -const menuItems: (topic: Topic, items?: Topic[]) => MenuItem[] = ( - topic, - items = [] -) => { - const defaultNotebook = db.settings.getDefaultNotebook(); - return [ - { - type: "button", - key: "edit", - title: "Edit", - icon: Edit.path, - onClick: () => - hashNavigate(`/notebooks/${topic.notebookId}/topics/${topic.id}/edit`) - }, - { - type: "button", - key: "set-as-default", - title: "Set as default", - checked: - defaultNotebook?.id === topic.notebookId && - defaultNotebook?.topic === topic.id, - icon: TopicIcon.path, - onClick: async () => { - const defaultNotebook = db.settings.getDefaultNotebook(); - const isDefault = - defaultNotebook?.id === topic.notebookId && - defaultNotebook?.topic === topic.id; - - await db.settings.setDefaultNotebook( - isDefault ? undefined : { id: topic.notebookId, topic: topic.id } - ); - } - }, - { - type: "button", - key: "shortcut", - title: db.shortcuts.exists(topic.id) - ? "Remove shortcut" - : "Create shortcut", - icon: Shortcut.path, - onClick: () => appStore.addToShortcuts(topic) - }, - { key: "sep", type: "separator" }, - { - type: "button", - key: "delete", - title: "Delete", - icon: Trash.path, - variant: "dangerous", - onClick: async () => { - const result = await confirm({ - title: `Delete ${pluralize(items.length, "topic")}?`, - positiveButtonText: `Yes`, - negativeButtonText: "No", - checks: { - deleteContainingNotes: { - text: `Move all notes in ${ - items.length > 1 ? "these topics" : "this topic" - } to trash` - } - } - }); - - if (result) { - if (result.deleteContainingNotes) { - const notes: Note[] = []; - for (const item of items) { - const topic = db.notebooks.topics(item.notebookId).topic(item.id); - if (!topic) continue; - notes.push(...topic.all); - } - await Multiselect.moveNotesToTrash(notes, false); - } - await Multiselect.deleteTopics(topic.notebookId, items); - } - }, - multiSelect: true - } - ]; -}; diff --git a/apps/web/src/components/trash-item/index.tsx b/apps/web/src/components/trash-item/index.tsx index cc961a640..f3805ab2f 100644 --- a/apps/web/src/components/trash-item/index.tsx +++ b/apps/web/src/components/trash-item/index.tsx @@ -66,9 +66,9 @@ function TrashItem(props: TrashItemProps) { } export default TrashItem; -const menuItems: (item: TrashItem, items?: TrashItem[]) => MenuItem[] = ( +const menuItems: (item: TrashItem, ids?: string[]) => MenuItem[] = ( item, - items = [] + ids = [] ) => { return [ { @@ -77,8 +77,8 @@ const menuItems: (item: TrashItem, items?: TrashItem[]) => MenuItem[] = ( title: "Restore", icon: Restore.path, onClick: () => { - store.restore(items.map((i) => i.id)); - showToast("success", `${pluralize(items.length, "item")} restored`); + store.restore(ids); + showToast("success", `${pluralize(ids.length, "item")} restored`); }, multiSelect: true }, @@ -89,10 +89,9 @@ const menuItems: (item: TrashItem, items?: TrashItem[]) => MenuItem[] = ( icon: DeleteForver.path, variant: "dangerous", onClick: async () => { - if (!(await showMultiPermanentDeleteConfirmation(items.length))) return; - const ids = items.map((i) => i.id); + if (!(await showMultiPermanentDeleteConfirmation(ids.length))) return; showUndoableToast( - `${pluralize(items.length, "item")} permanently deleted`, + `${pluralize(ids.length, "item")} permanently deleted`, () => store.delete(ids), () => store.delete(ids, true), () => store.refresh() diff --git a/apps/web/src/dialogs/add-notebook-dialog.jsx b/apps/web/src/dialogs/add-notebook-dialog.jsx deleted file mode 100644 index a3c791181..000000000 --- a/apps/web/src/dialogs/add-notebook-dialog.jsx +++ /dev/null @@ -1,282 +0,0 @@ -/* -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 . -*/ - -import React from "react"; -import { Flex, Text } from "@theme-ui/components"; -import { Notebook, Checkmark, Plus, Topic } from "../components/icons"; -import Dialog from "../components/dialog"; -import qclone from "qclone"; -import Field from "../components/field"; -import { showToast } from "../utils/toast"; - -class AddNotebookDialog extends React.Component { - title = ""; - description = ""; - id = undefined; - deletedTopics = []; - state = { - topics: [], - isEditting: false, - editIndex: -1 - }; - - removeTopic(index) { - const topics = this.state.topics.slice(); - if (!topics[index]) return; - if (topics[index].id) { - this.deletedTopics.push(topics[index].id); - } - topics.splice(index, 1); - this.setState({ - topics - }); - } - - addTopic(topicTitle) { - if (topicTitle.trim().length <= 0) return; - - const topics = this.state.topics.slice(); - topics.push({ title: topicTitle }); - this.setState({ - topics - }); - this.resetTopicInput(); - } - - editTopic(index) { - this._topicInputRef.value = this.state.topics[index].title; - this._topicInputRef.focus(); - this.setState({ isEditting: true, editIndex: index }); - } - - doneEditingTopic() { - this.setState({ isEditting: false, editIndex: -1 }); - this.resetTopicInput(); - } - - resetTopicInput() { - this._topicInputRef.value = ""; - this._topicInputRef.focus(); - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.isOpen === false) { - this._reset(); - } - } - - componentDidMount() { - if (!this.props.notebook) return; - const { title, description, id, topics } = qclone(this.props.notebook); - this.setState({ - topics - }); - this.title = title; - this.notebookTitle = title; - this.description = description; - this.id = id; - } - - _reset() { - this.title = ""; - this.notebookTitle = ""; - this.description = ""; - this.id = undefined; - this.setState({ - topics: [] - }); - } - - createNotebook() { - if (!this.title.trim()) - return showToast("error", "Notebook title cannot be empty."); - - const notebook = { - title: this.title, - description: this.description, - topics: this.state.topics, - id: this.id - }; - this.props.onDone(notebook, this.deletedTopics); - } - - render() { - const props = this.props; - return ( - { - this.createNotebook(); - } - }} - onClose={() => props.onClose(false)} - negativeButton={{ text: "Cancel", onClick: () => props.onClose(false) }} - > - - (this.title = e.target.value)} - onKeyUp={(e) => { - if (e.key === "Enter") { - this.createNotebook(); - } - }} - /> - (this.description = e.target.value)} - defaultValue={this.description} - helpText="Optional" - sx={{ mt: 1 }} - /> - (this._topicInputRef = ref)} - data-test-id="edit-topic-input" - label="Topics" - name="topic" - id="topic" - action={{ - testId: "edit-topic-action", - onClick: () => { - if (this.state.isEditting) { - this.doneEditingTopic(); - } else { - this.addTopic(this._topicInputRef.value); - } - }, - icon: this.state.isEditting ? Checkmark : Plus - }} - onChange={(e) => { - if (!this.state.isEditting) return; - const topics = this.state.topics.slice(); - topics[this.state.editIndex].title = e.target.value; - this.setState({ - topics - }); - }} - onKeyUp={(e) => { - if (e.key === "Enter") { - if (this.state.isEditting) { - this.doneEditingTopic(); - } else { - this.addTopic(e.target.value); - } - } - }} - helpText="Press enter to add a topic (optional)" - sx={{ mt: 1 }} - /> - {this.state.topics.map((topic, index) => ( - this.editTopic(index)} - onDoneEditing={() => this.doneEditingTopic()} - onDelete={() => this.removeTopic(index)} - /> - ))} - - - ); - } -} - -export default AddNotebookDialog; - -function TopicItem(props) { - const { title, onEdit, onDoneEditing, onDelete, isEditing, hideActions } = - props; - return ( - - - - - {title} - - - {!hideActions && ( - - - {isEditing ? "Done" : "Edit"} - - { - e.stopPropagation(); - onDelete(); - }} - sx={{ - alignItems: "center", - ":hover": { opacity: 0.8 }, - height: "25px", - color: "red", - display: "flex" - }} - > - Delete - - - )} - - ); -} diff --git a/apps/web/src/dialogs/add-notebook-dialog.tsx b/apps/web/src/dialogs/add-notebook-dialog.tsx new file mode 100644 index 000000000..d0668c51a --- /dev/null +++ b/apps/web/src/dialogs/add-notebook-dialog.tsx @@ -0,0 +1,116 @@ +/* +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 . +*/ + +import { useRef, useCallback } from "react"; +import Dialog from "../components/dialog"; +import Field from "../components/field"; +import { showToast } from "../utils/toast"; +import { Notebook } from "@notesnook/core"; +import { Perform } from "../common/dialog-controller"; +import { store as noteStore } from "../stores/note-store"; +import { store as notebookStore } from "../stores/notebook-store"; +import { store as appStore } from "../stores/app-store"; +import { db } from "../common/db"; + +type AddNotebookDialogProps = { + parentId?: string; + edit?: boolean; + notebook?: Notebook; + onClose: Perform; +}; + +function AddNotebookDialog(props: AddNotebookDialogProps) { + const { notebook, onClose, parentId } = props; + const title = useRef(notebook?.title || ""); + const description = useRef(notebook?.description || ""); + + const onSubmit = useCallback(async () => { + if (!title.current.trim()) + return showToast("error", "Notebook title cannot be empty."); + + const id = await db.notebooks.add({ + id: props.notebook?.id, + title: title.current, + description: description.current + }); + if (parentId) { + await db.relations.add( + { type: "notebook", id: parentId }, + { type: "notebook", id } + ); + } + + await notebookStore.refresh(); + await noteStore.refresh(); + await appStore.refreshNavItems(); + + showToast( + "success", + props.edit + ? "Notebook edited successfully!" + : "Notebook created successfully" + ); + onClose(true); + }, [props.notebook?.id, props.edit, onClose, parentId]); + return ( + onClose(false)} + positiveButton={{ + text: props.edit ? "Save" : "Create", + onClick: onSubmit + }} + negativeButton={{ text: "Cancel", onClick: () => onClose(false) }} + > + (title.current = e.target.value)} + onKeyUp={async (e) => { + if (e.key === "Enter") { + await onSubmit(); + } + }} + /> + (description.current = e.target.value)} + defaultValue={description.current} + helpText="Optional" + sx={{ mt: 1 }} + /> + + ); +} + +export default AddNotebookDialog; diff --git a/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx b/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx index e357c8392..e7cc3ac70 100644 --- a/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx +++ b/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx @@ -42,15 +42,13 @@ import { Theme } from "@notesnook/theme"; import { isMacStoreApp } from "../../utils/platform"; import { isUserSubscribed } from "../../hooks/use-is-user-premium"; import { SUBSCRIPTION_STATUS } from "../../common/constants"; - -import { alpha } from "@theme-ui/color"; import BaseDialog from "../../components/dialog"; import { ScopedThemeProvider } from "../../components/theme-provider"; import { User } from "@notesnook/core/dist/api/user-manager"; type BuyDialogProps = { couponCode?: string; - plan?: "monthly" | "yearly"; + plan?: "monthly" | "yearly" | "education"; onClose: () => void; }; @@ -492,7 +490,7 @@ function SelectedPlan(props: SelectedPlanProps) { } autoFocus={pricingInfo.invalidCoupon} disabled={!!pricingInfo?.coupon} - onKeyUp={(e: KeyboardEvent) => { + onKeyUp={(e) => { if (e.code === "Enter") applyCoupon(); }} action={{ diff --git a/apps/web/src/hooks/use-tip.ts b/apps/web/src/hooks/use-tip.ts index 695803316..f7b13495e 100644 --- a/apps/web/src/hooks/use-tip.ts +++ b/apps/web/src/hooks/use-tip.ts @@ -38,7 +38,7 @@ export type TipButton = { onClick: () => void; icon?: Icon; }; -export type Context = +export type TipContext = | "notes" | "notebooks" | "tags" @@ -47,23 +47,22 @@ export type Context = | "reminders" | "monographs" | "trash" - | "topics" | "attachments"; export type Tip = { text: string; - contexts: Context[]; + contexts: TipContext[]; button?: TipButton; }; const destructiveContexts: string[] = []; -let tipState: Partial> | undefined = undefined; +let tipState: Partial> | undefined = undefined; export class TipManager { static init() {} - static tip(context: Context) { + static tip(context: TipContext) { if (!tipState) tipState = Config.get("tipState", {}); if (destructiveContexts.indexOf(context) > -1) { @@ -78,7 +77,7 @@ export class TipManager { } export const useTip = ( - context: Context, + context: TipContext, options?: { rotate: boolean; delay: number; @@ -110,7 +109,7 @@ export const useTip = ( const tips: Tip[] = [ { text: "Hold Ctrl/Cmd & click on multiple items to select them.", - contexts: ["notes", "notebooks", "tags", "topics"] + contexts: ["notes", "notebooks", "tags"] }, { text: "Monographs enable you to share your notes in a secure and private way.", @@ -129,12 +128,12 @@ const tips: Tip[] = [ contexts: ["notebooks", "notebooks"] }, { - text: "A notebook can have unlimited topics with unlimited notes.", - contexts: ["notebooks", "topics"] + text: "A notebook can have unlimited sub-notebooks with unlimited notes.", + contexts: ["notebooks"] }, { - text: "You can multi-select notes and move them to a notebook or topic at once.", - contexts: ["notebooks", "topics"] + text: "You can multi-select notes and move them to a notebook or a sub-notebook at once.", + contexts: ["notebooks"] }, { text: "Mark important notes by adding them to favorites.", @@ -150,7 +149,7 @@ const tips: Tip[] = [ }, { text: "We value your feedback so join us on Discord and share your experiences and ideas.", - contexts: ["notes", "notebooks", "tags", "topics"], + contexts: ["notes", "notebooks", "tags"], button: { title: "Join the Notesnook community", icon: ArrowTopRight, @@ -164,7 +163,7 @@ const tips: Tip[] = [ } ]; -const DEFAULT_TIPS: Record> = { +const DEFAULT_TIPS: Record> = { attachments: { text: "You have no attachments." }, @@ -193,13 +192,6 @@ const DEFAULT_TIPS: Record> = { icon: Plus } }, - topics: { - text: "You can add topics in notebooks to further organize your notes.", - button: { - ...CREATE_BUTTON_MAP.topics, - icon: Plus - } - }, reminders: { text: "You can set daily, weekly or monthly reminders & stay ahead of your tasks.", button: { ...CREATE_BUTTON_MAP.reminders, icon: Plus } diff --git a/apps/web/src/navigation/hash-routes.tsx b/apps/web/src/navigation/hash-routes.tsx index 8a972b49c..4b87ec2fb 100644 --- a/apps/web/src/navigation/hash-routes.tsx +++ b/apps/web/src/navigation/hash-routes.tsx @@ -38,10 +38,6 @@ import DiffViewer from "../components/diff-viewer"; import Unlock from "../components/unlock"; import { store as editorStore } from "../stores/editor-store"; import { isMobile } from "../utils/dimensions"; -import { - showEditTopicDialog, - showCreateTopicDialog -} from "../common/dialog-controller"; import { hashNavigate } from "."; import Editor from "../components/editor"; import { defineRoutes } from "./types"; @@ -59,24 +55,12 @@ const hashroutes = defineRoutes({ "/notebooks/:notebookId/edit": ({ notebookId }) => { showEditNotebookDialog(notebookId)?.then(afterAction); }, - "/topics/create": () => { - showCreateTopicDialog().then(afterAction); - }, "/reminders/create": () => { showAddReminderDialog().then(afterAction); }, "/reminders/:reminderId/edit": ({ reminderId }) => { showEditReminderDialog(reminderId).then(afterAction); }, - "/notebooks/:notebookId/topics/:topicId/edit": ({ - notebookId, - topicId - }: { - notebookId: string; - topicId: string; - }) => { - showEditTopicDialog(notebookId, topicId)?.then(afterAction); - }, "/tags/create": () => { showCreateTagDialog().then(afterAction); }, diff --git a/apps/web/src/navigation/index.ts b/apps/web/src/navigation/index.ts index 1471512da..061b67cc6 100644 --- a/apps/web/src/navigation/index.ts +++ b/apps/web/src/navigation/index.ts @@ -23,31 +23,22 @@ import Config from "../utils/config"; import { HashRoute } from "./hash-routes"; import { ReplaceParametersInPath } from "./types"; -export function navigate(url: string): void; -export function navigate(url: string, replace?: boolean): void; export function navigate( url: string, - query?: URLSearchParams, - replace?: boolean -): void; -export function navigate( - url: string, - replaceOrQuery?: boolean | URLSearchParams, - replace?: boolean + options: { + notify?: boolean; + replace?: boolean; + query?: URLSearchParams; + } = {} ) { - if (replaceOrQuery !== null && typeof replaceOrQuery === "object") { - url += "?" + replaceOrQuery.toString(); - } else if (replace === undefined && replaceOrQuery !== undefined) { - replace = replaceOrQuery; - } else if (replace === undefined && replaceOrQuery === undefined) { - replace = false; - } - + const { notify, query, replace } = options; + if (query !== null && typeof query === "object") + url += "?" + query.toString(); if (replace) window.history.replaceState(null, "", makeURL(url, getCurrentHash())); else window.history.pushState(null, "", makeURL(url, getCurrentHash())); - dispatchEvent(new PopStateEvent("popstate")); + if (notify) dispatchEvent(new PopStateEvent("popstate")); } type HashNavigateOptions = { diff --git a/apps/web/src/navigation/routes.tsx b/apps/web/src/navigation/routes.tsx index c99e1475b..a6123a729 100644 --- a/apps/web/src/navigation/routes.tsx +++ b/apps/web/src/navigation/routes.tsx @@ -23,11 +23,10 @@ import Notebooks from "../views/notebooks"; import Notes from "../views/notes"; import Search from "../views/search"; import Tags from "../views/tags"; -import Topics from "../views/topics"; +import Notebook from "../views/notebook"; import { navigate } from "."; import Trash from "../views/trash"; import { store as notestore } from "../stores/note-store"; -import { store as nbstore } from "../stores/notebook-store"; import Reminders from "../views/reminders"; import { defineRoutes } from "./types"; import React from "react"; @@ -38,7 +37,8 @@ type RouteResult = { key: string; type: "notes" | "notebooks" | "reminders" | "trash" | "tags" | "search"; title?: string; - component: React.FunctionComponent; + component: React.ReactNode; + props?: any; buttons?: RouteContainerButtons; }; @@ -73,19 +73,18 @@ const routes = defineRoutes({ } } }), - "/notebooks/:notebookId": ({ notebookId }) => { - const notebook = db.notebooks.notebook(notebookId); - if (!notebook) return false; - nbstore.setSelectedNotebook(notebookId); - notestore.setContext({ - type: "notebook", - value: { id: notebookId } - }); - + "/notebooks/:rootId/:notebookId?": ({ rootId, notebookId }) => { return defineRoute({ key: "notebook", type: "notes", - component: Topics, + component: Notebook, + props: { + rootId, + notebookId + }, + // () => ( + // + // ), buttons: { create: CREATE_BUTTON_MAP.notes, back: { @@ -93,37 +92,31 @@ const routes = defineRoutes({ onClick: () => navigate("/notebooks") }, search: { - title: `Search ${notebook.title} notes` - } - } - }); - }, - "/notebooks/:notebookId/:topicId": ({ notebookId, topicId }) => { - const notebook = db.notebooks.notebook(notebookId); - const topic = notebook?.topics?.topic(topicId)?._topic; - if (!topic) return false; - nbstore.setSelectedNotebook(notebookId); - notestore.setContext({ - type: "topic", - value: { id: notebookId, topic: topicId } - }); - return defineRoute({ - key: "notebook", - type: "notes", - title: topic.title, - component: Topics, - buttons: { - create: CREATE_BUTTON_MAP.notes, - back: { - title: `Go back to ${notebook.title}`, - onClick: () => navigate(`/notebooks/${notebookId}`) - }, - search: { - title: `Search ${notebook.title} ${topic.title} notes` + title: `Search notes` } } }); }, + // "/notebooks/:rootId/:notebookId": ({ notebookId, rootId }) => { + // return defineRoute({ + // key: "notebook", + // type: "notes", + // // title: topic.title, + // component: () => ( + // + // ), + // buttons: { + // create: CREATE_BUTTON_MAP.notes, + // back: { + // title: `Go back to notebooks`, // ${notebook.title}`, + // onClick: () => navigate(`/notebooks/${rootId}`) + // }, + // search: { + // title: `Search notes` + // } + // } + // }); + // }, "/favorites": () => { notestore.setContext({ type: "favorite" }); return defineRoute({ diff --git a/apps/web/src/navigation/types.ts b/apps/web/src/navigation/types.ts index 8565fe105..6d45c25ae 100644 --- a/apps/web/src/navigation/types.ts +++ b/apps/web/src/navigation/types.ts @@ -17,7 +17,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -type IsParameter = Part extends `:${infer Parameter}` ? Parameter : never; +type IsParameter = Part extends `:${infer Parameter}?` + ? Parameter + : Part extends `:${infer Parameter}` + ? Parameter + : never; type FilteredParts = Path extends `${infer PartA}/${infer PartB}` ? IsParameter | FilteredParts diff --git a/apps/web/src/stores/notebook-store.ts b/apps/web/src/stores/notebook-store.ts index 0f3ca74d3..9fd337b44 100644 --- a/apps/web/src/stores/notebook-store.ts +++ b/apps/web/src/stores/notebook-store.ts @@ -27,9 +27,7 @@ import { Notebook, VirtualizedGrouping } from "@notesnook/core"; type ViewMode = "detailed" | "compact"; class NotebookStore extends BaseStore { - notebooks: VirtualizedGrouping | undefined = undefined; - // selectedNotebook = undefined; - // selectedNotebookTopics = []; + notebooks?: VirtualizedGrouping; viewMode = Config.get("notebooks:viewMode", "detailed"); setViewMode = (viewMode: ViewMode) => { @@ -38,7 +36,7 @@ class NotebookStore extends BaseStore { }; refresh = async () => { - const notebooks = await db.notebooks.all.grouped( + const notebooks = await db.notebooks.roots.grouped( db.settings.getGroupOptions("notebooks") ); this.set({ notebooks }); @@ -55,20 +53,6 @@ class NotebookStore extends BaseStore { await db.notebooks.pin(state, ...ids); await this.refresh(); }; - - // setSelectedNotebook = (id) => { - // if (!id) return; - // const notebook = db.notebooks.notebook(id)?.data; - // if (!notebook) return; - - // this.set((state) => { - // state.selectedNotebook = notebook; - // state.selectedNotebookTopics = groupArray( - // notebook.topics, - // db.settings.getGroupOptions("topics") - // ); - // }); - // }; } const [useStore, store] = createStore(NotebookStore); diff --git a/apps/web/src/views/notebook.tsx b/apps/web/src/views/notebook.tsx new file mode 100644 index 000000000..274335929 --- /dev/null +++ b/apps/web/src/views/notebook.tsx @@ -0,0 +1,567 @@ +/* +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 . +*/ + +import { useCallback, useEffect, useRef, useState } from "react"; +import ListContainer from "../components/list-container"; +import { useStore as useAppStore } from "../stores/app-store"; +import { hashNavigate, navigate } from "../navigation"; +import { Button, Flex, Text } from "@theme-ui/components"; +import { + ChevronDown, + ChevronRight, + Edit, + Home, + MoreVertical, + Notebook2, + RemoveShortcutLink, + ShortcutLink, + SortAsc +} from "../components/icons"; +import { pluralize } from "@notesnook/common"; +import { Allotment, AllotmentHandle } from "allotment"; +import { Plus } from "../components/icons"; +import { useStore as useNotesStore } from "../stores/note-store"; +import { useStore as useNotebookStore } from "../stores/notebook-store"; +import Placeholder from "../components/placeholders"; +import { db } from "../common/db"; +import { getFormattedDate } from "@notesnook/common"; +import { showAddNotebookDialog } from "../common/dialog-controller"; +import { + UncontrolledTreeEnvironment, + Tree, + TreeItemIndex, + TreeEnvironmentRef +} from "react-complex-tree"; +// import "react-complex-tree/lib/style-modern.css"; +import SubNotebook from "../components/sub-notebook"; +import { NotebookContext } from "../components/list-container/types"; +import { FlexScrollContainer } from "../components/scroll-container"; +import { Menu } from "../hooks/use-menu"; +import Config from "../utils/config"; + +type NotebookProps = { + rootId: string; + notebookId?: string; +}; +function Notebook(props: NotebookProps) { + const { rootId, notebookId } = props; + const [isCollapsed, setIsCollapsed] = useState(false); + + const paneRef = useRef(null); + const sizes = useRef([]); + + const context = useNotesStore((store) => store.context); + const notes = useNotesStore((store) => store.contextNotes); + const refreshContext = useNotesStore((store) => store.refreshContext); + const isCompact = useNotesStore((store) => store.viewMode === "compact"); + + useEffect(() => { + const { context, setContext } = useNotesStore.getState(); + if ( + context && + context.type === "notebook" && + context.id && + (context.id === rootId || context.id === notebookId) + ) + return; + if (!notebookId && !rootId) return; + + console.log("setContext", context, notebookId, rootId); + setContext({ type: "notebook", id: notebookId || rootId }); + }, [rootId, notebookId]); + + const toggleCollapse = useCallback((isCollapsed) => { + if (!paneRef.current || !sizes.current) return; + + if (!isCollapsed) { + if (sizes.current[1] < 60) { + paneRef.current.reset(); + } else { + paneRef.current.resize(sizes.current); + } + } + }, []); + + useEffect(() => { + toggleCollapse(isCollapsed); + }, [isCollapsed, toggleCollapse]); + + console.log(context, rootId, notebookId); + if (!context || !notes || context.type !== "notebook") return null; + return ( + <> + { + const [_, topicsPane] = paneSizes; + if (topicsPane > 30 && !isCollapsed) sizes.current = paneSizes; + }} + onDragEnd={([_, topicsPane]) => { + if (topicsPane < 35 && !isCollapsed) { + setIsCollapsed(true); + } + }} + > + + + } + header={ + + } + button={{ + onClick: () => + hashNavigate("/notes/create", { + addNonce: true, + replace: true + }) + }} + /> + + + + { + setIsCollapsed((isCollapsed) => !isCollapsed); + }} + /> + + + + ); +} +export default Notebook; + +type SubNotebooksProps = { + notebookId?: string; + rootId: string; + isCollapsed: boolean; + onClick: () => void; +}; +function SubNotebooks({ + notebookId, + 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(null); + const reloadItem = useRef<(changedItemIds: TreeItemIndex[]) => void>(); + const notebooks = useNotebookStore((store) => store.notebooks); + const contextNotes = useNotesStore((store) => store.contextNotes); + const context = useNotesStore((store) => store.context); + + const saveViewState = useCallback((id: string) => { + if (!treeRef.current?.viewState) return; + Config.set(`${id}:viewState`, treeRef.current.viewState[id]); + }, []); + + useEffect(() => { + const items: TreeItemIndex[] = []; + for (const item in treeRef.current?.items) { + if (item === "root") continue; + items.push(item); + } + reloadItem.current?.(items); + }, [notebooks, notebookId]); + + useEffect(() => { + if ( + !context || + context?.type !== "notebook" || + !context.id || + !treeRef.current?.items[context.id] + ) + return; + reloadItem.current?.([context.id]); + }, [contextNotes, context]); + + return ( + + { + mouseEventCounter.current = 1; + }} + onMouseUp={() => { + mouseEventCounter.current++; + }} + onClick={() => { + if (mouseEventCounter.current === 2) onClick(); + mouseEventCounter.current = 0; + }} + > + + {isCollapsed ? : } + + NOTEBOOKS + + + + + + + + + + { + const element = document.getElementById(`id_${item.index}`); + if (!element) return; + setTimeout(() => { + element.focus(); + element.scrollIntoView(); + }); + }} + onPrimaryAction={(item) => { + const element = document.getElementById(`id_${item.index}`); + if (!element) return; + element.click(); + }} + dataProvider={{ + onDidChangeTreeData(listener) { + reloadItem.current = listener; + return { + dispose() { + reloadItem.current = undefined; + } + }; + }, + async getTreeItem(itemId) { + if (itemId === "root") { + return { + data: { notebook: { title: "Root" } }, + index: itemId, + isFolder: true, + canMove: false, + canRename: false, + children: [rootId] + }; + } + + const notebook = (await db.notebooks.notebook(itemId as string))!; + const children = await db.relations + .from({ type: "notebook", id: itemId as string }, "notebook") + .get(); + return { + index: itemId, + data: { notebook }, + children: children.map((i) => i.toId), + isFolder: children.length > 0 + }; + }, + async getTreeItems(itemIds) { + console.log("get tree items:", itemIds); + const notebooks = await db.notebooks.all.records( + itemIds as string[], + db.settings.getGroupOptions("notebooks") + ); + const allChildren = await db.relations + .from({ type: "notebook", ids: itemIds as string[] }, [ + "notebook", + "note" + ]) + .get(); + return itemIds.filter(Boolean).map((id) => { + if (id === "root") { + return { + data: { notebook: { title: "Root" } }, + index: id, + isFolder: true, + canMove: false, + canRename: false, + children: [rootId] + }; + } + + const notebook = notebooks[id]; + const children = allChildren + .filter((r) => r.fromId === id && r.toType === "notebook") + .map((r) => r.toId); + const totalNotes = allChildren.filter( + (r) => r.fromId === id && r.toType === "note" + ).length; + return { + index: id, + data: { notebook, totalNotes }, + children: children, + isFolder: children.length > 0 + }; + }); + } + }} + renderItem={(props) => ( + <> + + reloadItem.current && reloadItem.current([props.item.index]) + } + /> + {props.children} + + )} + getItemTitle={(item) => item.data.notebook.title} + viewState={{ + [rootId]: Config.get(`${rootId}:viewState`, { + expandedItems: [notebookId || rootId], + focusedItem: notebookId || rootId + }) + }} + onExpandItem={(_, id) => saveViewState(id)} + onCollapseItem={(_, id) => saveViewState(id)} + > + + + + + ); +} + +function navigateCrumb(crumb: { id: string; title: string }, rootId: string) { + if (crumb.id === "notebooks") navigate("/notebooks"); + else if (crumb.id === rootId) { + navigate(`/notebooks/${rootId}`); + } else { + navigate(`/notebooks/${rootId}/${crumb.id}`); + } +} +function NotebookHeader({ + rootId, + context +}: { + rootId: string; + context: NotebookContext; +}) { + const moreCrumbsRef = useRef(null); + const [notebook, setNotebook] = useState(context.item); + const [totalNotes, setTotalNotes] = useState(context.totalNotes); + const [crumbs, setCrumbs] = useState<{ id: string; title: string }[]>([]); + const [isShortcut, setIsShortcut] = useState(false); + const shortcuts = useAppStore((store) => store.shortcuts); + const addToShortcuts = useAppStore((store) => store.addToShortcuts); + + useEffect(() => { + setIsShortcut(shortcuts.findIndex((p) => p.id === context.id) > -1); + }, [shortcuts, context.id]); + + useEffect(() => { + (async function () { + if (!notebook) setNotebook(await db.notebooks.notebook(context.id)); + if (totalNotes === undefined) + setTotalNotes( + await db.relations + .from({ type: "notebook", id: context.id }, "note") + .count() + ); + })(); + }, [context.id, totalNotes, notebook]); + + useEffect(() => { + (async function () { + setCrumbs([ + { title: "Notebooks", id: "notebooks" }, + ...(await db.notebooks.breadcrumbs(context.id)) + ]); + })(); + }, [context.id]); + + if (!notebook) return null; + const { title, description, dateEdited } = notebook; + + return ( + + + {crumbs.length > 2 ? ( + + ) : null} + + {crumbs.slice(-2).map((crumb, index, array) => ( + <> + navigateCrumb(crumb, rootId)} + > + {crumb.title} + + {index === array.length - 1 ? null : ( + + )} + + ))} + + + {getFormattedDate(dateEdited, "date")} + + + {title} + + + + + + + + {description && ( + + {description} + + )} + + {/* {pluralize(topics.length, "topic")}, */} + {pluralize(totalNotes, "note")} + + + ); +} diff --git a/apps/web/src/views/topics.jsx b/apps/web/src/views/topics.jsx deleted file mode 100644 index ef9437527..000000000 --- a/apps/web/src/views/topics.jsx +++ /dev/null @@ -1,322 +0,0 @@ -/* -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 . -*/ - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ListContainer from "../components/list-container"; -import { useStore as useNbStore } from "../stores/notebook-store"; -import { useStore as useAppStore } from "../stores/app-store"; -import { hashNavigate, navigate } from "../navigation"; -import { Button, Flex, Text } from "@theme-ui/components"; -import { - ChevronDown, - ChevronRight, - Edit, - RemoveShortcutLink, - ShortcutLink, - SortAsc -} from "../components/icons"; -import { pluralize } from "@notesnook/common"; -import { Allotment } from "allotment"; -import { Plus } from "../components/icons"; -import { useStore as useNotesStore } from "../stores/note-store"; -import Placeholder from "../components/placeholders"; -import { showSortMenu } from "../components/group-header"; -import { db } from "../common/db"; -import { groupArray } from "@notesnook/core/dist/utils/grouping"; -import { getFormattedDate } from "@notesnook/common"; - -function Notebook() { - const [isCollapsed, setIsCollapsed] = useState(false); - /** - * @type {React.RefObject} - */ - const paneRef = useRef(null); - /** - * @type {React.RefObject<[number, number]>} - */ - const sizes = useRef([]); - - const selectedNotebook = useNbStore((store) => store.selectedNotebook); - const refresh = useNbStore((store) => store.setSelectedNotebook); - - const context = useNotesStore((store) => store.context); - const refreshContext = useNotesStore((store) => store.refreshContext); - const isCompact = useNotesStore((store) => store.viewMode === "compact"); - - useEffect(() => { - if ( - context && - context.value && - selectedNotebook && - selectedNotebook.id !== context.value.id - ) - refresh(context.value.id); - }, [selectedNotebook, context, refresh]); - - const toggleCollapse = useCallback((isCollapsed) => { - if (!paneRef.current || !sizes.current) return; - - if (!isCollapsed) { - if (sizes.current[1] < 60) { - paneRef.current.reset(); - } else { - paneRef.current.resize(sizes.current); - } - } - }, []); - - const notes = useMemo( - () => - groupArray(context?.notes || [], db.settings.getGroupOptions("notes")), - [context?.notes] - ); - - useEffect(() => { - toggleCollapse(isCollapsed); - }, [isCollapsed, toggleCollapse]); - - if (!context) return null; - return ( - <> - {context.type === "topic" && selectedNotebook ? ( - - {[ - { title: "Notebooks", onClick: () => navigate(`/notebooks/`) }, - { - title: selectedNotebook.title, - onClick: () => navigate(`/notebooks/${selectedNotebook.id}`) - } - ].map((crumb, index, array) => ( - <> - - {index === array.length - 1 ? null : } - - ))} - - ) : null} - { - const [_, topicsPane] = paneSizes; - if (topicsPane > 30 && !isCollapsed) sizes.current = paneSizes; - }} - onDragEnd={([_, topicsPane]) => { - if (topicsPane < 35 && !isCollapsed) { - setIsCollapsed(true); - } - }} - > - - - } - header={ - context?.type === "topic" ? ( - <> - ) : ( - - ) - } - button={{ - content: "Make a new note", - onClick: () => - hashNavigate("/notes/create", { - addNonce: true, - replace: true - }) - }} - /> - - - - { - setIsCollapsed((isCollapsed) => !isCollapsed); - }} - /> - - - - ); -} -export default Notebook; - -function Topics({ selectedNotebook, isCollapsed, onClick }) { - const refresh = useNbStore((store) => store.setSelectedNotebook); - const topics = useNbStore((store) => store.selectedNotebookTopics); - - // 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); - - if (!selectedNotebook) return null; - return ( - - { - mouseEventCounter.current = 1; - }} - onMouseUp={() => { - mouseEventCounter.current++; - }} - onClick={() => { - if (mouseEventCounter.current === 2) onClick(); - mouseEventCounter.current = 0; - }} - > - - {isCollapsed ? : } - - TOPICS - - - - - - - - - } - header={<>} - button={{ - content: "Add a new topic", - onClick: () => hashNavigate(`/topics/create`) - }} - /> - - ); -} - -function NotebookHeader({ notebook }) { - const { title, description, topics, dateEdited } = notebook; - const [isShortcut, setIsShortcut] = useState(false); - const shortcuts = useAppStore((store) => store.shortcuts); - const addToShortcuts = useAppStore((store) => store.addToShortcuts); - const totalNotes = 0; // getTotalNotes(notebook); - - useEffect(() => { - setIsShortcut(shortcuts.findIndex((p) => p.id === notebook.id) > -1); - }, [shortcuts, notebook]); - - return ( - - {getFormattedDate(dateEdited)} - - {title} - - - - - - - {description && ( - - {description} - - )} - - {pluralize(topics.length, "topic")}, {pluralize(totalNotes, "note")} - - - ); -} diff --git a/packages/core/src/collections/notebooks.ts b/packages/core/src/collections/notebooks.ts index 6a5f4d388..61e313540 100644 --- a/packages/core/src/collections/notebooks.ts +++ b/packages/core/src/collections/notebooks.ts @@ -130,13 +130,91 @@ export class Notebooks implements ICollection { eb.selectFrom("subNotebooks").select("subNotebooks.id") ) .where("toId", "not in", this.db.trash.cache.notes) - .select((eb) => eb.fn.count("id").as("totalNotes")) + .select((eb) => eb.fn.count("relations.toId").as("totalNotes")) .executeTakeFirst(); if (!result) return 0; return result.totalNotes; } + async notes(id: string) { + const result = await this.db + .sql() + .withRecursive(`subNotebooks(id)`, (eb) => + eb + .selectNoFrom((eb) => eb.val(id).as("id")) + .unionAll((eb) => + eb + .selectFrom(["relations", "subNotebooks"]) + .select("relations.toId as id") + .where("toType", "==", "notebook") + .where("fromType", "==", "notebook") + .whereRef("fromId", "==", "subNotebooks.id") + .where("toId", "not in", this.db.trash.cache.notebooks) + .$narrowType<{ id: string }>() + ) + ) + .selectFrom("relations") + .where("toType", "==", "note") + .where("fromType", "==", "notebook") + .where("fromId", "in", (eb) => + eb.selectFrom("subNotebooks").select("subNotebooks.id") + ) + .where("toId", "not in", this.db.trash.cache.notes) + .select("relations.toId as id") + .$narrowType<{ id: string }>() + .execute(); + + return result.map((i) => i.id); + } + + get roots() { + return this.collection.createFilter( + (qb) => + qb + .where("id", "not in", (eb) => + eb + .selectFrom("relations") + .where("toType", "==", "notebook") + .where("fromType", "==", "notebook") + .select("relations.toId as id") + .$narrowType<{ id: string }>() + ) + .where(isFalse("dateDeleted")) + .where(isFalse("deleted")), + this.db.options?.batchSize + ); + } + + async breadcrumbs(id: string) { + const ids = await this.db + .sql() + .withRecursive(`subNotebooks(id)`, (eb) => + eb + .selectNoFrom((eb) => eb.val(id).as("id")) + .unionAll((eb) => + eb + .selectFrom(["relations", "subNotebooks"]) + .select("relations.fromId as id") + .where("toType", "==", "notebook") + .where("fromType", "==", "notebook") + .whereRef("toId", "==", "subNotebooks.id") + .where("fromId", "not in", this.db.trash.cache.notebooks) + .$narrowType<{ id: string }>() + ) + ) + .selectFrom("subNotebooks") + .select("id") + .execute(); + const records = await this.all + .fields(["notebooks.id", "notebooks.title"]) + .records(ids.map((i) => i.id)); + return ids.reverse().map((id) => records[id.id]) as { + id: string; + title: string; + }[]; + } + async notebook(id: string) { const notebook = await this.collection.get(id); if (!notebook || isTrashItem(notebook)) return; diff --git a/packages/core/src/database/migrations.ts b/packages/core/src/database/migrations.ts index 3f5fbe389..ebebe5bd2 100644 --- a/packages/core/src/database/migrations.ts +++ b/packages/core/src/database/migrations.ts @@ -97,7 +97,7 @@ export class NNMigrationProvider implements MigrationProvider { .$call(addTrashColumns) .addColumn("title", "text") .addColumn("description", "text") - .addColumn("dateEdited", "text") + .addColumn("dateEdited", "integer") .addColumn("pinned", "boolean") .execute(); diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts index 76957a4c5..68484feda 100644 --- a/packages/core/src/database/sql-collection.ts +++ b/packages/core/src/database/sql-collection.ts @@ -27,7 +27,13 @@ import { SQLiteItem, isFalse } from "."; -import { ExpressionOrFactory, SelectQueryBuilder, SqlBool, sql } from "kysely"; +import { + AnyColumnWithTable, + ExpressionOrFactory, + SelectQueryBuilder, + SqlBool, + sql +} from "kysely"; import { VirtualizedGrouping } from "../utils/virtualized-grouping"; import { groupArray } from "../utils/grouping"; @@ -240,6 +246,8 @@ export class SQLCollection< } export class FilteredSelector { + private _fields: AnyColumnWithTable[] = + []; constructor( readonly type: keyof DatabaseSchema, readonly filter: SelectQueryBuilder< @@ -250,6 +258,11 @@ export class FilteredSelector { readonly batchSize: number = 500 ) {} + fields(fields: AnyColumnWithTable[]) { + this._fields = fields; + return this; + } + async ids(sortOptions?: GroupOptions) { return ( await this.filter @@ -267,7 +280,8 @@ export class FilteredSelector { .$if(!!sortOptions, (eb) => eb.$call(this.buildSortExpression(sortOptions!)) ) - .selectAll() + .$if(this._fields.length === 0, (eb) => eb.selectAll()) + .$if(this._fields.length > 0, (eb) => eb.select(this._fields)) .execute()) as T[]; } @@ -304,7 +318,8 @@ export class FilteredSelector { const item = await this.filter .where(filter) .limit(1) - .selectAll() + .$if(this._fields.length === 0, (eb) => eb.selectAll()) + .$if(this._fields.length > 0, (eb) => eb.select(this._fields)) .executeTakeFirst(); return item as T | undefined; } @@ -344,6 +359,25 @@ export class FilteredSelector { ); } + async sorted(options: GroupOptions) { + const items = await this.filter + .$call(this.buildSortExpression(options)) + .select("id") + .execute(); + const ids = items.map((item) => item.id); + return new VirtualizedGrouping(ids, this.batchSize, async (ids) => { + const results = await this.filter + .where("id", "in", ids) + .selectAll() + .execute(); + const items: Record = {}; + for (const item of results) { + items[item.id] = item as T; + } + return items; + }); + } + private buildSortExpression(options: GroupOptions) { return ( qb: SelectQueryBuilder @@ -370,7 +404,8 @@ export class FilteredSelector { let index = 0; while (true) { const rows = await this.filter - .selectAll() + .$if(this._fields.length === 0, (eb) => eb.selectAll()) + .$if(this._fields.length > 0, (eb) => eb.select(this._fields)) .orderBy("dateCreated asc") .offset(index) .limit(this.batchSize) diff --git a/packages/ui/src/components/menu/menu-separator.tsx b/packages/ui/src/components/menu/menu-separator.tsx index 216311a9d..6c7de6c64 100644 --- a/packages/ui/src/components/menu/menu-separator.tsx +++ b/packages/ui/src/components/menu/menu-separator.tsx @@ -27,7 +27,7 @@ export function MenuSeparator() { width: "95%", height: "1px", bg: "separator", - my: 2, + my: 1, alignSelf: "center" }} />