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
+
+ ) : (
+
+ {noteRelations.map((relatedNote) => (
+ -
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
Tags
+ {note.tags.length === 0 ? (
+
No tags yet
+ ) : (
+
+ {note.tags.map((tag) => (
+ -
+
+ {tag.name}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ 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() {