From 04572a8e5081b1e4871e273cde9dbaaa44c52fe0 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Wed, 13 Mar 2024 21:43:44 +0000 Subject: structure: Create apps dir and copy tooling dir from t3-turbo repo --- apps/web/README.md | 36 ++++ apps/web/app/api/auth/[...nextauth]/route.tsx | 3 + apps/web/app/api/trpc/[trpc]/route.ts | 36 ++++ apps/web/app/dashboard/admin/page.tsx | 203 +++++++++++++++++++ apps/web/app/dashboard/archive/page.tsx | 9 + apps/web/app/dashboard/bookmarks/layout.tsx | 23 +++ apps/web/app/dashboard/bookmarks/loading.tsx | 11 + apps/web/app/dashboard/bookmarks/page.tsx | 5 + apps/web/app/dashboard/error.tsx | 9 + apps/web/app/dashboard/favourites/page.tsx | 14 ++ apps/web/app/dashboard/layout.tsx | 24 +++ apps/web/app/dashboard/lists/[listId]/page.tsx | 44 ++++ apps/web/app/dashboard/lists/page.tsx | 14 ++ apps/web/app/dashboard/not-found.tsx | 7 + .../app/dashboard/preview/[bookmarkId]/page.tsx | 14 ++ apps/web/app/dashboard/search/page.tsx | 41 ++++ apps/web/app/dashboard/settings/page.tsx | 9 + apps/web/app/dashboard/tags/[tagName]/page.tsx | 55 +++++ apps/web/app/dashboard/tags/page.tsx | 56 ++++++ apps/web/app/favicon.ico | Bin 0 -> 15406 bytes apps/web/app/globals.css | 76 +++++++ apps/web/app/layout.tsx | 51 +++++ apps/web/app/page.tsx | 12 ++ apps/web/app/signin/page.tsx | 25 +++ apps/web/components.json | 17 ++ .../dashboard/bookmarks/AddLinkButton.tsx | 102 ++++++++++ .../dashboard/bookmarks/AddToListModal.tsx | 168 ++++++++++++++++ .../dashboard/bookmarks/BookmarkCardSkeleton.tsx | 30 +++ .../dashboard/bookmarks/BookmarkOptions.tsx | 185 +++++++++++++++++ .../dashboard/bookmarks/BookmarkPreview.tsx | 101 ++++++++++ .../dashboard/bookmarks/BookmarkedTextEditor.tsx | 109 ++++++++++ .../dashboard/bookmarks/BookmarkedTextViewer.tsx | 20 ++ .../components/dashboard/bookmarks/Bookmarks.tsx | 32 +++ .../dashboard/bookmarks/BookmarksGrid.tsx | 64 ++++++ .../components/dashboard/bookmarks/LinkCard.tsx | 114 +++++++++++ .../web/components/dashboard/bookmarks/TagList.tsx | 39 ++++ .../components/dashboard/bookmarks/TagModal.tsx | 207 +++++++++++++++++++ .../components/dashboard/bookmarks/TextCard.tsx | 94 +++++++++ apps/web/components/dashboard/bookmarks/TopNav.tsx | 43 ++++ .../components/dashboard/lists/AllListsView.tsx | 66 ++++++ .../dashboard/lists/DeleteListButton.tsx | 77 +++++++ apps/web/components/dashboard/lists/ListView.tsx | 25 +++ .../components/dashboard/search/SearchInput.tsx | 25 +++ .../components/dashboard/settings/AddApiKey.tsx | 167 ++++++++++++++++ .../dashboard/settings/ApiKeySettings.tsx | 49 +++++ .../components/dashboard/settings/DeleteApiKey.tsx | 74 +++++++ apps/web/components/dashboard/sidebar/AllLists.tsx | 60 ++++++ .../components/dashboard/sidebar/ModileSidebar.tsx | 24 +++ .../dashboard/sidebar/ModileSidebarItem.tsx | 27 +++ .../components/dashboard/sidebar/NewListModal.tsx | 170 ++++++++++++++++ apps/web/components/dashboard/sidebar/Sidebar.tsx | 66 ++++++ .../components/dashboard/sidebar/SidebarItem.tsx | 33 +++ .../dashboard/sidebar/SidebarProfileOptions.tsx | 35 ++++ apps/web/components/signin/CredentialsForm.tsx | 222 +++++++++++++++++++++ apps/web/components/signin/SignInForm.tsx | 37 ++++ .../web/components/signin/SignInProviderButton.tsx | 21 ++ apps/web/components/ui/action-button.tsx | 25 +++ apps/web/components/ui/back-button.tsx | 9 + apps/web/components/ui/badge.tsx | 36 ++++ apps/web/components/ui/button.tsx | 56 ++++++ apps/web/components/ui/card.tsx | 86 ++++++++ apps/web/components/ui/dialog.tsx | 122 +++++++++++ apps/web/components/ui/dropdown-menu.tsx | 200 +++++++++++++++++++ apps/web/components/ui/form.tsx | 177 ++++++++++++++++ apps/web/components/ui/imageCard.tsx | 70 +++++++ apps/web/components/ui/input.tsx | 25 +++ apps/web/components/ui/label.tsx | 26 +++ apps/web/components/ui/popover.tsx | 31 +++ apps/web/components/ui/scroll-area.tsx | 48 +++++ apps/web/components/ui/select.tsx | 160 +++++++++++++++ apps/web/components/ui/separator.tsx | 31 +++ apps/web/components/ui/skeleton.tsx | 15 ++ apps/web/components/ui/spinner.tsx | 20 ++ apps/web/components/ui/table.tsx | 117 +++++++++++ apps/web/components/ui/tabs.tsx | 55 +++++ apps/web/components/ui/textarea.tsx | 24 +++ apps/web/components/ui/toast.tsx | 127 ++++++++++++ apps/web/components/ui/toaster.tsx | 35 ++++ apps/web/components/ui/use-toast.ts | 189 ++++++++++++++++++ apps/web/lib/bookmarkUtils.tsx | 22 ++ apps/web/lib/hooks/bookmark-search.ts | 73 +++++++ apps/web/lib/providers.tsx | 75 +++++++ apps/web/lib/trpc.tsx | 5 + apps/web/lib/utils.ts | 6 + apps/web/next.config.mjs | 53 +++++ apps/web/package.json | 89 +++++++++ apps/web/postcss.config.cjs | 6 + apps/web/public/blur.avif | Bin 0 -> 52746 bytes apps/web/public/icons/logo-128.png | Bin 0 -> 2362 bytes apps/web/public/icons/logo-16.png | Bin 0 -> 287 bytes apps/web/public/icons/logo-48.png | Bin 0 -> 780 bytes apps/web/public/manifest.json | 25 +++ apps/web/server/api/client.ts | 16 ++ apps/web/server/auth.ts | 96 +++++++++ apps/web/tailwind.config.ts | 89 +++++++++ apps/web/tsconfig.json | 17 ++ apps/web/vitest.config.ts | 14 ++ 97 files changed, 5550 insertions(+) create mode 100644 apps/web/README.md create mode 100644 apps/web/app/api/auth/[...nextauth]/route.tsx create mode 100644 apps/web/app/api/trpc/[trpc]/route.ts create mode 100644 apps/web/app/dashboard/admin/page.tsx create mode 100644 apps/web/app/dashboard/archive/page.tsx create mode 100644 apps/web/app/dashboard/bookmarks/layout.tsx create mode 100644 apps/web/app/dashboard/bookmarks/loading.tsx create mode 100644 apps/web/app/dashboard/bookmarks/page.tsx create mode 100644 apps/web/app/dashboard/error.tsx create mode 100644 apps/web/app/dashboard/favourites/page.tsx create mode 100644 apps/web/app/dashboard/layout.tsx create mode 100644 apps/web/app/dashboard/lists/[listId]/page.tsx create mode 100644 apps/web/app/dashboard/lists/page.tsx create mode 100644 apps/web/app/dashboard/not-found.tsx create mode 100644 apps/web/app/dashboard/preview/[bookmarkId]/page.tsx create mode 100644 apps/web/app/dashboard/search/page.tsx create mode 100644 apps/web/app/dashboard/settings/page.tsx create mode 100644 apps/web/app/dashboard/tags/[tagName]/page.tsx create mode 100644 apps/web/app/dashboard/tags/page.tsx create mode 100644 apps/web/app/favicon.ico create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/signin/page.tsx create mode 100644 apps/web/components.json create mode 100644 apps/web/components/dashboard/bookmarks/AddLinkButton.tsx create mode 100644 apps/web/components/dashboard/bookmarks/AddToListModal.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx create mode 100644 apps/web/components/dashboard/bookmarks/Bookmarks.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx create mode 100644 apps/web/components/dashboard/bookmarks/LinkCard.tsx create mode 100644 apps/web/components/dashboard/bookmarks/TagList.tsx create mode 100644 apps/web/components/dashboard/bookmarks/TagModal.tsx create mode 100644 apps/web/components/dashboard/bookmarks/TextCard.tsx create mode 100644 apps/web/components/dashboard/bookmarks/TopNav.tsx create mode 100644 apps/web/components/dashboard/lists/AllListsView.tsx create mode 100644 apps/web/components/dashboard/lists/DeleteListButton.tsx create mode 100644 apps/web/components/dashboard/lists/ListView.tsx create mode 100644 apps/web/components/dashboard/search/SearchInput.tsx create mode 100644 apps/web/components/dashboard/settings/AddApiKey.tsx create mode 100644 apps/web/components/dashboard/settings/ApiKeySettings.tsx create mode 100644 apps/web/components/dashboard/settings/DeleteApiKey.tsx create mode 100644 apps/web/components/dashboard/sidebar/AllLists.tsx create mode 100644 apps/web/components/dashboard/sidebar/ModileSidebar.tsx create mode 100644 apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx create mode 100644 apps/web/components/dashboard/sidebar/NewListModal.tsx create mode 100644 apps/web/components/dashboard/sidebar/Sidebar.tsx create mode 100644 apps/web/components/dashboard/sidebar/SidebarItem.tsx create mode 100644 apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx create mode 100644 apps/web/components/signin/CredentialsForm.tsx create mode 100644 apps/web/components/signin/SignInForm.tsx create mode 100644 apps/web/components/signin/SignInProviderButton.tsx create mode 100644 apps/web/components/ui/action-button.tsx create mode 100644 apps/web/components/ui/back-button.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/dialog.tsx create mode 100644 apps/web/components/ui/dropdown-menu.tsx create mode 100644 apps/web/components/ui/form.tsx create mode 100644 apps/web/components/ui/imageCard.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/popover.tsx create mode 100644 apps/web/components/ui/scroll-area.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/skeleton.tsx create mode 100644 apps/web/components/ui/spinner.tsx create mode 100644 apps/web/components/ui/table.tsx create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/components/ui/toast.tsx create mode 100644 apps/web/components/ui/toaster.tsx create mode 100644 apps/web/components/ui/use-toast.ts create mode 100644 apps/web/lib/bookmarkUtils.tsx create mode 100644 apps/web/lib/hooks/bookmark-search.ts create mode 100644 apps/web/lib/providers.tsx create mode 100644 apps/web/lib/trpc.tsx create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.cjs create mode 100644 apps/web/public/blur.avif create mode 100644 apps/web/public/icons/logo-128.png create mode 100644 apps/web/public/icons/logo-16.png create mode 100644 apps/web/public/icons/logo-48.png create mode 100644 apps/web/public/manifest.json create mode 100644 apps/web/server/api/client.ts create mode 100644 apps/web/server/auth.ts create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vitest.config.ts (limited to 'apps/web') diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000..c4033664 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/web/app/api/auth/[...nextauth]/route.tsx b/apps/web/app/api/auth/[...nextauth]/route.tsx new file mode 100644 index 00000000..2f7f1cb0 --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.tsx @@ -0,0 +1,3 @@ +import { authHandler } from "@/server/auth"; + +export { authHandler as GET, authHandler as POST }; diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..02ca966d --- /dev/null +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,36 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "@hoarder/trpc/routers/_app"; +import { createContext } from "@/server/api/client"; +import { authenticateApiKey } from "@hoarder/trpc/auth"; +import { db } from "@hoarder/db"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + onError: ({ path, error }) => { + if (process.env.NODE_ENV === "development") { + console.error(`❌ tRPC failed on ${path}`); + } + console.error(error); + }, + + createContext: async (opts) => { + // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. + // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. + const authorizationHeader = opts.req.headers.get("Authorization"); + if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { + const token = authorizationHeader.split(" ")[1]!; + try { + const user = await authenticateApiKey(token); + return { user, db }; + } catch (e) { + // Fallthrough to cookie-based auth + } + } + + return createContext(); + }, + }); +export { handler as GET, handler as POST }; diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx new file mode 100644 index 00000000..6babdd79 --- /dev/null +++ b/apps/web/app/dashboard/admin/page.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { keepPreviousData } from "@tanstack/react-query"; +import { Trash } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +function ActionsSection() { + const { mutate: recrawlLinks, isPending: isRecrawlPending } = + api.admin.recrawlAllLinks.useMutation({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { mutate: reindexBookmarks, isPending: isReindexPending } = + api.admin.reindexAllBookmarks.useMutation({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + return ( + <> +

Actions

+ recrawlLinks()} + > + Recrawl All Links + + reindexBookmarks()} + > + Reindex All Bookmarks + + + ); +} + +function ServerStatsSection() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return ; + } + + return ( + <> +

Server Stats

+ + + + Num Users + {serverStats.numUsers} + + + Num Bookmarks + {serverStats.numBookmarks} + + +
+
+

Background Jobs

+ + + + Pending Crawling Jobs + {serverStats.pendingCrawls} + + + Pending Indexing Jobs + {serverStats.pendingIndexing} + + + Pending OpenAI Jobs + {serverStats.pendingOpenai} + + +
+ + ); +} + +function UsersSection() { + const { data: session } = useSession(); + const invalidateUserList = api.useUtils().users.list.invalidate; + const { data: users } = api.users.list.useQuery(); + const { mutate: deleteUser, isPending: isDeletionPending } = + api.users.delete.useMutation({ + onSuccess: () => { + toast({ + description: "User deleted", + }); + invalidateUserList(); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: `Something went wrong: ${e.message}`, + }); + }, + }); + + if (!users) { + return ; + } + + return ( + <> +

Users

+ + + Name + Email + Role + Action + + + {users.users.map((u) => ( + + {u.name} + {u.email} + {u.role} + + deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + + + + + ))} + +
+ + ); +} + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return ; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + return ( +
+

Admin

+
+ +
+ +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/archive/page.tsx b/apps/web/app/dashboard/archive/page.tsx new file mode 100644 index 00000000..69559185 --- /dev/null +++ b/apps/web/app/dashboard/archive/page.tsx @@ -0,0 +1,9 @@ +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; + +export default async function ArchivedBookmarkPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/bookmarks/layout.tsx b/apps/web/app/dashboard/bookmarks/layout.tsx new file mode 100644 index 00000000..71ee143b --- /dev/null +++ b/apps/web/app/dashboard/bookmarks/layout.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import TopNav from "@/components/dashboard/bookmarks/TopNav"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Hoarder - Bookmarks", +}; + +export default function BookmarksLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+ +
+
+
{children}
+
+ ); +} diff --git a/apps/web/app/dashboard/bookmarks/loading.tsx b/apps/web/app/dashboard/bookmarks/loading.tsx new file mode 100644 index 00000000..4e56c3c4 --- /dev/null +++ b/apps/web/app/dashboard/bookmarks/loading.tsx @@ -0,0 +1,11 @@ +import Spinner from "@/components/ui/spinner"; + +export default function Loading() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/app/dashboard/bookmarks/page.tsx b/apps/web/app/dashboard/bookmarks/page.tsx new file mode 100644 index 00000000..c9391d85 --- /dev/null +++ b/apps/web/app/dashboard/bookmarks/page.tsx @@ -0,0 +1,5 @@ +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; + +export default async function BookmarksPage() { + return ; +} diff --git a/apps/web/app/dashboard/error.tsx b/apps/web/app/dashboard/error.tsx new file mode 100644 index 00000000..556e59a3 --- /dev/null +++ b/apps/web/app/dashboard/error.tsx @@ -0,0 +1,9 @@ +"use client"; + +export default function Error() { + return ( +
+
Something went wrong
+
+ ); +} diff --git a/apps/web/app/dashboard/favourites/page.tsx b/apps/web/app/dashboard/favourites/page.tsx new file mode 100644 index 00000000..de17461d --- /dev/null +++ b/apps/web/app/dashboard/favourites/page.tsx @@ -0,0 +1,14 @@ +import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; + +export default async function FavouritesBookmarkPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx new file mode 100644 index 00000000..31d592fb --- /dev/null +++ b/apps/web/app/dashboard/layout.tsx @@ -0,0 +1,24 @@ +import { Separator } from "@/components/ui/separator"; +import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; +import Sidebar from "@/components/dashboard/sidebar/Sidebar"; + +export default async function Dashboard({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+ +
+
+
+ + +
+ {children} +
+
+ ); +} diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx new file mode 100644 index 00000000..006fd3ad --- /dev/null +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -0,0 +1,44 @@ +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; +import { notFound, redirect } from "next/navigation"; +import ListView from "@/components/dashboard/lists/ListView"; +import DeleteListButton from "@/components/dashboard/lists/DeleteListButton"; + +export default async function ListPage({ + params, +}: { + params: { listId: string }; +}) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + let list; + try { + list = await api.lists.get({ listId: params.listId }); + } catch (e) { + if (e instanceof TRPCError) { + if (e.code == "NOT_FOUND") { + notFound(); + } + } + throw e; + } + + const bookmarks = await api.bookmarks.getBookmarks({ ids: list.bookmarks }); + + return ( +
+
+ + {list.icon} {list.name} + + +
+
+ +
+ ); +} diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx new file mode 100644 index 00000000..88eeda47 --- /dev/null +++ b/apps/web/app/dashboard/lists/page.tsx @@ -0,0 +1,14 @@ +import { api } from "@/server/api/client"; +import AllListsView from "@/components/dashboard/lists/AllListsView"; + +export default async function ListsPage() { + const lists = await api.lists.list(); + + return ( +
+

📋 All Lists

+
+ +
+ ); +} diff --git a/apps/web/app/dashboard/not-found.tsx b/apps/web/app/dashboard/not-found.tsx new file mode 100644 index 00000000..64df220c --- /dev/null +++ b/apps/web/app/dashboard/not-found.tsx @@ -0,0 +1,7 @@ +export default function NotFound() { + return ( +
+
Not Found :(
+
+ ); +} diff --git a/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx new file mode 100644 index 00000000..707d2b69 --- /dev/null +++ b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx @@ -0,0 +1,14 @@ +import { api } from "@/server/api/client"; +import BookmarkPreview from "@/components/dashboard/bookmarks/BookmarkPreview"; + +export default async function BookmarkPreviewPage({ + params, +}: { + params: { bookmarkId: string }; +}) { + const bookmark = await api.bookmarks.getBookmark({ + bookmarkId: params.bookmarkId, + }); + + return ; +} diff --git a/apps/web/app/dashboard/search/page.tsx b/apps/web/app/dashboard/search/page.tsx new file mode 100644 index 00000000..602f6aa0 --- /dev/null +++ b/apps/web/app/dashboard/search/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid"; +import Loading from "../bookmarks/loading"; +import { Suspense, useRef } from "react"; +import { SearchInput } from "@/components/dashboard/search/SearchInput"; +import { useBookmarkSearch } from "@/lib/hooks/bookmark-search"; + +function SearchComp() { + const { data, isPending, isPlaceholderData } = useBookmarkSearch(); + + const inputRef: React.MutableRefObject = + useRef(null); + + return ( +
+ +
+ {data ? ( + b.id) }} + bookmarks={data.bookmarks} + /> + ) : ( + + )} +
+ ); +} + +export default function SearchPage() { + return ( + + + + ); +} diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx new file mode 100644 index 00000000..38091e6c --- /dev/null +++ b/apps/web/app/dashboard/settings/page.tsx @@ -0,0 +1,9 @@ +import ApiKeySettings from "@/components/dashboard/settings/ApiKeySettings"; +export default async function Settings() { + return ( +
+

Settings

+ +
+ ); +} diff --git a/apps/web/app/dashboard/tags/[tagName]/page.tsx b/apps/web/app/dashboard/tags/[tagName]/page.tsx new file mode 100644 index 00000000..c978b86a --- /dev/null +++ b/apps/web/app/dashboard/tags/[tagName]/page.tsx @@ -0,0 +1,55 @@ +import { getServerAuthSession } from "@/server/auth"; +import { db } from "@hoarder/db"; +import { notFound, redirect } from "next/navigation"; +import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid"; +import { api } from "@/server/api/client"; +import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; +import { and, eq } from "drizzle-orm"; + +export default async function TagPage({ + params, +}: { + params: { tagName: string }; +}) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + const tagName = decodeURIComponent(params.tagName); + const tag = await db.query.bookmarkTags.findFirst({ + where: and( + eq(bookmarkTags.userId, session.user.id), + eq(bookmarkTags.name, tagName), + ), + columns: { + id: true, + }, + }); + + if (!tag) { + // TODO: Better error message when the tag is not there + notFound(); + } + + const bookmarkIds = await db.query.tagsOnBookmarks.findMany({ + where: eq(tagsOnBookmarks.tagId, tag.id), + columns: { + bookmarkId: true, + }, + }); + + const query = { + ids: bookmarkIds.map((b) => b.bookmarkId), + archived: false, + }; + + const bookmarks = await api.bookmarks.getBookmarks(query); + + return ( +
+ {tagName} +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx new file mode 100644 index 00000000..44c164e1 --- /dev/null +++ b/apps/web/app/dashboard/tags/page.tsx @@ -0,0 +1,56 @@ +import { Separator } from "@/components/ui/separator"; +import { getServerAuthSession } from "@/server/auth"; +import { db } from "@hoarder/db"; +import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; +import { count, eq } from "drizzle-orm"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +function TagPill({ name, count }: { name: string; count: number }) { + return ( + + {name} {count} + + ); +} + +export default async function TagsPage() { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + let tags = await db + .select({ + id: tagsOnBookmarks.tagId, + name: bookmarkTags.name, + count: count(), + }) + .from(tagsOnBookmarks) + .where(eq(bookmarkTags.userId, session.user.id)) + .groupBy(tagsOnBookmarks.tagId) + .innerJoin(bookmarkTags, eq(bookmarkTags.id, tagsOnBookmarks.tagId)); + + // Sort tags by usage desc + tags = tags.sort((a, b) => b.count - a.count); + + let tagPill; + if (tags.length) { + tagPill = tags.map((t) => ( + + )); + } else { + tagPill = "No Tags"; + } + + return ( +
+ All Tags +
+
{tagPill}
+
+ ); +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 00000000..750e3c04 Binary files /dev/null and b/apps/web/app/favicon.ico differ diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 00000000..8abdb15c --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 00000000..b1790a1f --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import React from "react"; +import { Toaster } from "@/components/ui/toaster"; +import Providers from "@/lib/providers"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { getServerAuthSession } from "@/server/auth"; +import type { Viewport } from "next"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Hoarder", + applicationName: "Hoarder", + description: "Your AI powered second brain", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + title: "Hoarder", + }, + formatDetection: { + telephone: false, + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await getServerAuthSession(); + return ( + + + + {children} + + + + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 00000000..f467b64b --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,12 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; + +export default async function Home() { + // TODO: Home currently just redirects between pages until we build a proper landing page + const session = await getServerAuthSession(); + if (!session) { + redirect("/signin"); + } + + redirect("/dashboard/bookmarks"); +} diff --git a/apps/web/app/signin/page.tsx b/apps/web/app/signin/page.tsx new file mode 100644 index 00000000..fed71b62 --- /dev/null +++ b/apps/web/app/signin/page.tsx @@ -0,0 +1,25 @@ +import { PackageOpen } from "lucide-react"; +import SignInForm from "@/components/signin/SignInForm"; +import { redirect } from "next/dist/client/components/navigation"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function SignInPage() { + const session = await getServerAuthSession(); + if (session) { + redirect("/"); + } + + return ( +
+
+ + + +

