mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
web: add support for nested notebooks
This commit is contained in:
9
apps/web/package-lock.json
generated
9
apps/web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,7 @@ function CachedRouter() {
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Component key={key} />
|
||||
<Component key={key} {...RouteResult.props} />
|
||||
</Flex>
|
||||
))}
|
||||
</RouteContainer>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
163
apps/web/src/components/sub-notebook/index.tsx
Normal file
163
apps/web/src/components/sub-notebook/index.tsx
Normal 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
|
||||
];
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
116
apps/web/src/dialogs/add-notebook-dialog.tsx
Normal file
116
apps/web/src/dialogs/add-notebook-dialog.tsx
Normal 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;
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
567
apps/web/src/views/notebook.tsx
Normal file
567
apps/web/src/views/notebook.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,7 +27,7 @@ export function MenuSeparator() {
|
||||
width: "95%",
|
||||
height: "1px",
|
||||
bg: "separator",
|
||||
my: 2,
|
||||
my: 1,
|
||||
alignSelf: "center"
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user