From 3f55422d45b322eb51766b793bd37e563e446fb9 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 8 Mar 2023 14:53:37 +0500 Subject: [PATCH] web: add support for top-level notes in notebooks --- apps/web/src/common/index.js | 6 +- .../components/dialogs/move-note-dialog.tsx | 129 ++++++------- .../src/components/list-container/index.tsx | 4 +- .../list-container/list-profiles.tsx | 94 ++++++---- apps/web/src/components/list-item/index.js | 8 +- apps/web/src/components/note/index.js | 48 +++-- apps/web/src/components/properties/index.js | 42 +++-- apps/web/src/components/topic/index.js | 12 +- apps/web/src/navigation/routes.js | 24 ++- apps/web/src/stores/editor-store.js | 3 +- apps/web/src/stores/note-store.js | 6 + apps/web/src/stores/notebook-store.js | 7 - apps/web/src/views/topics.js | 174 ++++++++++++++++-- 13 files changed, 357 insertions(+), 200 deletions(-) diff --git a/apps/web/src/common/index.js b/apps/web/src/common/index.js index e92fa9348..0e4682606 100644 --- a/apps/web/src/common/index.js +++ b/apps/web/src/common/index.js @@ -41,7 +41,7 @@ import { EVENTS } from "@notesnook/core/common"; export const CREATE_BUTTON_MAP = { notes: { - title: "Make a note", + title: "Add a note", onClick: () => hashNavigate("/notes/create", { addNonce: true, replace: true }) }, @@ -49,6 +49,10 @@ export const CREATE_BUTTON_MAP = { title: "Create a notebook", onClick: () => hashNavigate("/notebooks/create", { replace: true }) }, + notebook: { + title: "Add a note", + onClick: () => hashNavigate(`/notes/create`, { replace: true }) + }, topics: { title: "Create a topic", onClick: () => hashNavigate(`/topics/create`, { replace: true }) diff --git a/apps/web/src/components/dialogs/move-note-dialog.tsx b/apps/web/src/components/dialogs/move-note-dialog.tsx index 461c70fa7..46b715df6 100644 --- a/apps/web/src/components/dialogs/move-note-dialog.tsx +++ b/apps/web/src/components/dialogs/move-note-dialog.tsx @@ -35,7 +35,7 @@ import { usePersistentState } from "../../hooks/use-persistent-state"; type MoveDialogProps = { onClose: Perform; noteIds: string[] }; type NotebookReference = { id: string; - topic: string; + topic?: string; new: boolean; op: "add" | "remove"; }; @@ -95,6 +95,19 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) { } } } + + for (const notebook of noteIds + .map((id) => db.relations?.to({ id, type: "note" }, "notebook")) + .flat()) { + if (!notebook) continue; + + selected.push({ + id: notebook.id, + op: "add", + new: false + }); + } + setSelected(selected); setIsMultiselect(false); }, [noteIds, notebooks, setSelected, setIsMultiselect]); @@ -128,19 +141,13 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) { notestore.refresh(); - const addedTopics = selected.filter((a) => a.op === "add").length; - const removedTopics = selected.filter( - (a) => a.op === "remove" - ).length; - - showToast( - "success", - `${pluralize(noteIds.length, "note", "notes")} added to ${pluralize( - addedTopics, - "topic", - "topics" - )} & removed from ${pluralize(removedTopics, "topic", "topics")}.` - ); + const stringified = stringifySelected(selected); + if (stringified) { + showToast( + "success", + stringified.replace("Add", "Added").replace("remove", "removed") + ); + } onClose(true); } @@ -226,10 +233,12 @@ function NotebookItem(props: { }) { const { notebook, isSearching, onCreateItem } = props; - const { selected, setIsMultiselect, setSelected } = useSelectionStore(); + const { selected, setIsMultiselect, setSelected, isMultiselect } = + useSelectionStore(); const [isCreatingNew, setIsCreatingNew] = useState(false); - const isSelected = isNotebookSelected(notebook, selected); + const index = findSelectionIndex(notebook, selected); + const isSelected = index > -1; return ( @@ -263,10 +272,14 @@ function NotebookItem(props: { e.preventDefault(); e.stopPropagation(); - setIsMultiselect(true); - setSelected( - selectNotebook(notebook, selected, isSelected || false) - ); + const isCtrlPressed = e.ctrlKey || e.metaKey; + if (isCtrlPressed) setIsMultiselect(true); + + if (isMultiselect || isCtrlPressed) { + setSelected(selectMultiple(notebook, selected)); + } else { + setSelected(selectSingle(notebook, selected)); + } }} > @@ -384,9 +397,9 @@ function TopicItem(props: { topic: Topic }) { if (isCtrlPressed) setIsMultiselect(true); if (isMultiselect || isCtrlPressed) { - setSelected(selectMultipleTopics(topic, selected)); + setSelected(selectMultiple(topic, selected)); } else { - setSelected(selectSingleTopic(topic, selected)); + setSelected(selectSingle(topic, selected)); } }} > @@ -545,22 +558,24 @@ function SelectedCheck({ ); } -function createSelection(topic: Topic): NotebookReference { +function createSelection(topic: Topic | Notebook): NotebookReference { return { - id: topic.notebookId, - topic: topic.id, + id: "notebookId" in topic ? topic.notebookId : topic.id, + topic: "notebookId" in topic ? topic.id : undefined, op: "add", new: true }; } function findSelectionIndex( - topic: Topic | NotebookReference, + topic: Topic | NotebookReference | Notebook, array: NotebookReference[] ) { return "op" in topic ? array.findIndex((a) => a.id === topic.id && a.topic === topic.topic) - : array.findIndex((a) => a.id === topic.notebookId && a.topic === topic.id); + : "notebookId" in topic + ? array.findIndex((a) => a.id === topic.notebookId && a.topic === topic.id) + : array.findIndex((a) => a.id === topic.id && !a.topic); } function topicHasNotes(topic: Item, noteIds: string[]) { @@ -568,36 +583,10 @@ function topicHasNotes(topic: Item, noteIds: string[]) { return noteIds.some((id) => notes.indexOf(id) > -1); } -// There are 3 cases: -// 1. Click on a notebook to select/deselect all its topics -// 2. Click on a topic to select/deselect it (and deselect all other topics) -// 3. Ctrl+click on a topic to enter multi select - -function selectNotebook( - notebook: Notebook, - selected: NotebookReference[], - isNotebookSelected: boolean +function selectMultiple( + topic: Topic | Notebook, + selected: NotebookReference[] ) { - for (const topic of notebook.topics) { - const index = findSelectionIndex(topic, selected); - const item = selected[index]; - - // 1. first reset the item's selection state - // 2. set the new state - - if (item?.new) selected.splice(index, 1); - else if (item && !item.new) item.op = "remove"; - - if (!isNotebookSelected) { - if (!item || item.new) selected.push(createSelection(topic)); - else if (item && !item.new) item.op = "add"; - } - } - - return selected; -} - -function selectMultipleTopics(topic: Topic, selected: NotebookReference[]) { const index = findSelectionIndex(topic, selected); const isSelected = index > -1; const item = selected[index]; @@ -613,7 +602,7 @@ function selectMultipleTopics(topic: Topic, selected: NotebookReference[]) { return selected; } -function selectSingleTopic(topic: Topic, array: NotebookReference[]) { +function selectSingle(topic: Topic | Notebook, array: NotebookReference[]) { const selected: NotebookReference[] = array.filter((ref) => !ref.new); const index = findSelectionIndex(topic, array); @@ -630,19 +619,6 @@ function selectSingleTopic(topic: Topic, array: NotebookReference[]) { return selected; } -function isNotebookSelected(notebook: Notebook, selected: NotebookReference[]) { - const selectedTopics = notebook.topics.filter((topic) => { - const index = findSelectionIndex(topic, selected); - return selected[index]?.op === "add"; - }); - - return !selectedTopics.length - ? false - : selectedTopics.length === notebook.topics.length - ? true - : null; -} - function stringifySelected(suggestion: NotebookReference[]) { const added = suggestion .filter((a) => a.op === "add") @@ -660,7 +636,7 @@ function stringifySelected(suggestion: NotebookReference[]) { if (removed.length >= 1) { parts.push("remove from"); - parts.push(added[0]); + parts.push(removed[0]); } if (removed.length > 1) parts.push(`and ${removed.length - 1} others`); @@ -668,7 +644,12 @@ function stringifySelected(suggestion: NotebookReference[]) { } function resolve(ref: NotebookReference) { - const topic = db.notebooks?.notebook(ref.id)?.topics.topic(ref.topic); - if (!topic) return undefined; - return topic._topic.title; + const notebook = db.notebooks?.notebook(ref.id); + if (!notebook) return undefined; + + if (ref.topic) { + return notebook.topics.topic(ref.topic)?._topic?.title; + } else { + return notebook.title; + } } diff --git a/apps/web/src/components/list-container/index.tsx b/apps/web/src/components/list-container/index.tsx index d198a88f9..8c66d645f 100644 --- a/apps/web/src/components/list-container/index.tsx +++ b/apps/web/src/components/list-container/index.tsx @@ -61,7 +61,7 @@ type ListContainerProps = { context?: Context; refresh: () => void; header?: JSX.Element; - placeholder: () => JSX.Element; + placeholder: JSX.Element; isLoading?: boolean; button?: { onClick: () => void; @@ -136,7 +136,7 @@ function ListContainer(props: ListContainerProps) { ) : ( - + {props.placeholder} )} diff --git a/apps/web/src/components/list-container/list-profiles.tsx b/apps/web/src/components/list-container/list-profiles.tsx index 0a37070e5..ce6fb8f02 100644 --- a/apps/web/src/components/list-container/list-profiles.tsx +++ b/apps/web/src/components/list-container/list-profiles.tsx @@ -28,6 +28,7 @@ import { db } from "../../common/db"; import { getTotalNotes } from "../../common"; import Reminder from "../reminder"; import type { Reminder as ReminderType } from "@notesnook/core/collections/reminders"; +import { useMemo } from "react"; const SINGLE_LINE_HEIGHT = 1.4; const DEFAULT_LINE_HEIGHT = @@ -60,19 +61,26 @@ type ItemWrapper = ( props: ItemWrapperProps ) => JSX.Element; -const NotesProfile: ItemWrapper = ({ index, item, type, context, compact }) => ( - -); +const NotesProfile: ItemWrapper = ({ index, item, type, context, compact }) => { + const references = useMemo( + () => getReferences(item.id, item.notebooks as Item[], context?.type), + [item, context] + ); + + return ( + + ); +}; const NotebooksProfile: ItemWrapper = ({ index, item, type }) => ( (function ( - prev: NotebookResult, - curr - ): NotebookResult { - if (prev) return prev; + const references: Reference[] = []; + let latestDateEdited = 0; + + db.relations + ?.to({ id: noteId, type: "note" }, "notebook") + ?.forEach((notebook: any) => { + references.push({ + type: "notebook", + url: `/notebooks/${notebook.id}`, + title: notebook.title + } as Reference); + + if (latestDateEdited < notebook.dateEdited) + latestDateEdited = notebook.dateEdited; + }); + + notebooks?.forEach((curr) => { const topicId = (curr as NotebookReference).topics[0]; const notebook = db.notebooks?.notebook(curr.id)?.data as NotebookType; if (!notebook) return; @@ -156,14 +174,16 @@ function getNotebook( const topic = notebook.topics.find((t: Item) => t.id === topicId); if (!topic) return; - return { - id: notebook.id, - title: notebook.title, - dateEdited: notebook.dateEdited, - topic: { id: topicId, title: topic.title } - } as NotebookResult; - }, - undefined as NotebookResult); + references.push({ + url: `/notebooks/${curr.id}/${topicId}`, + title: topic.title, + type: "topic" + }); + if (latestDateEdited < (topic.dateEdited as number)) + latestDateEdited = topic.dateEdited as number; + }); + + return { dateEdited: latestDateEdited, references: references.slice(0, 3) }; } function getReminder(noteId: string) { diff --git a/apps/web/src/components/list-item/index.js b/apps/web/src/components/list-item/index.js index debfe26ea..eb592ad46 100644 --- a/apps/web/src/components/list-item/index.js +++ b/apps/web/src/components/list-item/index.js @@ -138,7 +138,7 @@ function ListItem(props) { backgroundColor: isSelected ? "shade" - : isMenuTarget + : isMenuTarget || isFocused ? "hover" : background, @@ -167,9 +167,11 @@ function ListItem(props) { }} data-test-id={`list-item`} > + {!isCompact && props.header} + - {!isCompact && props.header} - {!isSimple && !isCompact && props.body && ( - {notebook && ( + {references?.references?.map((reference) => ( { - navigate(`/notebooks/${notebook.id}/${notebook.topic.id}`); + navigate(reference.url); }} - text={`${notebook.title} › ${notebook.topic.title}`} - icon={Icon.Notebook} + text={reference.title} + icon={reference.type === "topic" ? Icon.Topic : Icon.Notebook} /> - )} + ))} {reminder && isReminderActive(reminder) && ( 0) { + const notebooks = db.relations?.to(note, "notebook"); + + if (note && (note.notebooks?.length > 0 || notebooks?.length > 0)) { menuItems.push( { key: "remove-from-all-notebooks", @@ -556,16 +559,28 @@ function notebooksMenuItems({ note }) { }, { key: "sep", type: "separator" } ); - note.notebooks.forEach((ref) => { - // if (prev) return prev; - // const topicId = (curr as NotebookReference).topics[0]; + + notebooks?.forEach((notebook) => { + menuItems.push({ + key: notebook.id, + title: notebook.title, + icon: Icon.Notebook, + checked: true, + tooltip: "Click to remove from this notebook", + onClick: async () => { + await db.notes.removeFromNotebook({ id: notebook.id }, note.id); + store.refresh(); + } + }); + }); + + note.notebooks?.forEach((ref) => { const notebook = db.notebooks?.notebook(ref.id); if (!notebook) return; - const notebookMenuItems = []; for (const topicId of ref.topics) { if (!notebook.topics.topic(topicId)) continue; const topic = notebook.topics.topic(topicId)._topic; - notebookMenuItems.push({ + menuItems.push({ key: topicId, title: topic.title, icon: Icon.Topic, @@ -580,13 +595,6 @@ function notebooksMenuItems({ note }) { } }); } - - menuItems.push({ - key: ref.id, - title: notebook.title, - icon: Icon.Notebook2, - items: notebookMenuItems - }); }); } diff --git a/apps/web/src/components/properties/index.js b/apps/web/src/components/properties/index.js index 51fbb2f3f..1c4807ce3 100644 --- a/apps/web/src/components/properties/index.js +++ b/apps/web/src/components/properties/index.js @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import * as Icon from "../icons"; import { Flex, Text } from "@theme-ui/components"; import { useStore, store } from "../../stores/editor-store"; @@ -86,12 +86,25 @@ function Properties(props) { const attachments = useAttachmentStore((store) => store.attachments.filter((a) => a.noteIds.includes(session.id)) ); - const { id: sessionId, color, notebooks, sessionType, dateCreated } = session; + const { + id: sessionId, + color, + notebooks = [], + sessionType, + dateCreated + } = session; const isPreviewMode = sessionType === "preview"; const reminders = db.relations.from( { id: session.id, type: "note" }, "reminder" ); + const allNotebooks = useMemo( + () => [ + ...notebooks.map((ref) => db.notebooks.notebook(ref.id)?.data), + ...db.relations.to({ id: sessionId, type: "note" }, "notebook") + ], + [sessionId, notebooks] + ); const changeState = useCallback( function changeState(prop) { @@ -246,22 +259,17 @@ function Properties(props) { )} - {notebooks?.length > 0 && ( + {allNotebooks?.length > 0 && ( - {notebooks.map((ref) => { - const notebook = db.notebooks.notebook(ref.id)?._notebook; - if (!notebook) return null; - - return ( - - ); - })} + {allNotebooks.map((notebook) => ( + + ))} )} {reminders?.length > 0 && ( diff --git a/apps/web/src/components/topic/index.js b/apps/web/src/components/topic/index.js index 39f3f22dd..4de13cbd7 100644 --- a/apps/web/src/components/topic/index.js +++ b/apps/web/src/components/topic/index.js @@ -25,12 +25,16 @@ import { hashNavigate } from "../../navigation"; import { Flex, Text } from "@theme-ui/components"; import * as Icon from "../icons"; import { Multiselect } from "../../common/multi-select"; -import { pluralize } from "../../utils/string"; import { confirm } from "../../common/dialog-controller"; +import { useStore as useNotesStore } from "../../stores/note-store"; function Topic({ item, index, onClick }) { const { id, notebookId } = item; const topic = item; + const isOpened = useNotesStore( + (store) => store.context?.value?.topic === item.id + ); + const totalNotes = useMemo(() => { return db.notebooks.notebook(notebookId)?.topics.topic(id).totalNotes; }, [id, notebookId]); @@ -38,6 +42,8 @@ function Topic({ item, index, onClick }) { return ( - - {pluralize(totalNotes || 0, "note", "notes")} - + {totalNotes} } index={index} diff --git a/apps/web/src/navigation/routes.js b/apps/web/src/navigation/routes.js index f6c4f2422..88ab6f727 100644 --- a/apps/web/src/navigation/routes.js +++ b/apps/web/src/navigation/routes.js @@ -29,7 +29,6 @@ import { navigate } from "../navigation"; import Trash from "../views/trash"; import { store as notestore } from "../stores/note-store"; import { store as nbstore } from "../stores/notebook-store"; -import { showToast } from "../utils/toast"; import Reminders from "../views/reminders"; const routes = { @@ -59,9 +58,14 @@ const routes = { const notebook = db.notebooks.notebook(notebookId); if (!notebook) return false; nbstore.setSelectedNotebook(notebookId); + notestore.setContext({ + type: "notebook", + value: { id: notebookId } + }); + return { - key: "topics", - type: "topics", + key: "notebook", + type: "notebook", component: , buttons: { back: { @@ -69,7 +73,7 @@ const routes = { action: () => navigate("/notebooks") }, search: { - title: `Search ${notebook.title} topics` + title: `Search ${notebook.title} notes` } } }; @@ -83,16 +87,10 @@ const routes = { value: { id: notebookId, topic: topicId } }); return { - key: "notes", - type: "notes", + key: "topic", + type: "notebook", title: topic.title, - subtitle: notebook.title, - isEditable: true, - onChange: (title) => { - db.notebooks.notebook(notebookId).topics.add({ ...topic, title }); - showToast("success", "Topic title updated!"); - }, - component: , + component: , buttons: { back: { title: `Go back to ${notebook.title}`, diff --git a/apps/web/src/stores/editor-store.js b/apps/web/src/stores/editor-store.js index 62699f543..ef4340a78 100644 --- a/apps/web/src/stores/editor-store.js +++ b/apps/web/src/stores/editor-store.js @@ -187,7 +187,8 @@ class EditorStore extends BaseStore { if (currentSession.context) { const { type, value } = currentSession.context; - if (type === "topic") await db.notes.addToNotebook(value, id); + if (type === "topic" || type === "notebook") + await db.notes.addToNotebook(value, id); else if (type === "color") await db.notes.note(id).color(value); else if (type === "tag") await db.notes.note(id).tag(value); // update the note. diff --git a/apps/web/src/stores/note-store.js b/apps/web/src/stores/note-store.js index 303e2da14..ada703627 100644 --- a/apps/web/src/stores/note-store.js +++ b/apps/web/src/stores/note-store.js @@ -205,6 +205,12 @@ function notesFromContext(context) { case "color": notes = db.notes.colored(context.value); break; + case "notebook": { + const notebook = db.notebooks.notebook(context?.value?.id); + if (!notebook) break; + notes = db.relations.from(notebook.data, "note"); + break; + } case "topic": { const notebook = db.notebooks.notebook(context?.value?.id); if (!notebook) break; diff --git a/apps/web/src/stores/notebook-store.js b/apps/web/src/stores/notebook-store.js index 2ecb96980..2b26e4e01 100644 --- a/apps/web/src/stores/notebook-store.js +++ b/apps/web/src/stores/notebook-store.js @@ -27,7 +27,6 @@ import Config from "../utils/config"; class NotebookStore extends BaseStore { notebooks = []; - selectedNotebookTopics = []; selectedNotebookId = 0; viewMode = Config.get("notebooks:viewMode", "detailed"); @@ -60,13 +59,7 @@ class NotebookStore extends BaseStore { }; setSelectedNotebook = (id) => { - const topics = db.notebooks.notebook(id)?.topics?.all; - if (!topics) return; this.set((state) => { - state.selectedNotebookTopics = groupArray( - topics, - db.settings.getGroupOptions("topics") - ); state.selectedNotebookId = id; }); }; diff --git a/apps/web/src/views/topics.js b/apps/web/src/views/topics.js index b34a3eea4..d6e7e1045 100644 --- a/apps/web/src/views/topics.js +++ b/apps/web/src/views/topics.js @@ -17,49 +17,183 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, 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 } from "../navigation"; -import TopicsPlaceholder from "../components/placeholders/topics-placeholder"; +import { hashNavigate, navigate } from "../navigation"; import { Button, Flex, Text } from "@theme-ui/components"; -import { Edit, RemoveShortcutLink, ShortcutLink } from "../components/icons"; +import { + ChevronDown, + ChevronRight, + Edit, + RemoveShortcutLink, + ShortcutLink +} from "../components/icons"; import { getTotalNotes } from "../common"; import { formatDate } from "@notesnook/core/utils/date"; import { db } from "../common/db"; import { pluralize } from "../utils/string"; +import { Allotment } from "allotment"; +import { Plus } from "../components/icons"; +import { useStore as useNotesStore } from "../stores/note-store"; +import Placeholder from "../components/placeholders"; + +function Notebook() { + const [isCollapsed, setIsCollapsed] = useState(false); -function Topics() { - const selectedNotebookTopics = useNbStore( - (store) => store.selectedNotebookTopics - ); const selectedNotebookId = useNbStore((store) => store.selectedNotebookId); const refresh = useNbStore((store) => store.setSelectedNotebook); + const notebooks = useNbStore((store) => store.notebooks); + const context = useNotesStore((store) => store.context); + const refreshContext = useNotesStore((store) => store.refreshContext); + const isCompact = useNotesStore((store) => store.viewMode === "compact"); + + useEffect(() => { + if (context && context.value && selectedNotebookId !== context.value.id) + refresh(context.value.id); + }, [selectedNotebookId, context, refresh]); + + const selectedNotebook = useMemo( + () => db.notebooks?.notebook(selectedNotebookId)?.data, + [selectedNotebookId, notebooks] + ); + + if (!context) return null; return ( <> + {context.type === "topic" && selectedNotebook ? ( + + {[ + { title: "Notebooks", onClick: () => navigate(`/notebooks/`) }, + { + title: selectedNotebook.title, + onClick: () => navigate(`/notebooks/${selectedNotebookId}`) + } + ].map((crumb, index, array) => ( + <> + + {index === array.length - 1 ? null : ( + + )} + + ))} + + ) : null} + + + + } + header={ + context?.type === "topic" ? ( + <> + ) : ( + + ) + } + button={{ + content: "Make a new note", + onClick: () => + hashNavigate("/notes/create", { + addNonce: true, + replace: true + }) + }} + /> + + + + + + + + ); +} +export default Notebook; + +function Topics({ selectedNotebook, isCollapsed, setIsCollapsed }) { + const refresh = useNbStore((store) => store.setSelectedNotebook); + return ( + + setIsCollapsed((s) => !s)} + > + + {isCollapsed ? ( + + ) : ( + + )} + + TOPICS + + + + + refresh(selectedNotebookId)} - items={selectedNotebookTopics} - context={{ notebookId: selectedNotebookId }} - placeholder={TopicsPlaceholder} - header={ - - } + refresh={() => refresh(selectedNotebook.id)} + items={selectedNotebook.topics} + context={{ + notebookId: selectedNotebook.id + }} + placeholder={} + header={<>} button={{ content: "Add a new topic", onClick: () => hashNavigate(`/topics/create`) }} /> - + ); } -export default Topics; function NotebookHeader({ notebook }) { const { title, description, topics, dateEdited } = notebook; @@ -73,7 +207,7 @@ function NotebookHeader({ notebook }) { }, [shortcuts, notebook]); return ( - + {formatDate(dateEdited)} {title}