Hoarder

+
+
+ +
+
+ ); +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 00000000..fa674c93 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/apps/web/components/dashboard/bookmarks/AddLinkButton.tsx b/apps/web/components/dashboard/bookmarks/AddLinkButton.tsx new file mode 100644 index 00000000..5973f909 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/AddLinkButton.tsx @@ -0,0 +1,102 @@ +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useState } from "react"; + +export function AddLinkButton({ children }: { children: React.ReactNode }) { + const [isOpen, setOpen] = useState(false); + + const formSchema = z.object({ + url: z.string().url({ message: "The link must be a valid URL" }), + }); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + url: "", + }, + }); + + const invalidateBookmarksCache = api.useUtils().bookmarks.invalidate; + const createBookmarkMutator = api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateBookmarksCache(); + form.reset(); + setOpen(false); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const onError: SubmitErrorHandler> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( + + {children} + +
+ + Add Link + + + createBookmarkMutator.mutate({ url: value.url, type: "link" }), + onError, + )} + > + { + return ( + + + + + + ); + }} + /> + + + + + + Add + + + + +
+
+ ); +} diff --git a/apps/web/components/dashboard/bookmarks/AddToListModal.tsx b/apps/web/components/dashboard/bookmarks/AddToListModal.tsx new file mode 100644 index 00000000..c9fd5da0 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/AddToListModal.tsx @@ -0,0 +1,168 @@ +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; + +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import LoadingSpinner from "@/components/ui/spinner"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +export default function AddToListModal({ + bookmarkId, + open, + setOpen, +}: { + bookmarkId: string; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const formSchema = z.object({ + listId: z.string({ + required_error: "Please select a list", + }), + }); + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + + const { data: lists, isPending: isFetchingListsPending } = + api.lists.list.useQuery(); + + const listInvalidationFunction = api.useUtils().lists.get.invalidate; + const bookmarksInvalidationFunction = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate: addToList, isPending: isAddingToListPending } = + api.lists.addToList.useMutation({ + onSuccess: (_resp, req) => { + toast({ + description: "List has been updated!", + }); + listInvalidationFunction({ listId: req.listId }); + bookmarksInvalidationFunction(); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + toast({ + variant: "destructive", + description: e.message, + }); + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + const isPending = isFetchingListsPending || isAddingToListPending; + + return ( + + +
+ { + addToList({ + bookmarkId: bookmarkId, + listId: value.listId, + }); + })} + > + + Add to List + + +
+ {lists ? ( + { + return ( + + + + + + + ); + }} + /> + ) : ( + + )} +
+ + + + + + Add + + +
+ +
+
+ ); +} + +export function useAddToListModal(bookmarkId: string) { + const [open, setOpen] = useState(false); + + return { + open, + setOpen, + content: ( + + ), + }; +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx new file mode 100644 index 00000000..1f5fa433 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx @@ -0,0 +1,30 @@ +import { + ImageCard, + ImageCardBody, + ImageCardContent, + ImageCardFooter, + ImageCardTitle, + ImageCardBanner, +} from "@/components/ui/imageCard"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function BookmarkCardSkeleton() { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx new file mode 100644 index 00000000..4f08ebee --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useToast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ZBookmark, ZBookmarkedLink } from "@hoarder/trpc/types/bookmarks"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Archive, + Link, + List, + MoreHorizontal, + Pencil, + RotateCw, + Star, + Tags, + Trash2, +} from "lucide-react"; +import { useTagModel } from "./TagModal"; +import { useState } from "react"; +import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { useAddToListModal } from "./AddToListModal"; + +export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { + const { toast } = useToast(); + const linkId = bookmark.id; + + const { setOpen: setTagModalIsOpen, content: tagModal } = + useTagModel(bookmark); + const { setOpen: setAddToListModalOpen, content: addToListModal } = + useAddToListModal(bookmark.id); + + const [isTextEditorOpen, setTextEditorOpen] = useState(false); + + const invalidateAllBookmarksCache = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const invalidateBookmarkCache = + api.useUtils().bookmarks.getBookmark.invalidate; + + const onError = () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }; + const deleteBookmarkMutator = api.bookmarks.deleteBookmark.useMutation({ + onSuccess: () => { + toast({ + description: "The bookmark has been deleted!", + }); + }, + onError, + onSettled: () => { + invalidateAllBookmarksCache(); + }, + }); + + const updateBookmarkMutator = api.bookmarks.updateBookmark.useMutation({ + onSuccess: () => { + toast({ + description: "The bookmark has been updated!", + }); + }, + onError, + onSettled: () => { + invalidateBookmarkCache({ bookmarkId: bookmark.id }); + invalidateAllBookmarksCache(); + }, + }); + + const crawlBookmarkMutator = api.bookmarks.recrawlBookmark.useMutation({ + onSuccess: () => { + toast({ + description: "Re-fetch has been enqueued!", + }); + }, + onError, + onSettled: () => { + invalidateBookmarkCache({ bookmarkId: bookmark.id }); + }, + }); + + return ( + <> + {tagModal} + {addToListModal} + + + + + + + {bookmark.content.type === "text" && ( + setTextEditorOpen(true)}> + + Edit + + )} + + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }) + } + > + + {bookmark.favourited ? "Un-favourite" : "Favourite"} + + + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }) + } + > + + {bookmark.archived ? "Un-archive" : "Archive"} + + {bookmark.content.type === "link" && ( + { + navigator.clipboard.writeText( + (bookmark.content as ZBookmarkedLink).url, + ); + toast({ + description: "Link was added to your clipboard!", + }); + }} + > + + Copy Link + + )} + setTagModalIsOpen(true)}> + + Edit Tags + + + setAddToListModalOpen(true)}> + + Add to List + + + {bookmark.content.type === "link" && ( + + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + + Refresh + + )} + + deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + + Delete + + + + + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx new file mode 100644 index 00000000..2a8ae1b1 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { BackButton } from "@/components/ui/back-button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isBookmarkStillCrawling } from "@/lib/bookmarkUtils"; +import { api } from "@/lib/trpc"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ArrowLeftCircle, CalendarDays, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import Markdown from "react-markdown"; + +export default function BookmarkPreview({ + initialData, +}: { + initialData: ZBookmark; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + // If the link is not crawled or not tagged + if (isBookmarkStillCrawling(data)) { + return 1000; + } + return false; + }, + }, + ); + + const linkHeader = bookmark.content.type == "link" && ( +
+

