Skip to content

Commit

Permalink
Add tagging and note connecting functionality (#5)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bercivarga authored Mar 10, 2024
1 parent be319b7 commit 6e3e937
Show file tree
Hide file tree
Showing 18 changed files with 522 additions and 43 deletions.
171 changes: 171 additions & 0 deletions app/(platform)/notes/[noteId]/note-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="flex w-80 flex-col bg-slate-100/50 p-6 text-sm text-slate-400">
<div className="flex flex-col items-stretch gap-4">
<span className="block">Connected notes</span>
{noteRelations.length === 0 ? (
<span className="text-xs text-slate-400">
No connected notes yet
</span>
) : (
<ul className="flex flex-col gap-2">
{noteRelations.map((relatedNote) => (
<li
key={relatedNote.id}
className="flex w-full items-center justify-between"
>
<Link href={`/notes/${relatedNote.id}`} prefetch={false}>
<Button
className="w-full justify-start p-0"
variant={"link"}
size={"sm"}
>
{relatedNote.title.length > 32
? `${relatedNote.title.slice(0, 32)}...`
: relatedNote.title}
</Button>
</Link>
<Button
size={"sm"}
variant={"link"}
onClick={() => handleDisconnectNote(relatedNote.id)}
>
<TrashIcon />
</Button>
</li>
))}
</ul>
)}
<Button
className="w-full justify-start"
variant={"outline"}
size={"sm"}
onClick={() => setShowNoteConnector(true)}
>
<PlusCircledIcon className="mr-3" />
Connect notes
</Button>
</div>
<hr className="my-6 border-t border-slate-200" />
<div className="flex flex-col items-stretch gap-4">
<span className="block">Tags</span>
{note.tags.length === 0 ? (
<span className="text-xs text-slate-400">No tags yet</span>
) : (
<ul className="flex flex-wrap gap-2">
{note.tags.map((tag) => (
<li key={tag.id}>
<Badge variant="outline">
{tag.name}
<Button
size={"sm"}
variant={"link"}
className="ml-2 h-min w-min p-0 hover:bg-slate-200"
onClick={() => handleDisconnectTag(tag.id)}
>
<Cross1Icon className="h-[10px] w-[10px]" />
</Button>
</Badge>
</li>
))}
</ul>
)}
<Button
className="w-full justify-start"
variant={"outline"}
size={"sm"}
onClick={() => setShowTagManager(true)}
>
<PlusCircledIcon className="mr-3" />
Manage tags
</Button>
</div>
<hr className="my-6 border-t border-slate-200" />
<div className="flex flex-col items-stretch gap-4">
<span className="block">Note actions</span>
<Button
className="w-full justify-start"
variant={"destructive"}
onClick={handleDeleteNote}
>
<TrashIcon className="mr-3" />
Move to trash
</Button>
</div>
</div>
<NoteConnector
note={note}
open={showNoteConnector}
setOpen={setShowNoteConnector}
/>
<TagManager
note={note}
open={showTagManager}
setOpen={setShowTagManager}
/>
</>
);
}
92 changes: 92 additions & 0 deletions app/(platform)/notes/[noteId]/note-connector.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof searchNotes>>;

export default function NoteConnector({ note, open, setOpen }: Props) {
const [results, setResults] = useState<NotesSearchResult>([]);

const router = useRouter();

const { id: noteId } = note;

const existingNoteRelationIds = prepareNoteRelations(note).map(
(note) => note.id
);

async function handleSearchChange(
event: React.ChangeEvent<HTMLInputElement>
) {
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 (
<CommandDialog open={open} onOpenChange={handleClose}>
<CommandInput
onInput={debouncedHandleSearchChange}
placeholder="Type a note's name to search..."
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{!!results.length && (
<CommandGroup heading="Suggestions">
{results.map((result) => (
<CommandItem
key={result.id}
onSelect={() => handleConnectNotes(result.id)}
className="cursor-pointer"
>
{result.title}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandList>
</CommandDialog>
);
}
33 changes: 3 additions & 30 deletions app/(platform)/notes/[noteId]/note-editor.tsx
Original file line number Diff line number Diff line change
@@ -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<Awaited<ReturnType<typeof getNote>>>; // I love TS
note: INote;
};

const debounceDelay = 500;
Expand All @@ -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 (
<div className="flex h-full min-h-screen divide-x">
<div className="flex h-full min-h-screen w-full divide-x">
<div className="flex w-full flex-col gap-6 p-6">
<input
className="text-4xl font-bold outline-none"
Expand All @@ -60,17 +44,6 @@ export default function NoteEditor({ note }: Props) {
}}
/>
</div>
<div className="flex w-80 flex-col items-stretch gap-4 bg-slate-100/50 p-6">
<span className="block text-sm text-slate-400">Note actions</span>
<Button
className="w-full justify-start"
variant={"destructive"}
onClick={handleDeleteNote}
>
<TrashIcon className="mr-3" />
Delete note
</Button>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion app/(platform)/notes/[noteId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -33,8 +34,9 @@ export default async function EditNotePage({ params }: Props) {
}

return (
<main>
<main className="flex h-full min-h-screen w-full divide-x">
<NoteEditor note={note} />
<NoteActions note={note} />
</main>
);
}
Expand Down
Loading

0 comments on commit 6e3e937

Please sign in to comment.