web: add support for nested notebooks

This commit is contained in:
Abdullah Atta
2023-11-09 14:20:30 +05:00
parent 1611ed7de1
commit 982bdc1b28
32 changed files with 1207 additions and 1153 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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) => (
<Dialog
isOpen={true}
onDone={async (nb: Record<string, unknown>) => {
// 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) => (
<Dialog
isOpen={true}
notebook={notebook}
edit={true}
onDone={async (nb: Notebook, deletedTopics: string[]) => {
// 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) => (
<Dialog
title={"Create topic"}
subtitle={"You can create as many topics as you want."}
onClose={() => {
perform(false);
}}
onAction={async (topic: Record<string, unknown>) => {
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) => (
<Dialog
title={"Edit topic"}
subtitle={`You are editing "${topic.title}" topic.`}
defaultValue={topic.title}
icon={TopicIcon}
item={topic}
onClose={() => 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) => (
<Dialog

View File

@@ -59,7 +59,7 @@ function CachedRouter() {
flex: 1
}}
>
<Component key={key} />
<Component key={key} {...RouteResult.props} />
</Flex>
))}
</RouteContainer>

View File

@@ -18,65 +18,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<HTMLInputElement>;
["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) {
<Flex mt={1} sx={{ position: "relative" }}>
<Input
as={as}
data-test-id={props["data-test-id"]}
variant={variant}
defaultValue={defaultValue}
ref={inputRef}
autoFocus={autoFocus}
required={required}
name={name}
{...inputProps}
id={id}
disabled={disabled}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
pattern={pattern}
type={type || "text"}
min={min}
value={value}
type={isPasswordVisible ? "text" : type || "text"}
sx={{
flex: 1,
...styles.input,
...styles?.input,
":disabled": {
bg: "background-disabled"
},
colorScheme
}}
onChange={(e) => {
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" && (
<Flex
onClick={() => {
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) {
</Button>
)}
</Flex>
{validatePassword && (
<Flex mt={1} sx={{ flexDirection: "column" }}>
{rules.map((rule) => (
<Flex key={rule.title}>
{rule.isValid ? (
<Check color="icon-success" size={14} />
) : (
<Cross color="icon-error" size={14} />
)}
<Text ml={1} sx={{ fontSize: "body", color: "paragraph" }}>
{rule.title}
</Text>
</Flex>
))}
</Flex>
)}
</Flex>
);
}

View File

@@ -163,8 +163,7 @@ function ListContainer(props: ListContainerProps) {
}}
itemContent={(index, item) => {
if (isGroupHeader(item)) {
if (!group)
return <div style={{ height: 28, width: "100%" }} />;
if (!group) return null;
return (
<GroupHeader
groupingKey={group}

View File

@@ -20,7 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 (
<SubNotebook
item={item}
totalNotes={totalNotes.current}
notebookId={context.id}
/>
);
return (
<Notebook
item={item}
@@ -111,8 +120,6 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
return <TrashItem item={item} date={getDate(item, type)} />;
case "reminder":
return <Reminder item={item} />;
case "topic":
return <Topic item={item} />;
case "tag":
return <Tag item={item} totalNotes={totalNotes.current} />;
default:
@@ -146,7 +153,7 @@ function getDate(item: Item, groupType?: GroupingKey): number {
);
}
async function resolveItems(ids: string[], items: Record<string, Item>) {
export async function resolveItems(ids: string[], items: Record<string, Item>) {
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<string>;
colors: Set<string>;

View File

@@ -18,12 +18,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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";
};

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<TItem extends Item, TContext> = {
onKeyPress?: (e: React.KeyboardEvent<HTMLDivElement>) => 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<TItem extends Item, TContext>(
@@ -65,7 +69,9 @@ function ListItem<TItem extends Item, TContext>(
isCompact,
isDisabled,
isSimple,
item
item,
sx,
context
} = props;
const listItemRef = useRef<HTMLDivElement>(null);
@@ -95,10 +101,9 @@ function ListItem<TItem extends Item, TContext>(
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<TItem extends Item, TContext>(
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<TItem extends Item, TContext>(
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<TItem extends Item, TContext>(
>
{!isCompact && props.header}
<Text
data-test-id={`title`}
variant={isSimple || isCompact ? "body" : "subtitle"}
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: isCompact || isSimple ? "body" : "bold",
color:
selected && heading === "heading" ? `${heading}-selected` : heading,
display: "block"
}}
>
{props.title}
</Text>
{typeof props.title === "string" ? (
<Text
data-test-id={`title`}
variant={isSimple || isCompact ? "body" : "subtitle"}
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: isCompact || isSimple ? "body" : "bold",
color:
selected && heading === "heading"
? `${heading}-selected`
: heading,
display: "block"
}}
>
{props.title}
</Text>
) : (
props.title
)}
{!isSimple && !isCompact && props.body && (
<Text

View File

@@ -132,7 +132,10 @@ function NavigationMenu(props: NavigationMenuProps) {
useEffect(() => {
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 (

View File

@@ -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) {
<Flex
sx={{ alignItems: "center", flexWrap: "wrap", gap: 1, mt: "small" }}
>
{notebooks?.items.map((notebook) => (
<IconTag
key={notebook.id}
onClick={() => {
navigate(`/notebooks/${notebook.id}`);
}}
text={notebook.title}
icon={Notebook}
/>
))}
{context?.type !== "notebook" &&
notebooks?.items.map((notebook) => (
<IconTag
key={notebook.id}
onClick={() => {
navigate(`/notebooks/${notebook.id}`);
}}
text={notebook.title}
icon={Notebook}
/>
))}
{reminder && isReminderActive(reminder) && (
<IconTag
icon={Reminder}
@@ -554,6 +564,7 @@ function colorsToMenuItems(
}
function notebooksMenuItems(ids: string[]): MenuItem[] {
console.log("NOTE IDS", ids);
return [
{
type: "button",

View File

@@ -21,8 +21,8 @@ import React from "react";
import { Flex, Text } from "@theme-ui/components";
import ListItem from "../list-item";
import { useStore, store } from "../../stores/notebook-store";
import { useStore as useNotesStore } from "../../stores/note-store";
import { store as appStore } from "../../stores/app-store";
import { showUnpinnedToast } from "../../common/toasts";
import { db } from "../../common/db";
import {
Topic as TopicIcon,
@@ -36,13 +36,12 @@ import {
} from "../icons";
import { hashNavigate, navigate } from "../../navigation";
import IconTag from "../icon-tag";
import { showToast } from "../../utils/toast";
import { Multiselect } from "../../common/multi-select";
import { pluralize } from "@notesnook/common";
import { confirm } from "../../common/dialog-controller";
import { getFormattedDate } from "@notesnook/common";
import { MenuItem } from "@notesnook/ui";
import { Note, Notebook } from "@notesnook/core/dist/types";
import { Notebook } from "@notesnook/core";
type NotebookProps = {
item: Notebook;
@@ -60,12 +59,15 @@ function Notebook(props: NotebookProps) {
isCompact={isCompact}
isSimple={simplified}
item={notebook}
onClick={() => {
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

View File

@@ -18,12 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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);

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<ListItem
isFocused={isOpened}
isCompact
item={item}
onClick={async () => {
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={
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
{isExpandable ? (
isExpanded ? (
<ChevronDown
size={16}
onClick={(e) => {
e.stopPropagation();
collapse();
}}
/>
) : (
<ChevronRight
size={16}
onClick={(e) => {
e.stopPropagation();
expand();
}}
/>
)
) : null}
<Text
data-test-id={`title`}
variant={"body"}
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: "body",
display: "block"
}}
>
{item.title}
</Text>
</Flex>
}
footer={<Text variant="subBody">{totalNotes}</Text>}
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
];
};

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<ListItem
isFocused={isOpened}
isCompact
item={topic}
onClick={() => navigate(`/notebooks/${topic.notebookId}/${topic.id}`)}
title={topic.title}
footer={<Text variant="subBody">0</Text>} // 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
}
];
};

View File

@@ -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()

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<Dialog
isOpen={props.isOpen}
title={props.edit ? "Edit Notebook" : "Create a Notebook"}
description={
props.edit
? `You are editing "${this.notebookTitle}".`
: "Notebooks are the best way to organize your notes."
}
icon={Notebook}
positiveButton={{
text: props.edit ? "Save" : "Create",
onClick: () => {
this.createNotebook();
}
}}
onClose={() => props.onClose(false)}
negativeButton={{ text: "Cancel", onClick: () => props.onClose(false) }}
>
<Flex sx={{ overflowY: "auto", flexDirection: "column" }}>
<Field
defaultValue={this.title}
data-test-id="title-input"
autoFocus
required
label="Title"
name="title"
id="title"
onChange={(e) => (this.title = e.target.value)}
onKeyUp={(e) => {
if (e.key === "Enter") {
this.createNotebook();
}
}}
/>
<Field
data-test-id="description-input"
label="Description"
name="description"
id="description"
onChange={(e) => (this.description = e.target.value)}
defaultValue={this.description}
helpText="Optional"
sx={{ mt: 1 }}
/>
<Field
inputRef={(ref) => (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) => (
<TopicItem
key={topic.id || topic.title || index}
title={topic.title}
isEditing={this.state.editIndex === index}
onEdit={() => this.editTopic(index)}
onDoneEditing={() => this.doneEditingTopic()}
onDelete={() => this.removeTopic(index)}
/>
))}
</Flex>
</Dialog>
);
}
}
export default AddNotebookDialog;
function TopicItem(props) {
const { title, onEdit, onDoneEditing, onDelete, isEditing, hideActions } =
props;
return (
<Flex
p={2}
pl={0}
sx={{
borderWidth: 1,
borderBottomColor: isEditing ? "accent" : "border",
borderBottomStyle: "solid",
cursor: "pointer",
":hover": { borderBottomColor: "accent" },
alignItems: "center",
justifyContent: "space-between"
}}
onClick={isEditing ? onDoneEditing : onEdit}
data-test-id="topic-item"
>
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
<Topic />
<Text as="span" ml={1} sx={{ fontSize: "body", color: "paragraph" }}>
{title}
</Text>
</Flex>
{!hideActions && (
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
<Text
variant="subBody"
sx={{
alignItems: "center",
":hover": { opacity: 0.8 },
height: "25px",
color: "accent",
display: "flex"
}}
onClick={isEditing ? onDoneEditing : onEdit}
>
{isEditing ? "Done" : "Edit"}
</Text>
<Text
variant="subBody"
ml={2}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
sx={{
alignItems: "center",
":hover": { opacity: 0.8 },
height: "25px",
color: "red",
display: "flex"
}}
>
Delete
</Text>
</Flex>
)}
</Flex>
);
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<string>(notebook?.title || "");
const description = useRef<string>(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 (
<Dialog
isOpen={true}
title={props.edit ? "Edit Notebook" : "Create a Notebook"}
description={
props.edit
? `You are editing "${notebook?.title}".`
: "Notebooks are the best way to organize your notes."
}
onClose={() => onClose(false)}
positiveButton={{
text: props.edit ? "Save" : "Create",
onClick: onSubmit
}}
negativeButton={{ text: "Cancel", onClick: () => onClose(false) }}
>
<Field
defaultValue={title.current}
data-test-id="title-input"
autoFocus
required
label="Title"
name="title"
id="title"
onChange={(e) => (title.current = e.target.value)}
onKeyUp={async (e) => {
if (e.key === "Enter") {
await onSubmit();
}
}}
/>
<Field
data-test-id="description-input"
label="Description"
name="description"
id="description"
onChange={(e) => (description.current = e.target.value)}
defaultValue={description.current}
helpText="Optional"
sx={{ mt: 1 }}
/>
</Dialog>
);
}
export default AddNotebookDialog;

View File

@@ -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={{

View File

@@ -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<Record<Context, boolean>> | undefined = undefined;
let tipState: Partial<Record<TipContext, boolean>> | 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<Context, Omit<Tip, "contexts">> = {
const DEFAULT_TIPS: Record<TipContext, Omit<Tip, "contexts">> = {
attachments: {
text: "You have no attachments."
},
@@ -193,13 +192,6 @@ const DEFAULT_TIPS: Record<Context, Omit<Tip, "contexts">> = {
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 }

View File

@@ -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);
},

View File

@@ -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 = {

View File

@@ -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
},
// () => (
// <Notebook key={rootId} rootId={rootId} notebookId={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: () => (
// <Notebook key={rootId} rootId={rootId} notebookId={notebookId} />
// ),
// 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({

View File

@@ -17,7 +17,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
type IsParameter<Part> = Part extends `:${infer Parameter}` ? Parameter : never;
type IsParameter<Part> = Part extends `:${infer Parameter}?`
? Parameter
: Part extends `:${infer Parameter}`
? Parameter
: never;
type FilteredParts<Path> = Path extends `${infer PartA}/${infer PartB}`
? IsParameter<PartA> | FilteredParts<PartB>

View File

@@ -27,9 +27,7 @@ import { Notebook, VirtualizedGrouping } from "@notesnook/core";
type ViewMode = "detailed" | "compact";
class NotebookStore extends BaseStore<NotebookStore> {
notebooks: VirtualizedGrouping<Notebook> | undefined = undefined;
// selectedNotebook = undefined;
// selectedNotebookTopics = [];
notebooks?: VirtualizedGrouping<Notebook>;
viewMode = Config.get<ViewMode>("notebooks:viewMode", "detailed");
setViewMode = (viewMode: ViewMode) => {
@@ -38,7 +36,7 @@ class NotebookStore extends BaseStore<NotebookStore> {
};
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<NotebookStore> {
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);

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<AllotmentHandle>(null);
const sizes = useRef<number[]>([]);
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 (
<>
<Allotment
ref={paneRef}
vertical
onChange={(paneSizes) => {
const [_, topicsPane] = paneSizes;
if (topicsPane > 30 && !isCollapsed) sizes.current = paneSizes;
}}
onDragEnd={([_, topicsPane]) => {
if (topicsPane < 35 && !isCollapsed) {
setIsCollapsed(true);
}
}}
>
<Allotment.Pane>
<Flex variant="columnFill" sx={{ height: "100%" }}>
<ListContainer
group="notes"
refresh={refreshContext}
compact={isCompact}
context={context}
items={notes}
placeholder={<Placeholder context="notes" />}
header={
<NotebookHeader
key={context.id}
rootId={rootId}
context={context}
/>
}
button={{
onClick: () =>
hashNavigate("/notes/create", {
addNonce: true,
replace: true
})
}}
/>
</Flex>
</Allotment.Pane>
<Allotment.Pane
preferredSize={250}
visible
maxSize={isCollapsed ? 30 : Infinity}
>
<SubNotebooks
notebookId={notebookId}
isCollapsed={isCollapsed}
rootId={rootId}
onClick={() => {
setIsCollapsed((isCollapsed) => !isCollapsed);
}}
/>
</Allotment.Pane>
</Allotment>
</>
);
}
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<TreeEnvironmentRef>(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 (
<Flex id="topics" variant="columnFill" sx={{ height: "100%" }}>
<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 }}>
NOTEBOOKS
</Text>
</Flex>
<Flex sx={{ alignItems: "center" }}>
<Button
variant="secondary"
data-test-id="topics-sort-button"
sx={{
p: "small",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={(e) => {
e.stopPropagation();
// showSortMenu("topics", () => refresh(selectedNotebook.id));
}}
>
<SortAsc size={15} />
</Button>
<Button
variant="secondary"
sx={{
p: "1px",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={async (e) => {
e.stopPropagation();
await showAddNotebookDialog(notebookId);
}}
>
<Plus size={20} />
</Button>
</Flex>
</Flex>
<FlexScrollContainer>
<UncontrolledTreeEnvironment
ref={treeRef}
onFocusItem={(item) => {
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) => (
<>
<SubNotebook
item={props.item.data.notebook}
totalNotes={props.item.data.totalNotes}
depth={props.depth}
isExpandable={props.item.isFolder || false}
isExpanded={props.context.isExpanded || false}
collapse={props.context.collapseItem}
expand={props.context.expandItem}
focus={props.context.focusItem}
rootId={rootId}
refresh={() =>
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)}
>
<Tree treeId={rootId} rootItem="root" treeLabel="Tree Example" />
</UncontrolledTreeEnvironment>
</FlexScrollContainer>
</Flex>
);
}
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<HTMLButtonElement>(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 (
<Flex mx={2} my={2} sx={{ flexDirection: "column", minWidth: 200 }}>
<Flex sx={{ alignItems: "center", mb: 1 }}>
{crumbs.length > 2 ? (
<Button
ref={moreCrumbsRef}
variant="icon"
sx={{ p: 0, flexShrink: 0 }}
onClick={() => {
if (!moreCrumbsRef.current) return;
Menu.openMenu(
crumbs
.slice(0, -2)
.reverse()
.map((c) => ({
type: "button",
title: c.title,
key: c.id,
icon: c.id === "notebooks" ? Home.path : Notebook2.path,
onClick: () => navigateCrumb(c, rootId)
})),
{
position: {
target: moreCrumbsRef.current,
location: "below",
isTargetAbsolute: true,
align: "start",
yOffset: 10
}
}
);
}}
>
<MoreVertical size={14} />
</Button>
) : null}
<Text
as="p"
sx={{
lineHeight: 0.7,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden"
}}
>
{crumbs.slice(-2).map((crumb, index, array) => (
<>
<Text
as="span"
sx={{
fontSize: "subBody",
textDecoration: "none",
color: "var(--paragraph-secondary)",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
cursor: "pointer",
":hover": { color: "paragraph-hover" }
}}
onClick={() => navigateCrumb(crumb, rootId)}
>
{crumb.title}
</Text>
{index === array.length - 1 ? null : (
<ChevronRight
as="span"
sx={{ display: "inline", verticalAlign: "middle" }}
size={14}
/>
)}
</>
))}
</Text>
</Flex>
<Text variant="subBody">{getFormattedDate(dateEdited, "date")}</Text>
<Flex sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Text variant="heading" sx={{ fontSize: "subheading" }}>
{title}
</Text>
<Flex>
<Button
variant="secondary"
sx={{ borderRadius: 100, width: 30, height: 30 }}
mr={1}
p={0}
title={isShortcut ? "Remove shortcut" : "Create shortcut"}
onClick={() => addToShortcuts(notebook)}
>
{isShortcut ? (
<RemoveShortcutLink size={16} />
) : (
<ShortcutLink size={16} />
)}
</Button>
<Button
variant="secondary"
sx={{ borderRadius: 100, width: 30, height: 30 }}
p={0}
title="Edit notebook"
onClick={() => hashNavigate(`/notebooks/${notebook.id}/edit`)}
>
<Edit size={16} />
</Button>
</Flex>
</Flex>
{description && (
<Text variant="body" sx={{ fontSize: "subtitle" }}>
{description}
</Text>
)}
<Text as="em" variant="subBody" mt={2}>
{/* {pluralize(topics.length, "topic")}, */}
{pluralize(totalNotes, "note")}
</Text>
</Flex>
);
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<import("allotment").AllotmentHandle>}
*/
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 ? (
<Flex sx={{ alignItems: "center", mx: 2, mb: 1 }}>
{[
{ title: "Notebooks", onClick: () => navigate(`/notebooks/`) },
{
title: selectedNotebook.title,
onClick: () => navigate(`/notebooks/${selectedNotebook.id}`)
}
].map((crumb, index, array) => (
<>
<Button
variant="anchor"
sx={{
fontSize: "subBody",
textDecoration: "none",
color: "var(--paragraph-secondary)"
}}
onClick={crumb.onClick}
>
{crumb.title}
</Button>
{index === array.length - 1 ? null : <ChevronRight size={18} />}
</>
))}
</Flex>
) : null}
<Allotment
ref={paneRef}
vertical
onChange={(paneSizes) => {
const [_, topicsPane] = paneSizes;
if (topicsPane > 30 && !isCollapsed) sizes.current = paneSizes;
}}
onDragEnd={([_, topicsPane]) => {
if (topicsPane < 35 && !isCollapsed) {
setIsCollapsed(true);
}
}}
>
<Allotment.Pane>
<Flex variant="columnFill" sx={{ height: "100%" }}>
<ListContainer
group="notes"
refresh={refreshContext}
compact={isCompact}
context={{ ...context, notes: undefined }}
items={notes}
placeholder={<Placeholder context="notes" />}
header={
context?.type === "topic" ? (
<></>
) : (
<NotebookHeader notebook={selectedNotebook} />
)
}
button={{
content: "Make a new note",
onClick: () =>
hashNavigate("/notes/create", {
addNonce: true,
replace: true
})
}}
/>
</Flex>
</Allotment.Pane>
<Allotment.Pane
preferredSize={250}
visible
maxSize={isCollapsed ? 30 : Infinity}
>
<Topics
selectedNotebook={selectedNotebook}
isCollapsed={isCollapsed}
onClick={() => {
setIsCollapsed((isCollapsed) => !isCollapsed);
}}
/>
</Allotment.Pane>
</Allotment>
</>
);
}
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 (
<Flex id="topics" variant="columnFill" sx={{ height: "100%" }}>
<Flex
sx={{
m: 1,
ml: 2,
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 }}>
TOPICS
</Text>
</Flex>
<Flex sx={{ alignItems: "center" }}>
<Button
variant="secondary"
data-test-id="topics-sort-button"
sx={{
p: "small",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={(e) => {
e.stopPropagation();
showSortMenu("topics", () => refresh(selectedNotebook.id));
}}
>
<SortAsc size={15} />
</Button>
<Button
variant="secondary"
sx={{
p: "1px",
bg: "transparent",
visibility: isCollapsed ? "collapse" : "visible"
}}
onClick={(e) => {
e.stopPropagation();
hashNavigate(`/topics/create`);
}}
>
<Plus size={20} />
</Button>
</Flex>
</Flex>
<ListContainer
group="topics"
items={topics}
context={{
notebookId: selectedNotebook.id
}}
placeholder={<Placeholder context="topics" />}
header={<></>}
button={{
content: "Add a new topic",
onClick: () => hashNavigate(`/topics/create`)
}}
/>
</Flex>
);
}
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 (
<Flex mx={2} my={2} sx={{ flexDirection: "column", minWidth: 200 }}>
<Text variant="subBody">{getFormattedDate(dateEdited)}</Text>
<Flex sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Text variant="heading">{title}</Text>
<Flex>
<Button
variant="secondary"
sx={{ borderRadius: 100, width: 30, height: 30 }}
mr={1}
p={0}
title={isShortcut ? "Remove shortcut" : "Create shortcut"}
onClick={() => addToShortcuts(notebook)}
>
{isShortcut ? (
<RemoveShortcutLink size={16} />
) : (
<ShortcutLink size={16} />
)}
</Button>
<Button
variant="secondary"
sx={{ borderRadius: 100, width: 30, height: 30 }}
p={0}
title="Edit notebook"
onClick={() => hashNavigate(`/notebooks/${notebook.id}/edit`)}
>
<Edit size={16} />
</Button>
</Flex>
</Flex>
{description && (
<Text variant="body" sx={{ fontSize: "subtitle" }}>
{description}
</Text>
)}
<Text as="em" variant="subBody" mt={2}>
{pluralize(topics.length, "topic")}, {pluralize(totalNotes, "note")}
</Text>
</Flex>
);
}

View File

@@ -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<number>("id").as("totalNotes"))
.select((eb) => eb.fn.count<number>("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<Notebook>(
(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;

View File

@@ -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();

View File

@@ -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<T extends Item> {
private _fields: AnyColumnWithTable<DatabaseSchema, keyof DatabaseSchema>[] =
[];
constructor(
readonly type: keyof DatabaseSchema,
readonly filter: SelectQueryBuilder<
@@ -250,6 +258,11 @@ export class FilteredSelector<T extends Item> {
readonly batchSize: number = 500
) {}
fields(fields: AnyColumnWithTable<DatabaseSchema, keyof DatabaseSchema>[]) {
this._fields = fields;
return this;
}
async ids(sortOptions?: GroupOptions) {
return (
await this.filter
@@ -267,7 +280,8 @@ export class FilteredSelector<T extends Item> {
.$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<T extends Item> {
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<T extends Item> {
);
}
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<T>(ids, this.batchSize, async (ids) => {
const results = await this.filter
.where("id", "in", ids)
.selectAll()
.execute();
const items: Record<string, T> = {};
for (const item of results) {
items[item.id] = item as T;
}
return items;
});
}
private buildSortExpression(options: GroupOptions) {
return <T>(
qb: SelectQueryBuilder<DatabaseSchema, keyof DatabaseSchema, T>
@@ -370,7 +404,8 @@ export class FilteredSelector<T extends Item> {
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)

View File

@@ -27,7 +27,7 @@ export function MenuSeparator() {
width: "95%",
height: "1px",
bg: "separator",
my: 2,
my: 1,
alignSelf: "center"
}}
/>