From 6e3e9379c9c0573b26b5af80f89a09061884e5f6 Mon Sep 17 00:00:00 2001 From: bercivarga <65171545+bercivarga@users.noreply.github.com> Date: Sun, 10 Mar 2024 15:30:06 +0100 Subject: [PATCH] Add tagging and note connecting functionality (#5) * add caching adjustments and disable link prefetch in navbar * refactor note actions into a standalone component * add ui for linked notes and tags, adjust schema so that users own their tags * add badges for tags * add note relation creation functionality * fix db query filter and remove indexing from results * add better note connection filters * add tag manager functionality --- .../notes/[noteId]/note-actions.tsx | 171 ++++++++++++++++++ .../notes/[noteId]/note-connector.tsx | 92 ++++++++++ app/(platform)/notes/[noteId]/note-editor.tsx | 33 +--- app/(platform)/notes/[noteId]/page.tsx | 4 +- app/(platform)/notes/[noteId]/tag-manager.tsx | 104 +++++++++++ app/(platform)/side-nav.tsx | 2 +- components/ui/badge.tsx | 36 ++++ helpers/notes/addTag.ts | 20 +- helpers/notes/deleteNote.ts | 2 +- helpers/notes/getNote.ts | 1 + helpers/notes/prepareNoteRelations.ts | 17 ++ helpers/notes/removeTag.ts | 4 +- helpers/notes/searchNotes.ts | 24 +++ helpers/notes/searchTags.ts | 39 ++++ helpers/notes/updateNoteContent.ts | 2 +- helpers/notes/updateNoteTitle.ts | 2 +- prisma/schema.prisma | 9 +- types/platform.ts | 3 + 18 files changed, 522 insertions(+), 43 deletions(-) create mode 100644 app/(platform)/notes/[noteId]/note-actions.tsx create mode 100644 app/(platform)/notes/[noteId]/note-connector.tsx create mode 100644 app/(platform)/notes/[noteId]/tag-manager.tsx create mode 100644 components/ui/badge.tsx create mode 100644 helpers/notes/prepareNoteRelations.ts create mode 100644 helpers/notes/searchNotes.ts create mode 100644 helpers/notes/searchTags.ts create mode 100644 types/platform.ts diff --git a/app/(platform)/notes/[noteId]/note-actions.tsx b/app/(platform)/notes/[noteId]/note-actions.tsx new file mode 100644 index 0000000..b62db98 --- /dev/null +++ b/app/(platform)/notes/[noteId]/note-actions.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Cross1Icon, PlusCircledIcon, TrashIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { deleteNote } from "@/helpers/notes/deleteNote"; +import { disconnectNotes } from "@/helpers/notes/disconnectNotes"; +import { prepareNoteRelations } from "@/helpers/notes/prepareNoteRelations"; +import { removeTag } from "@/helpers/notes/removeTag"; +import { INote } from "@/types/platform"; + +import NoteConnector from "./note-connector"; +import TagManager from "./tag-manager"; + +type Props = { + note: INote; +}; + +export default function NoteActions({ note }: Props) { + const [showNoteConnector, setShowNoteConnector] = useState(false); + const [showTagManager, setShowTagManager] = useState(false); + + const router = useRouter(); + + async function handleDeleteNote() { + const deletedNote = await deleteNote(note.id); + + if (!deletedNote) { + alert("Failed to delete note"); // TODO: better error handling with a toast + return; + } + + router.push("/notes"); + } + + async function handleDisconnectNote(toDisconnectId: string) { + const disconnectedNote = await disconnectNotes(note.id, toDisconnectId); + + if (!disconnectedNote) { + alert("Failed to disconnect note"); // TODO: better error handling with a toast + return; + } + + router.refresh(); + } + + async function handleDisconnectTag(tagId: string) { + const disconnectedNote = await removeTag(note.id, tagId); + + if (!disconnectedNote) { + alert("Failed to disconnect tag"); // TODO: better error handling with a toast + return; + } + + router.refresh(); + } + + const noteRelations = prepareNoteRelations(note); + + return ( + <> +
+
+ Connected notes + {noteRelations.length === 0 ? ( + + No connected notes yet + + ) : ( + + )} + +
+
+
+ Tags + {note.tags.length === 0 ? ( + No tags yet + ) : ( + + )} + +
+
+
+ Note actions + +
+
+ + + + ); +} diff --git a/app/(platform)/notes/[noteId]/note-connector.tsx b/app/(platform)/notes/[noteId]/note-connector.tsx new file mode 100644 index 0000000..36e4d45 --- /dev/null +++ b/app/(platform)/notes/[noteId]/note-connector.tsx @@ -0,0 +1,92 @@ +"use client"; + +import debounce from "lodash.debounce"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { connectNotes } from "@/helpers/notes/connectNotes"; +import { prepareNoteRelations } from "@/helpers/notes/prepareNoteRelations"; +import { searchNotes } from "@/helpers/notes/searchNotes"; +import { INote } from "@/types/platform"; + +type Props = { + note: INote; + open: boolean; + setOpen: (open: boolean) => void; +}; + +type NotesSearchResult = Awaited>; + +export default function NoteConnector({ note, open, setOpen }: Props) { + const [results, setResults] = useState([]); + + const router = useRouter(); + + const { id: noteId } = note; + + const existingNoteRelationIds = prepareNoteRelations(note).map( + (note) => note.id + ); + + async function handleSearchChange( + event: React.ChangeEvent + ) { + const search = event.target.value; + + const results = await searchNotes(search, { + doNotIncludeNoteIds: [...existingNoteRelationIds, noteId], + }); + + if (!results) return; + + setResults(results); + } + + const debouncedHandleSearchChange = debounce(handleSearchChange, 500); + + async function handleConnectNotes(relatedNoteId: string) { + await connectNotes(noteId, relatedNoteId); + setOpen(false); + router.refresh(); + } + + function handleClose() { + setResults([]); + setOpen(false); + } + + return ( + + + + No results found. + + {!!results.length && ( + + {results.map((result) => ( + handleConnectNotes(result.id)} + className="cursor-pointer" + > + {result.title} + + ))} + + )} + + + + ); +} diff --git a/app/(platform)/notes/[noteId]/note-editor.tsx b/app/(platform)/notes/[noteId]/note-editor.tsx index 1ce3c23..5353d69 100644 --- a/app/(platform)/notes/[noteId]/note-editor.tsx +++ b/app/(platform)/notes/[noteId]/note-editor.tsx @@ -1,18 +1,14 @@ "use client"; -import { TrashIcon } from "@radix-ui/react-icons"; import debounce from "lodash.debounce"; -import { useRouter } from "next/navigation"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { deleteNote } from "@/helpers/notes/deleteNote"; -import { getNote } from "@/helpers/notes/getNote"; import { updateNoteContent } from "@/helpers/notes/updateNoteContent"; import { updateNoteTitle } from "@/helpers/notes/updateNoteTitle"; +import { INote } from "@/types/platform"; type Props = { - note: NonNullable>>; // I love TS + note: INote; }; const debounceDelay = 500; @@ -23,20 +19,8 @@ export default function NoteEditor({ note }: Props) { const [title, setTitle] = useState(note.title); const [content, setContent] = useState(note.content); - const router = useRouter(); - - async function handleDeleteNote() { - const deletedNote = await deleteNote(note.id); - if (!deletedNote) { - alert("Failed to delete note"); // TODO: better error handling with a toast - return; - } - - router.push("/notes"); - } - return ( -
+
-
- Note actions - -
); } diff --git a/app/(platform)/notes/[noteId]/page.tsx b/app/(platform)/notes/[noteId]/page.tsx index e5fa7c5..e63b114 100644 --- a/app/(platform)/notes/[noteId]/page.tsx +++ b/app/(platform)/notes/[noteId]/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { getNote } from "@/helpers/notes/getNote"; +import NoteActions from "./note-actions"; import NoteEditor from "./note-editor"; type Props = { @@ -33,8 +34,9 @@ export default async function EditNotePage({ params }: Props) { } return ( -
+
+
); } diff --git a/app/(platform)/notes/[noteId]/tag-manager.tsx b/app/(platform)/notes/[noteId]/tag-manager.tsx new file mode 100644 index 0000000..b699ea9 --- /dev/null +++ b/app/(platform)/notes/[noteId]/tag-manager.tsx @@ -0,0 +1,104 @@ +"use client"; + +import debounce from "lodash.debounce"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { addTag } from "@/helpers/notes/addTag"; +import { searchTags } from "@/helpers/notes/searchTags"; +import { INote } from "@/types/platform"; + +type TagsSearchResult = Awaited>; + +type Props = { + note: INote; + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function TagManager({ note, open, setOpen }: Props) { + const [results, setResults] = useState([]); + const [localSearch, setLocalSearch] = useState(""); + + const router = useRouter(); + + const { id: noteId } = note; + + const existingTags = note.tags.map((tag) => tag.id); + + async function handleSearchChange( + event: React.ChangeEvent + ) { + const search = event.target.value; + setLocalSearch(search); + + const results = await searchTags(search, { + doNotIncludeTagIds: [...existingTags], + }); + + if (!results) return; + + setResults(results); + } + + const debouncedHandleSearchChange = debounce(handleSearchChange, 500); + + async function handleAddTag(tagId: string) { + await addTag(noteId, tagId); + router.refresh(); + setOpen(false); + } + + function handleClose() { + setResults([]); + setLocalSearch(""); + setOpen(false); + } + + const showTagCreationSuggestion = !!localSearch; + + return ( + + + + No results found. + + {!!results.length && ( + + {results.map((result) => ( + handleAddTag(result.name)} + className="cursor-pointer" + > + {result.name} + + ))} + + )} + {showTagCreationSuggestion && ( + + handleAddTag(localSearch)} + className="cursor-pointer" + > + {localSearch} + + + )} + + + + ); +} diff --git a/app/(platform)/side-nav.tsx b/app/(platform)/side-nav.tsx index 7af390f..709c0eb 100644 --- a/app/(platform)/side-nav.tsx +++ b/app/(platform)/side-nav.tsx @@ -12,7 +12,7 @@ export default function SideNav() {