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