+ {bookmark.content.title || bookmark.content.url} +

+ + View Original + + +
+ ); + + let content; + switch (bookmark.content.type) { + case "link": { + if (!bookmark.content.htmlContent) { + content = ( +
Failed to fetch link content ...
+ ); + } else { + content = ( +
+ ); + } + break; + } + case "text": { + content = {bookmark.content.text}; + break; + } + } + + return ( +
+
+ + + +
+ + {bookmark.createdAt.toLocaleString()} + +
+
+
+ {linkHeader} +
+ {isBookmarkStillCrawling(bookmark) ? ( +
+ + + +
+ ) : ( + content + )} +
+
+ ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx new file mode 100644 index 00000000..a5b58f1a --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx @@ -0,0 +1,109 @@ +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; +import { toast } from "@/components/ui/use-toast"; + +export function BookmarkedTextEditor({ + bookmark, + open, + setOpen, +}: { + bookmark?: ZBookmark; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const isNewBookmark = bookmark === undefined; + const [noteText, setNoteText] = useState( + bookmark && bookmark.content.type == "text" ? bookmark.content.text : "", + ); + + const invalidateAllBookmarksCache = + api.useUtils().bookmarks.getBookmarks.invalidate; + const invalidateOneBookmarksCache = + api.useUtils().bookmarks.getBookmark.invalidate; + + const { mutate: createBookmarkMutator, isPending: isCreationPending } = + api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateAllBookmarksCache(); + toast({ + description: "Note created!", + }); + setOpen(false); + setNoteText(""); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + const { mutate: updateBookmarkMutator, isPending: isUpdatePending } = + api.bookmarks.updateBookmarkText.useMutation({ + onSuccess: () => { + invalidateOneBookmarksCache({ + bookmarkId: bookmark!.id, + }); + toast({ + description: "Note updated!", + }); + setOpen(false); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + const isPending = isCreationPending || isUpdatePending; + + const onSave = () => { + if (isNewBookmark) { + createBookmarkMutator({ + type: "text", + text: noteText, + }); + } else { + updateBookmarkMutator({ + bookmarkId: bookmark.id, + text: noteText, + }); + } + }; + + return ( + + + + {isNewBookmark ? "New Note" : "Edit Note"} + + Write your note with markdown support + + +