diff options
Diffstat (limited to 'apps/web/app/dashboard')
| -rw-r--r-- | apps/web/app/dashboard/admin/page.tsx | 203 | ||||
| -rw-r--r-- | apps/web/app/dashboard/archive/page.tsx | 9 | ||||
| -rw-r--r-- | apps/web/app/dashboard/bookmarks/layout.tsx | 23 | ||||
| -rw-r--r-- | apps/web/app/dashboard/bookmarks/loading.tsx | 11 | ||||
| -rw-r--r-- | apps/web/app/dashboard/bookmarks/page.tsx | 5 | ||||
| -rw-r--r-- | apps/web/app/dashboard/error.tsx | 9 | ||||
| -rw-r--r-- | apps/web/app/dashboard/favourites/page.tsx | 14 | ||||
| -rw-r--r-- | apps/web/app/dashboard/layout.tsx | 24 | ||||
| -rw-r--r-- | apps/web/app/dashboard/lists/[listId]/page.tsx | 44 | ||||
| -rw-r--r-- | apps/web/app/dashboard/lists/page.tsx | 14 | ||||
| -rw-r--r-- | apps/web/app/dashboard/not-found.tsx | 7 | ||||
| -rw-r--r-- | apps/web/app/dashboard/preview/[bookmarkId]/page.tsx | 14 | ||||
| -rw-r--r-- | apps/web/app/dashboard/search/page.tsx | 41 | ||||
| -rw-r--r-- | apps/web/app/dashboard/settings/page.tsx | 9 | ||||
| -rw-r--r-- | apps/web/app/dashboard/tags/[tagName]/page.tsx | 55 | ||||
| -rw-r--r-- | apps/web/app/dashboard/tags/page.tsx | 56 |
16 files changed, 538 insertions, 0 deletions
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 ( + <> + <p className="text-xl">Actions</p> + <ActionButton + className="w-1/2" + variant="destructive" + loading={isRecrawlPending} + onClick={() => recrawlLinks()} + > + Recrawl All Links + </ActionButton> + <ActionButton + className="w-1/2" + variant="destructive" + loading={isReindexPending} + onClick={() => reindexBookmarks()} + > + Reindex All Bookmarks + </ActionButton> + </> + ); +} + +function ServerStatsSection() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return <LoadingSpinner />; + } + + return ( + <> + <p className="text-xl">Server Stats</p> + <Table className="w-1/2"> + <TableBody> + <TableRow> + <TableCell className="w-2/3">Num Users</TableCell> + <TableCell>{serverStats.numUsers}</TableCell> + </TableRow> + <TableRow> + <TableCell>Num Bookmarks</TableCell> + <TableCell>{serverStats.numBookmarks}</TableCell> + </TableRow> + </TableBody> + </Table> + <hr /> + <p className="text-xl">Background Jobs</p> + <Table className="w-1/2"> + <TableBody> + <TableRow> + <TableCell className="w-2/3">Pending Crawling Jobs</TableCell> + <TableCell>{serverStats.pendingCrawls}</TableCell> + </TableRow> + <TableRow> + <TableCell>Pending Indexing Jobs</TableCell> + <TableCell>{serverStats.pendingIndexing}</TableCell> + </TableRow> + <TableRow> + <TableCell>Pending OpenAI Jobs</TableCell> + <TableCell>{serverStats.pendingOpenai}</TableCell> + </TableRow> + </TableBody> + </Table> + </> + ); +} + +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 <LoadingSpinner />; + } + + return ( + <> + <p className="text-xl">Users</p> + <Table> + <TableHeader> + <TableHead>Name</TableHead> + <TableHead>Email</TableHead> + <TableHead>Role</TableHead> + <TableHead>Action</TableHead> + </TableHeader> + <TableBody> + {users.users.map((u) => ( + <TableRow key={u.id}> + <TableCell>{u.name}</TableCell> + <TableCell>{u.email}</TableCell> + <TableCell>{u.role}</TableCell> + <TableCell> + <ActionButton + variant="destructive" + onClick={() => deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + <Trash /> + </ActionButton> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </> + ); +} + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return <LoadingSpinner />; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + return ( + <div className="m-4 flex flex-col gap-5 rounded-md border bg-white p-4"> + <p className="text-2xl">Admin</p> + <hr /> + <ServerStatsSection /> + <hr /> + <UsersSection /> + <hr /> + <ActionsSection /> + </div> + ); +} 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 ( + <div className="continer mt-4"> + <Bookmarks title="🗄️ Archive" archived={true} showDivider={true} /> + </div> + ); +} 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 ( + <div className="flex h-full flex-col"> + <div> + <TopNav /> + </div> + <hr /> + <div className="my-4 flex-1 pb-4">{children}</div> + </div> + ); +} 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 ( + <div className="flex size-full"> + <div className="m-auto"> + <Spinner /> + </div> + </div> + ); +} 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 <Bookmarks title="Bookmarks" archived={false} />; +} 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 ( + <div className="flex size-full"> + <div className="m-auto text-3xl">Something went wrong</div> + </div> + ); +} 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 ( + <div className="continer mt-4"> + <Bookmarks + title="⭐️ Favourites" + archived={false} + favourited={true} + showDivider={true} + /> + </div> + ); +} 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 ( + <div className="flex min-h-screen w-screen flex-col sm:h-screen sm:flex-row"> + <div className="hidden flex-none sm:flex"> + <Sidebar /> + </div> + <main className="flex-1 bg-gray-100 sm:overflow-y-auto"> + <div className="block w-full sm:hidden"> + <MobileSidebar /> + <Separator /> + </div> + {children} + </main> + </div> + ); +} 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 ( + <div className="container flex flex-col gap-3"> + <div className="flex justify-between"> + <span className="pt-4 text-2xl"> + {list.icon} {list.name} + </span> + <DeleteListButton list={list} /> + </div> + <hr /> + <ListView list={list} bookmarks={bookmarks.bookmarks} /> + </div> + ); +} 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 ( + <div className="container mt-4 flex flex-col gap-3"> + <p className="text-2xl">📋 All Lists</p> + <hr /> + <AllListsView initialData={lists.lists} /> + </div> + ); +} 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 ( + <div className="flex size-full"> + <div className="m-auto text-3xl">Not Found :(</div> + </div> + ); +} 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 <BookmarkPreview initialData={bookmark} />; +} 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<HTMLInputElement | null> = + useRef<HTMLInputElement | null>(null); + + return ( + <div className="container flex flex-col gap-3 p-4"> + <SearchInput + ref={inputRef} + autoFocus={true} + loading={isPending || isPlaceholderData} + /> + <hr /> + {data ? ( + <BookmarksGrid + query={{ ids: data.bookmarks.map((b) => b.id) }} + bookmarks={data.bookmarks} + /> + ) : ( + <Loading /> + )} + </div> + ); +} + +export default function SearchPage() { + return ( + <Suspense> + <SearchComp /> + </Suspense> + ); +} 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 ( + <div className="m-4 flex flex-col space-y-2 rounded-md border bg-white p-4"> + <p className="text-2xl">Settings</p> + <ApiKeySettings /> + </div> + ); +} 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 ( + <div className="container flex flex-col gap-3"> + <span className="pt-4 text-2xl">{tagName}</span> + <hr /> + <BookmarksGrid query={query} bookmarks={bookmarks.bookmarks} /> + </div> + ); +} 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 ( + <Link + className="text-foreground hover:bg-foreground hover:text-background flex gap-2 rounded-md border border-gray-200 bg-white px-2 py-1" + href={`/dashboard/tags/${name}`} + > + {name} <Separator orientation="vertical" /> {count} + </Link> + ); +} + +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) => ( + <TagPill key={t.id} name={t.name} count={t.count} /> + )); + } else { + tagPill = "No Tags"; + } + + return ( + <div className="container mt-2 space-y-3"> + <span className="text-2xl">All Tags</span> + <hr /> + <div className="flex flex-wrap gap-3">{tagPill}</div> + </div> + ); +} |
