diff options
Diffstat (limited to 'apps/web')
97 files changed, 5550 insertions, 0 deletions
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 ( + <> + <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> + ); +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico Binary files differnew file mode 100644 index 00000000..750e3c04 --- /dev/null +++ b/apps/web/app/favicon.ico 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 ( + <html lang="en"> + <body className={inter.className}> + <Providers session={session}> + {children} + <ReactQueryDevtools initialIsOpen={false} /> + </Providers> + <Toaster /> + </body> + </html> + ); +} 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 ( + <div className="grid min-h-screen grid-rows-6 justify-center"> + <div className="row-span-2 flex w-96 items-center justify-center space-x-2"> + <span> + <PackageOpen size="60" className="" /> + </span> + <p className="text-6xl">Hoarder</p> + </div> + <div className="row-span-4 px-3"> + <SignInForm /> + </div> + </div> + ); +} 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<z.infer<typeof formSchema>>({ + 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<z.infer<typeof formSchema>> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( + <Dialog open={isOpen} onOpenChange={setOpen}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <Form {...form}> + <DialogHeader> + <DialogTitle>Add Link</DialogTitle> + </DialogHeader> + <form + className="flex flex-col gap-4" + onSubmit={form.handleSubmit( + (value) => + createBookmarkMutator.mutate({ url: value.url, type: "link" }), + onError, + )} + > + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input type="text" placeholder="Link" {...field} /> + </FormControl> + </FormItem> + ); + }} + /> + <DialogFooter className="flex-shrink gap-1 sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={createBookmarkMutator.isPending} + > + Add + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} 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<z.infer<typeof formSchema>>({ + 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 ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + addToList({ + bookmarkId: bookmarkId, + listId: value.listId, + }); + })} + > + <DialogHeader> + <DialogTitle>Add to List</DialogTitle> + </DialogHeader> + + <div className="py-4"> + {lists ? ( + <FormField + control={form.control} + name="listId" + render={({ field }) => { + return ( + <FormItem> + <FormControl> + <Select onValueChange={field.onChange}> + <SelectTrigger className="w-full"> + <SelectValue placeholder="Select a list" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {lists && + lists.lists.map((l) => ( + <SelectItem key={l.id} value={l.id}> + {l.icon} {l.name} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + ) : ( + <LoadingSpinner /> + )} + </div> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isAddingToListPending} + disabled={isPending} + > + Add + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} + +export function useAddToListModal(bookmarkId: string) { + const [open, setOpen] = useState(false); + + return { + open, + setOpen, + content: ( + <AddToListModal bookmarkId={bookmarkId} open={open} setOpen={setOpen} /> + ), + }; +} 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 ( + <ImageCard + className={ + "border-grey-100 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all" + } + > + <ImageCardBanner src="/blur.avif" /> + <ImageCardContent> + <ImageCardTitle></ImageCardTitle> + <ImageCardBody className="space-y-2"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </ImageCardBody> + <ImageCardFooter></ImageCardFooter> + </ImageCardContent> + </ImageCard> + ); +} 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} + <BookmarkedTextEditor + bookmark={bookmark} + open={isTextEditorOpen} + setOpen={setTextEditorOpen} + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + className="px-1 focus-visible:ring-0 focus-visible:ring-offset-0" + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-fit"> + {bookmark.content.type === "text" && ( + <DropdownMenuItem onClick={() => setTextEditorOpen(true)}> + <Pencil className="mr-2 size-4" /> + <span>Edit</span> + </DropdownMenuItem> + )} + <DropdownMenuItem + onClick={() => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }) + } + > + <Star className="mr-2 size-4" /> + <span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }) + } + > + <Archive className="mr-2 size-4" /> + <span>{bookmark.archived ? "Un-archive" : "Archive"}</span> + </DropdownMenuItem> + {bookmark.content.type === "link" && ( + <DropdownMenuItem + onClick={() => { + navigator.clipboard.writeText( + (bookmark.content as ZBookmarkedLink).url, + ); + toast({ + description: "Link was added to your clipboard!", + }); + }} + > + <Link className="mr-2 size-4" /> + <span>Copy Link</span> + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => setTagModalIsOpen(true)}> + <Tags className="mr-2 size-4" /> + <span>Edit Tags</span> + </DropdownMenuItem> + + <DropdownMenuItem onClick={() => setAddToListModalOpen(true)}> + <List className="mr-2 size-4" /> + <span>Add to List</span> + </DropdownMenuItem> + + {bookmark.content.type === "link" && ( + <DropdownMenuItem + onClick={() => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <RotateCw className="mr-2 size-4" /> + <span>Refresh</span> + </DropdownMenuItem> + )} + <DropdownMenuItem + className="text-destructive" + onClick={() => + deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <Trash2 className="mr-2 size-4" /> + <span>Delete</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </> + ); +} 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" && ( + <div className="flex flex-col space-y-2"> + <p className="text-center text-3xl"> + {bookmark.content.title || bookmark.content.url} + </p> + <Link href={bookmark.content.url} className="mx-auto flex gap-2"> + <span className="my-auto">View Original</span> + <ExternalLink /> + </Link> + </div> + ); + + let content; + switch (bookmark.content.type) { + case "link": { + if (!bookmark.content.htmlContent) { + content = ( + <div className="text-red-500">Failed to fetch link content ...</div> + ); + } else { + content = ( + <div + dangerouslySetInnerHTML={{ + __html: bookmark.content.htmlContent || "", + }} + className="prose" + /> + ); + } + break; + } + case "text": { + content = <Markdown className="prose">{bookmark.content.text}</Markdown>; + break; + } + } + + return ( + <div className="bg-background m-4 min-h-screen space-y-4 rounded-md border p-4"> + <div className="flex justify-between"> + <BackButton className="ghost" variant="ghost"> + <ArrowLeftCircle /> + </BackButton> + <div className="my-auto"> + <span className="my-auto flex gap-2"> + <CalendarDays /> {bookmark.createdAt.toLocaleString()} + </span> + </div> + </div> + <hr /> + {linkHeader} + <div className="mx-auto flex h-full border-x p-2 px-4 lg:w-2/3"> + {isBookmarkStillCrawling(bookmark) ? ( + <div className="flex w-full flex-col gap-2"> + <Skeleton className="h-4" /> + <Skeleton className="h-4" /> + <Skeleton className="h-4" /> + </div> + ) : ( + content + )} + </div> + </div> + ); +} 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 ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle> + <DialogDescription> + Write your note with markdown support + </DialogDescription> + </DialogHeader> + <Textarea + value={noteText} + onChange={(e) => setNoteText(e.target.value)} + className="h-52 grow" + /> + <DialogFooter className="flex-shrink gap-1 sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton type="button" loading={isPending} onClick={onSave}> + Save + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx new file mode 100644 index 00000000..8a620341 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx @@ -0,0 +1,20 @@ +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import Markdown from "react-markdown"; + +export function BookmarkedTextViewer({ + content, + open, + setOpen, +}: { + content: string; + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="max-h-[75%] overflow-auto"> + <Markdown className="prose">{content}</Markdown> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/Bookmarks.tsx b/apps/web/components/dashboard/bookmarks/Bookmarks.tsx new file mode 100644 index 00000000..1ad3670c --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/Bookmarks.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import BookmarksGrid from "./BookmarksGrid"; +import { ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks"; +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function Bookmarks({ + favourited, + archived, + title, + showDivider, +}: ZGetBookmarksRequest & { title: string; showDivider?: boolean }) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const query = { + favourited, + archived, + }; + + const bookmarks = await api.bookmarks.getBookmarks(query); + + return ( + <div className="container flex flex-col gap-3"> + <div className="text-2xl">{title}</div> + {showDivider && <hr />} + <BookmarksGrid query={query} bookmarks={bookmarks.bookmarks} /> + </div> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx new file mode 100644 index 00000000..4d5b6b0a --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -0,0 +1,64 @@ +"use client"; + +import LinkCard from "./LinkCard"; +import { ZBookmark, ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks"; +import { api } from "@/lib/trpc"; +import TextCard from "./TextCard"; +import { Slot } from "@radix-ui/react-slot"; +import Masonry from "react-masonry-css"; +import resolveConfig from "tailwindcss/resolveConfig"; +import tailwindConfig from "@/tailwind.config"; +import { useMemo } from "react"; + +function getBreakpointConfig() { + const fullConfig = resolveConfig(tailwindConfig); + + const breakpointColumnsObj: { [key: number]: number; default: number } = { + default: 3, + }; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1; + return breakpointColumnsObj; +} + +function renderBookmark(bookmark: ZBookmark) { + let comp; + switch (bookmark.content.type) { + case "link": + comp = <LinkCard bookmark={bookmark} />; + break; + case "text": + comp = <TextCard bookmark={bookmark} />; + break; + } + return ( + <Slot + key={bookmark.id} + className="border-grey-100 mb-4 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all" + > + {comp} + </Slot> + ); +} + +export default function BookmarksGrid({ + query, + bookmarks: initialBookmarks, +}: { + query: ZGetBookmarksRequest; + bookmarks: ZBookmark[]; +}) { + const { data } = api.bookmarks.getBookmarks.useQuery(query, { + initialData: { bookmarks: initialBookmarks }, + }); + const breakpointConfig = useMemo(() => getBreakpointConfig(), []); + if (data.bookmarks.length == 0) { + return <p>No bookmarks</p>; + } + return ( + <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + {data.bookmarks.map((b) => renderBookmark(b))} + </Masonry> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/LinkCard.tsx b/apps/web/components/dashboard/bookmarks/LinkCard.tsx new file mode 100644 index 00000000..50f30e47 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/LinkCard.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { + ImageCard, + ImageCardBanner, + ImageCardBody, + ImageCardContent, + ImageCardFooter, + ImageCardTitle, +} from "@/components/ui/imageCard"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import Link from "next/link"; +import BookmarkOptions from "./BookmarkOptions"; +import { api } from "@/lib/trpc"; +import { Maximize2, Star } from "lucide-react"; +import TagList from "./TagList"; +import { + isBookmarkStillCrawling, + isBookmarkStillLoading, + isBookmarkStillTagging, +} from "@/lib/bookmarkUtils"; + +export default function LinkCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + 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 (isBookmarkStillLoading(data)) { + return 1000; + } + return false; + }, + }, + ); + const link = bookmark.content; + if (link.type != "link") { + throw new Error("Unexpected bookmark type"); + } + const parsedUrl = new URL(link.url); + + // A dummy white pixel for when there's no image. + // TODO: Better handling for cards with no images + const image = + link.imageUrl ?? + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII="; + + return ( + <ImageCard className={className}> + <Link href={link.url}> + <ImageCardBanner + src={isBookmarkStillCrawling(bookmark) ? "/blur.avif" : image} + /> + </Link> + <ImageCardContent> + <ImageCardTitle> + <Link className="line-clamp-2" href={link.url} target="_blank"> + {link?.title ?? parsedUrl.host} + </Link> + </ImageCardTitle> + {/* There's a hack here. Every tag has the full hight of the container itself. That why, when we enable flex-wrap, + the overflowed don't show up. */} + <ImageCardBody className="flex h-full flex-wrap space-x-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </ImageCardBody> + <ImageCardFooter> + <div className="mt-1 flex justify-between text-gray-500"> + <div className="my-auto"> + <Link + className="line-clamp-1 hover:text-black" + href={link.url} + target="_blank" + > + {parsedUrl.host} + </Link> + </div> + <div className="flex"> + {bookmark.favourited && ( + <Star + className="m-1 size-8 rounded p-1" + color="#ebb434" + fill="#ebb434" + /> + )} + <Link + className="my-auto block px-2" + href={`/dashboard/preview/${bookmark.id}`} + > + <Maximize2 size="20" /> + </Link> + <BookmarkOptions bookmark={bookmark} /> + </div> + </div> + </ImageCardFooter> + </ImageCardContent> + </ImageCard> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx new file mode 100644 index 00000000..6c9d2d22 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -0,0 +1,39 @@ +import { badgeVariants } from "@/components/ui/badge"; +import Link from "next/link"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { cn } from "@/lib/utils"; + +export default function TagList({ + bookmark, + loading, +}: { + bookmark: ZBookmark; + loading?: boolean; +}) { + if (loading) { + return ( + <div className="flex w-full flex-col justify-end space-y-2 p-2"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </div> + ); + } + return ( + <> + {bookmark.tags.map((t) => ( + <div key={t.id} className="flex h-full flex-col justify-end"> + <Link + className={cn( + badgeVariants({ variant: "outline" }), + "hover:bg-foreground hover:text-secondary text-nowrap", + )} + href={`/dashboard/tags/${t.name}`} + > + {t.name} + </Link> + </div> + ))} + </> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/TagModal.tsx b/apps/web/components/dashboard/bookmarks/TagModal.tsx new file mode 100644 index 00000000..8c09d00e --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/TagModal.tsx @@ -0,0 +1,207 @@ +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 { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ZAttachedByEnum } from "@hoarder/trpc/types/tags"; +import { cn } from "@/lib/utils"; +import { Sparkles, X } from "lucide-react"; +import { useState, KeyboardEvent, useEffect } from "react"; + +type EditableTag = { attachedBy: ZAttachedByEnum; id?: string; name: string }; + +function TagAddInput({ addTag }: { addTag: (tag: string) => void }) { + const onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + addTag(e.currentTarget.value); + e.currentTarget.value = ""; + } + }; + return ( + <Input + onKeyUp={onKeyUp} + className="h-8 w-full border-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + ); +} + +function TagPill({ + tag, + deleteCB, +}: { + tag: { attachedBy: ZAttachedByEnum; id?: string; name: string }; + deleteCB: () => void; +}) { + const isAttachedByAI = tag.attachedBy == "ai"; + return ( + <div + className={cn( + "flex min-h-8 space-x-1 rounded px-2", + isAttachedByAI + ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white" + : "bg-gray-200", + )} + > + {isAttachedByAI && <Sparkles className="m-auto size-4" />} + <p className="m-auto">{tag.name}</p> + <button className="m-auto size-4" onClick={deleteCB}> + <X className="size-4" /> + </button> + </div> + ); +} + +function TagEditor({ + tags, + setTags, +}: { + tags: Map<string, EditableTag>; + setTags: ( + cb: (m: Map<string, EditableTag>) => Map<string, EditableTag>, + ) => void; +}) { + return ( + <div className="mt-4 flex flex-wrap gap-2 rounded border p-2"> + {[...tags.values()].map((t) => ( + <TagPill + key={t.name} + tag={t} + deleteCB={() => + setTags((m) => { + const newMap = new Map(m); + newMap.delete(t.name); + return newMap; + }) + } + /> + ))} + <div className="flex-1"> + <TagAddInput + addTag={(val) => { + setTags((m) => { + if (m.has(val)) { + // Tag already exists + // Do nothing + return m; + } + const newMap = new Map(m); + newMap.set(val, { attachedBy: "human", name: val }); + return newMap; + }); + }} + /> + </div> + </div> + ); +} + +export default function TagModal({ + bookmark, + open, + setOpen, +}: { + bookmark: ZBookmark; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const [tags, setTags] = useState<Map<string, EditableTag>>(new Map()); + useEffect(() => { + const m = new Map<string, EditableTag>(); + for (const t of bookmark.tags) { + m.set(t.name, { attachedBy: t.attachedBy, id: t.id, name: t.name }); + } + setTags(m); + }, [bookmark.tags]); + + const bookmarkInvalidationFunction = + api.useUtils().bookmarks.getBookmark.invalidate; + + const { mutate, isPending } = api.bookmarks.updateTags.useMutation({ + onSuccess: () => { + toast({ + description: "Tags has been updated!", + }); + bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); + }, + onError: () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }, + }); + + const onSaveButton = () => { + const exitingTags = new Set(bookmark.tags.map((t) => t.name)); + + const attach = []; + const detach = []; + for (const t of tags.values()) { + if (!exitingTags.has(t.name)) { + attach.push({ tag: t.name }); + } + } + for (const t of bookmark.tags) { + if (!tags.has(t.name)) { + detach.push({ tagId: t.id }); + } + } + mutate({ + bookmarkId: bookmark.id, + attach, + detach, + }); + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Edit Tags</DialogTitle> + </DialogHeader> + <TagEditor tags={tags} setTags={setTags} /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + loading={isPending} + onClick={onSaveButton} + > + Save + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function useTagModel(bookmark: ZBookmark) { + const [open, setOpen] = useState(false); + + return { + open, + setOpen, + content: ( + <TagModal + key={bookmark.id} + bookmark={bookmark} + open={open} + setOpen={setOpen} + /> + ), + }; +} diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx new file mode 100644 index 00000000..2565e69d --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import BookmarkOptions from "./BookmarkOptions"; +import { api } from "@/lib/trpc"; +import { Maximize2, Star } from "lucide-react"; +import { cn } from "@/lib/utils"; +import TagList from "./TagList"; +import Markdown from "react-markdown"; +import { useState } from "react"; +import { BookmarkedTextViewer } from "./BookmarkedTextViewer"; +import Link from "next/link"; +import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; + +export default function TextCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + if (isBookmarkStillTagging(data)) { + return 1000; + } + return false; + }, + }, + ); + const [previewModalOpen, setPreviewModalOpen] = useState(false); + const bookmarkedText = bookmark.content; + if (bookmarkedText.type != "text") { + throw new Error("Unexpected bookmark type"); + } + + return ( + <> + <BookmarkedTextViewer + content={bookmarkedText.text} + open={previewModalOpen} + setOpen={setPreviewModalOpen} + /> + <div + className={cn( + className, + cn( + "flex h-min max-h-96 flex-col gap-y-1 overflow-hidden rounded-lg p-2 shadow-md", + ), + )} + > + <Markdown className="prose grow overflow-hidden"> + {bookmarkedText.text} + </Markdown> + <div className="mt-4 flex flex-none flex-wrap gap-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + <div className="flex w-full justify-between"> + <div /> + <div className="flex gap-0 text-gray-500"> + <div> + {bookmark.favourited && ( + <Star + className="my-1 size-8 rounded p-1" + color="#ebb434" + fill="#ebb434" + /> + )} + </div> + <Link + className="my-auto block px-2" + href={`/dashboard/preview/${bookmark.id}`} + > + <Maximize2 size="20" /> + </Link> + <BookmarkOptions bookmark={bookmark} /> + </div> + </div> + </div> + </> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/TopNav.tsx b/apps/web/components/dashboard/bookmarks/TopNav.tsx new file mode 100644 index 00000000..6c0f18e5 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/TopNav.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Link, NotebookPen } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { useState } from "react"; +import { AddLinkButton } from "./AddLinkButton"; +import { SearchInput } from "../search/SearchInput"; + +function AddText() { + const [isEditorOpen, setEditorOpen] = useState(false); + + return ( + <div className="flex"> + <BookmarkedTextEditor open={isEditorOpen} setOpen={setEditorOpen} /> + <Button className="m-auto" onClick={() => setEditorOpen(true)}> + <NotebookPen /> + </Button> + </div> + ); +} + +function AddLink() { + return ( + <div className="flex"> + <AddLinkButton> + <Button className="m-auto"> + <Link /> + </Button> + </AddLinkButton> + </div> + ); +} + +export default function TopNav() { + return ( + <div className="container flex gap-2 py-4"> + <SearchInput /> + <AddLink /> + <AddText /> + </div> + ); +} diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx new file mode 100644 index 00000000..81f31cde --- /dev/null +++ b/apps/web/components/dashboard/lists/AllListsView.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/trpc"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; +import { keepPreviousData } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { useNewListModal } from "@/components/dashboard/sidebar/NewListModal"; + +function ListItem({ + name, + icon, + path, +}: { + name: string; + icon: string; + path: string; +}) { + return ( + <Link href={path}> + <div className="bg-background rounded-md border border-gray-200 px-4 py-2 text-lg"> + <p className="text-nowrap"> + {icon} {name} + </p> + </div> + </Link> + ); +} + +export default function AllListsView({ + initialData, +}: { + initialData: ZBookmarkList[]; +}) { + const { setOpen: setIsNewListModalOpen } = useNewListModal(); + let { data: lists } = api.lists.list.useQuery(undefined, { + initialData: { lists: initialData }, + placeholderData: keepPreviousData, + }); + + // TODO: This seems to be a bug in react query + lists ||= { lists: initialData }; + + return ( + <div className="flex flex-col flex-wrap gap-2 md:flex-row"> + <Button + className="my-auto flex h-full" + onClick={() => setIsNewListModalOpen(true)} + > + <Plus /> + <span className="my-auto">New List</span> + </Button> + <ListItem name="Favourites" icon="⭐️" path={`/dashboard/favourites`} /> + <ListItem name="Archive" icon="🗄️" path={`/dashboard/archive`} /> + {lists.lists.map((l) => ( + <ListItem + key={l.id} + name={l.name} + icon={l.icon} + path={`/dashboard/lists/${l.id}`} + /> + ))} + </div> + ); +} diff --git a/apps/web/components/dashboard/lists/DeleteListButton.tsx b/apps/web/components/dashboard/lists/DeleteListButton.tsx new file mode 100644 index 00000000..5303b217 --- /dev/null +++ b/apps/web/components/dashboard/lists/DeleteListButton.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Trash } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ActionButton } from "@/components/ui/action-button"; +import { useState } from "react"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; + +export default function DeleteListButton({ list }: { list: ZBookmarkList }) { + const [isDialogOpen, setDialogOpen] = useState(false); + + const router = useRouter(); + + const listsInvalidationFunction = api.useUtils().lists.list.invalidate; + const { mutate: deleteList, isPending } = api.lists.delete.useMutation({ + onSuccess: () => { + listsInvalidationFunction(); + toast({ + description: `List "${list.icon} ${list.name}" is deleted!`, + }); + router.push("/"); + }, + onError: () => { + toast({ + variant: "destructive", + description: `Something went wrong`, + }); + }, + }); + return ( + <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button className="mt-auto flex gap-2" variant="destructive"> + <Trash className="size-5" /> + <span className="hidden md:block">Delete List</span> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + Delete {list.icon} {list.name}? + </DialogTitle> + </DialogHeader> + <span> + Are you sure you want to delete {list.icon} {list.name}? + </span> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + variant="destructive" + loading={isPending} + onClick={() => deleteList({ listId: list.id })} + > + Delete + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/lists/ListView.tsx b/apps/web/components/dashboard/lists/ListView.tsx new file mode 100644 index 00000000..2d48d9e3 --- /dev/null +++ b/apps/web/components/dashboard/lists/ListView.tsx @@ -0,0 +1,25 @@ +"use client"; + +import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ZBookmarkListWithBookmarks } from "@hoarder/trpc/types/lists"; +import { api } from "@/lib/trpc"; + +export default function ListView({ + bookmarks, + list: initialData, +}: { + list: ZBookmarkListWithBookmarks; + bookmarks: ZBookmark[]; +}) { + const { data } = api.lists.get.useQuery( + { listId: initialData.id }, + { + initialData, + }, + ); + + return ( + <BookmarksGrid query={{ ids: data.bookmarks }} bookmarks={bookmarks} /> + ); +} diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx new file mode 100644 index 00000000..73d14c90 --- /dev/null +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -0,0 +1,25 @@ +import { Input } from "@/components/ui/input"; +import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; +import { cn } from "@/lib/utils"; +import React from "react"; + +const SearchInput = React.forwardRef< + HTMLInputElement, + React.HTMLAttributes<HTMLInputElement> & { loading?: boolean } +>(({ className, loading = false, ...props }, ref) => { + const { debounceSearch, searchQuery } = useDoBookmarkSearch(); + + return ( + <Input + ref={ref} + placeholder="Search" + defaultValue={searchQuery} + onChange={(e) => debounceSearch(e.target.value)} + className={cn(loading ? "animate-pulse-border" : undefined, className)} + {...props} + /> + ); +}); +SearchInput.displayName = "SearchInput"; + +export { SearchInput }; diff --git a/apps/web/components/dashboard/settings/AddApiKey.tsx b/apps/web/components/dashboard/settings/AddApiKey.tsx new file mode 100644 index 00000000..a4fd9c25 --- /dev/null +++ b/apps/web/components/dashboard/settings/AddApiKey.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { z } from "zod"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { ActionButton } from "@/components/ui/action-button"; + +function ApiKeySuccess({ apiKey }: { apiKey: string }) { + const [isCopied, setCopied] = useState(false); + + const onCopy = () => { + navigator.clipboard.writeText(apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + <div> + <div className="py-4"> + Note: please copy the key and store it somewhere safe. Once you close + the dialog, you won't be able to access it again. + </div> + <div className="flex space-x-2 pt-2"> + <Input value={apiKey} readOnly /> + <Button onClick={onCopy}> + {!isCopied ? ( + <Copy className="size-4" /> + ) : ( + <Check className="size-4" /> + )} + </Button> + </div> + </div> + ); +} + +function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const formSchema = z.object({ + name: z.string(), + }); + const router = useRouter(); + const mutator = api.apiKeys.create.useMutation({ + onSuccess: (resp) => { + onSuccess(resp.key); + router.refresh(); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer<typeof formSchema>) { + mutator.mutate({ name: value.name }); + } + + const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit, onError)} + className="flex w-full space-x-3 space-y-8 pt-4" + > + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Name</FormLabel> + <FormControl> + <Input type="text" placeholder="Name" {...field} /> + </FormControl> + <FormDescription> + Give your API key a unique name + </FormDescription> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + className="h-full" + type="submit" + loading={mutator.isPending} + > + Create + </ActionButton> + </form> + </Form> + ); +} + +export default function AddApiKey() { + const [key, setKey] = useState<string | undefined>(undefined); + const [dialogOpen, setDialogOpen] = useState<boolean>(false); + return ( + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button>New API Key</Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + {key ? "Key was successfully created" : "Create API key"} + </DialogTitle> + <DialogDescription> + {key ? ( + <ApiKeySuccess apiKey={key} /> + ) : ( + <AddApiKeyForm onSuccess={setKey} /> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button + type="button" + variant="outline" + onClick={() => setKey(undefined)} + > + Close + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/settings/ApiKeySettings.tsx b/apps/web/components/dashboard/settings/ApiKeySettings.tsx new file mode 100644 index 00000000..1598f25f --- /dev/null +++ b/apps/web/components/dashboard/settings/ApiKeySettings.tsx @@ -0,0 +1,49 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/server/api/client"; +import DeleteApiKey from "./DeleteApiKey"; +import AddApiKey from "./AddApiKey"; + +export default async function ApiKeys() { + const keys = await api.apiKeys.list(); + return ( + <div className="pt-4"> + <span className="text-xl">API Keys</span> + <hr className="my-2" /> + <div className="flex flex-col space-y-3"> + <div className="flex flex-1 justify-end"> + <AddApiKey /> + </div> + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>Key</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Action</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {keys.keys.map((k) => ( + <TableRow key={k.id}> + <TableCell>{k.name}</TableCell> + <TableCell>**_{k.keyId}_**</TableCell> + <TableCell>{k.createdAt.toLocaleString()}</TableCell> + <TableCell> + <DeleteApiKey name={k.name} id={k.id} /> + </TableCell> + </TableRow> + ))} + <TableRow></TableRow> + </TableBody> + </Table> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/settings/DeleteApiKey.tsx b/apps/web/components/dashboard/settings/DeleteApiKey.tsx new file mode 100644 index 00000000..566136af --- /dev/null +++ b/apps/web/components/dashboard/settings/DeleteApiKey.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Trash } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ActionButton } from "@/components/ui/action-button"; +import { useState } from "react"; + +export default function DeleteApiKey({ + name, + id, +}: { + name: string; + id: string; +}) { + const [isDialogOpen, setDialogOpen] = useState(false); + const router = useRouter(); + const mutator = api.apiKeys.revoke.useMutation({ + onSuccess: () => { + toast({ + description: "Key was successfully deleted", + }); + setDialogOpen(false); + router.refresh(); + }, + }); + + return ( + <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button variant="destructive"> + <Trash className="size-5" /> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Delete API Key</DialogTitle> + <DialogDescription> + Are you sure you want to delete the API key "{name}"? Any + service using this API key will lose access. + </DialogDescription> + </DialogHeader> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + variant="destructive" + loading={mutator.isPending} + onClick={() => mutator.mutate({ id })} + > + Delete + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx new file mode 100644 index 00000000..a77252d0 --- /dev/null +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { api } from "@/lib/trpc"; +import SidebarItem from "./SidebarItem"; +import NewListModal, { useNewListModal } from "./NewListModal"; +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; + +export default function AllLists({ + initialData, +}: { + initialData: { lists: ZBookmarkList[] }; +}) { + let { data: lists } = api.lists.list.useQuery(undefined, { + initialData, + }); + // TODO: This seems to be a bug in react query + lists ||= initialData; + const { setOpen } = useNewListModal(); + + return ( + <ul className="max-h-full gap-y-2 overflow-auto text-sm font-medium"> + <NewListModal /> + <li className="flex justify-between pb-2 font-bold"> + <p>Lists</p> + <Link href="#" onClick={() => setOpen(true)}> + <Plus /> + </Link> + </li> + <SidebarItem + logo={<span className="text-lg">📋</span>} + name="All Lists" + path={`/dashboard/lists`} + className="py-0.5" + /> + <SidebarItem + logo={<span className="text-lg">⭐️</span>} + name="Favourties" + path={`/dashboard/favourites`} + className="py-0.5" + /> + <SidebarItem + logo={<span className="text-lg">🗄️</span>} + name="Archive" + path={`/dashboard/archive`} + className="py-0.5" + /> + {lists.lists.map((l) => ( + <SidebarItem + key={l.id} + logo={<span className="text-lg"> {l.icon}</span>} + name={l.name} + path={`/dashboard/lists/${l.id}`} + className="py-0.5" + /> + ))} + </ul> + ); +} diff --git a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx new file mode 100644 index 00000000..4bd6a347 --- /dev/null +++ b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx @@ -0,0 +1,24 @@ +import MobileSidebarItem from "./ModileSidebarItem"; +import { + Tag, + PackageOpen, + Settings, + Search, + ClipboardList, +} from "lucide-react"; +import SidebarProfileOptions from "./SidebarProfileOptions"; + +export default async function MobileSidebar() { + return ( + <aside className="w-full"> + <ul className="flex justify-between space-x-2 border-b-black bg-gray-100 px-5 py-2 pt-5"> + <MobileSidebarItem logo={<PackageOpen />} path="/dashboard/bookmarks" /> + <MobileSidebarItem logo={<Search />} path="/dashboard/search" /> + <MobileSidebarItem logo={<ClipboardList />} path="/dashboard/lists" /> + <MobileSidebarItem logo={<Tag />} path="/dashboard/tags" /> + <MobileSidebarItem logo={<Settings />} path="/dashboard/settings" /> + <SidebarProfileOptions /> + </ul> + </aside> + ); +} diff --git a/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx b/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx new file mode 100644 index 00000000..9389d2e4 --- /dev/null +++ b/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function MobileSidebarItem({ + logo, + path, +}: { + logo: React.ReactNode; + path: string; +}) { + const currentPath = usePathname(); + return ( + <li + className={cn( + "flex w-full rounded-lg hover:bg-gray-50", + path == currentPath ? "bg-gray-50" : "", + )} + > + <Link href={path} className="mx-auto px-3 py-2"> + {logo} + </Link> + </li> + ); +} diff --git a/apps/web/components/dashboard/sidebar/NewListModal.tsx b/apps/web/components/dashboard/sidebar/NewListModal.tsx new file mode 100644 index 00000000..f51616ed --- /dev/null +++ b/apps/web/components/dashboard/sidebar/NewListModal.tsx @@ -0,0 +1,170 @@ +"use client"; + +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +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 { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; + +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/ui/input"; + +import { create } from "zustand"; + +export const useNewListModal = create<{ + open: boolean; + setOpen: (v: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open: boolean) => set(() => ({ open })), +})); + +export default function NewListModal() { + const { open, setOpen } = useNewListModal(); + + const formSchema = z.object({ + name: z.string(), + icon: z.string(), + }); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + icon: "💡", + }, + }); + + const listsInvalidationFunction = api.useUtils().lists.list.invalidate; + + const { mutate: createList, isPending } = api.lists.create.useMutation({ + onSuccess: () => { + toast({ + description: "List has been created!", + }); + listsInvalidationFunction(); + setOpen(false); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + toast({ + variant: "destructive", + description: e.message, + }); + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + return ( + <Dialog + open={open} + onOpenChange={(s) => { + form.reset(); + setOpen(s); + }} + > + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + createList(value); + })} + > + <DialogHeader> + <DialogTitle>New List</DialogTitle> + </DialogHeader> + <div className="flex w-full gap-2 py-4"> + <FormField + control={form.control} + name="icon" + render={({ field }) => { + return ( + <FormItem> + <FormControl> + <Popover> + <PopoverTrigger className="border-input h-full rounded border px-2 text-2xl"> + {field.value} + </PopoverTrigger> + <PopoverContent> + <Picker + data={data} + onEmojiSelect={(e: { native: string }) => + field.onChange(e.native) + } + /> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="grow"> + <FormControl> + <Input + type="text" + className="w-full" + placeholder="List Name" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + </div> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton type="submit" loading={isPending}> + Create + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx new file mode 100644 index 00000000..a5c1d7a5 --- /dev/null +++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx @@ -0,0 +1,66 @@ +import { Tag, Home, PackageOpen, Settings, Search, Shield } from "lucide-react"; +import { redirect } from "next/navigation"; +import SidebarItem from "./SidebarItem"; +import { getServerAuthSession } from "@/server/auth"; +import Link from "next/link"; +import SidebarProfileOptions from "./SidebarProfileOptions"; +import { Separator } from "@/components/ui/separator"; +import AllLists from "./AllLists"; +import serverConfig from "@hoarder/shared/config"; +import { api } from "@/server/api/client"; + +export default async function Sidebar() { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const lists = await api.lists.list(); + + return ( + <aside className="flex h-screen w-60 flex-col gap-5 border-r p-4"> + <Link href={"/dashboard/bookmarks"}> + <div className="flex items-center rounded-lg px-1 text-slate-900"> + <PackageOpen /> + <span className="ml-2 text-base font-semibold">Hoarder</span> + </div> + </Link> + <hr /> + <div> + <ul className="space-y-2 text-sm font-medium"> + <SidebarItem + logo={<Home />} + name="Home" + path="/dashboard/bookmarks" + /> + {serverConfig.meilisearch && ( + <SidebarItem + logo={<Search />} + name="Search" + path="/dashboard/search" + /> + )} + <SidebarItem logo={<Tag />} name="Tags" path="/dashboard/tags" /> + <SidebarItem + logo={<Settings />} + name="Settings" + path="/dashboard/settings" + /> + {session.user.role == "admin" && ( + <SidebarItem + logo={<Shield />} + name="Admin" + path="/dashboard/admin" + /> + )} + </ul> + </div> + <Separator /> + <AllLists initialData={lists} /> + <div className="mt-auto flex justify-between justify-self-end"> + <div className="my-auto"> {session.user.name} </div> + <SidebarProfileOptions /> + </div> + </aside> + ); +} diff --git a/apps/web/components/dashboard/sidebar/SidebarItem.tsx b/apps/web/components/dashboard/sidebar/SidebarItem.tsx new file mode 100644 index 00000000..856bdffd --- /dev/null +++ b/apps/web/components/dashboard/sidebar/SidebarItem.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function SidebarItem({ + name, + logo, + path, + className, +}: { + name: string; + logo: React.ReactNode; + path: string; + className?: string; +}) { + const currentPath = usePathname(); + return ( + <li + className={cn( + "rounded-lg px-3 py-2 hover:bg-slate-100", + path == currentPath ? "bg-gray-50" : "", + className, + )} + > + <Link href={path} className="flex w-full gap-x-2"> + {logo} + <span className="my-auto"> {name} </span> + </Link> + </li> + ); +} diff --git a/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx new file mode 100644 index 00000000..f931b63e --- /dev/null +++ b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { LogOut, MoreHorizontal } from "lucide-react"; +import { signOut } from "next-auth/react"; + +export default function SidebarProfileOptions() { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost"> + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-fit"> + <DropdownMenuItem + onClick={() => + signOut({ + callbackUrl: "/", + }) + } + > + <LogOut className="mr-2 size-4" /> + <span>Sign Out</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx new file mode 100644 index 00000000..5296e163 --- /dev/null +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -0,0 +1,222 @@ +"use client"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ActionButton } from "@/components/ui/action-button"; +import { zSignUpSchema } from "@hoarder/trpc/types/users"; +import { signIn } from "next-auth/react"; +import { useState } from "react"; +import { api } from "@/lib/trpc"; +import { useRouter } from "next/navigation"; +import { TRPCClientError } from "@trpc/client"; + +const signInSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +function SignIn() { + const [signinError, setSigninError] = useState(false); + const router = useRouter(); + const form = useForm<z.infer<typeof signInSchema>>({ + resolver: zodResolver(signInSchema), + }); + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(async (value) => { + const resp = await signIn("credentials", { + redirect: false, + email: value.email, + password: value.password, + }); + if (!resp || !resp?.ok) { + setSigninError(true); + return; + } + router.replace("/"); + })} + > + <div className="flex w-full flex-col space-y-2"> + {signinError && ( + <p className="w-full text-center text-red-500"> + Incorrect username or password + </p> + )} + <FormField + control={form.control} + name="email" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="text" placeholder="Email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton type="submit" loading={form.formState.isSubmitting}> + Sign In + </ActionButton> + </div> + </form> + </Form> + ); +} + +function SignUp() { + const form = useForm<z.infer<typeof zSignUpSchema>>({ + resolver: zodResolver(zSignUpSchema), + }); + const [errorMessage, setErrorMessage] = useState(""); + + const router = useRouter(); + + const createUserMutation = api.users.create.useMutation(); + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(async (value) => { + try { + await createUserMutation.mutateAsync(value); + } catch (e) { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } + return; + } + const resp = await signIn("credentials", { + redirect: false, + email: value.email, + password: value.password, + }); + if (!resp || !resp.ok) { + setErrorMessage("Hit an unexpected error while signing in"); + return; + } + router.replace("/"); + })} + > + <div className="flex w-full flex-col space-y-2"> + {errorMessage && ( + <p className="w-full text-center text-red-500">{errorMessage}</p> + )} + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input type="text" placeholder="Name" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="email" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="text" placeholder="Email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm Password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton type="submit" loading={form.formState.isSubmitting}> + Sign Up + </ActionButton> + </div> + </form> + </Form> + ); +} + +export default function CredentialsForm() { + return ( + <Tabs defaultValue="signin" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="signin">Sign In</TabsTrigger> + <TabsTrigger value="signup">Sign Up</TabsTrigger> + </TabsList> + <TabsContent value="signin"> + <SignIn /> + </TabsContent> + <TabsContent value="signup"> + <SignUp /> + </TabsContent> + </Tabs> + ); +} diff --git a/apps/web/components/signin/SignInForm.tsx b/apps/web/components/signin/SignInForm.tsx new file mode 100644 index 00000000..7c8f8936 --- /dev/null +++ b/apps/web/components/signin/SignInForm.tsx @@ -0,0 +1,37 @@ +import { getProviders } from "next-auth/react"; +import SignInProviderButton from "./SignInProviderButton"; +import CredentialsForm from "./CredentialsForm"; + +export default async function SignInForm() { + const providers = await getProviders(); + let providerValues; + if (providers) { + providerValues = Object.values(providers).filter( + // Credentials are handled manually by the sign in form + (p) => p.id != "credentials", + ); + } + + return ( + <div className="flex flex-col items-center space-y-2"> + <CredentialsForm /> + + {providerValues && providerValues.length > 0 && ( + <> + <div className="flex w-full items-center"> + <div className="flex-1 grow border-t-2 border-gray-200"></div> + <span className="bg-white px-3 text-gray-500">Or</span> + <div className="flex-1 grow border-t-2 border-gray-200"></div> + </div> + <div className="space-y-2"> + {providerValues.map((provider) => ( + <div key={provider.id}> + <SignInProviderButton provider={provider} /> + </div> + ))} + </div> + </> + )} + </div> + ); +} diff --git a/apps/web/components/signin/SignInProviderButton.tsx b/apps/web/components/signin/SignInProviderButton.tsx new file mode 100644 index 00000000..0831236c --- /dev/null +++ b/apps/web/components/signin/SignInProviderButton.tsx @@ -0,0 +1,21 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { ClientSafeProvider, signIn } from "next-auth/react"; + +export default function SignInProviderButton({ + provider, +}: { + provider: ClientSafeProvider; +}) { + return ( + <Button + onClick={() => + signIn(provider.id, { + callbackUrl: "/", + }) + } + > + Sign in with {provider.name} + </Button> + ); +} diff --git a/apps/web/components/ui/action-button.tsx b/apps/web/components/ui/action-button.tsx new file mode 100644 index 00000000..42e16f65 --- /dev/null +++ b/apps/web/components/ui/action-button.tsx @@ -0,0 +1,25 @@ +import { Button, ButtonProps } from "./button"; +import LoadingSpinner from "./spinner"; + +export function ActionButton({ + children, + loading, + spinner, + disabled, + ...props +}: ButtonProps & { + loading: boolean; + spinner?: React.ReactNode; +}) { + spinner ||= <LoadingSpinner />; + if (disabled !== undefined) { + disabled ||= loading; + } else if (loading) { + disabled = true; + } + return ( + <Button {...props} disabled={disabled}> + {loading ? spinner : children} + </Button> + ); +} diff --git a/apps/web/components/ui/back-button.tsx b/apps/web/components/ui/back-button.tsx new file mode 100644 index 00000000..685930df --- /dev/null +++ b/apps/web/components/ui/back-button.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button, ButtonProps } from "./button"; + +export function BackButton({ ...props }: ButtonProps) { + const router = useRouter(); + return <Button {...props} onClick={() => router.back()} />; +} diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx new file mode 100644 index 00000000..c30daca1 --- /dev/null +++ b/apps/web/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx new file mode 100644 index 00000000..79b45fa0 --- /dev/null +++ b/apps/web/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border-input bg-background hover:bg-accent hover:text-accent-foreground border", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean; +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx new file mode 100644 index 00000000..f4e57996 --- /dev/null +++ b/apps/web/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "bg-card text-card-foreground rounded-lg border shadow-sm", + className, + )} + {...props} + /> +)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h3 + ref={ref} + className={cn( + "text-2xl font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> +)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <p + ref={ref} + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> +)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx new file mode 100644 index 00000000..8fe3fe35 --- /dev/null +++ b/apps/web/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80", + className, + )} + {...props} + /> +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg", + className, + )} + {...props} + > + {children} + <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"> + <X className="size-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-1.5 text-center sm:text-left", + className, + )} + {...props} + /> +); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> +); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/components/ui/dropdown-menu.tsx b/apps/web/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..3a9a2ff7 --- /dev/null +++ b/apps/web/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none", + inset && "pl-8", + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto size-4" /> + </DropdownMenuPrimitive.SubTrigger> +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", + className, + )} + {...props} + /> +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", + className, + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="size-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="size-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("bg-muted -mx-1 my-1 h-px", className)} + {...props} + /> +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/web/components/ui/form.tsx b/apps/web/components/ui/form.tsx new file mode 100644 index 00000000..e62e10e9 --- /dev/null +++ b/apps/web/components/ui/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>"); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ); +}); +FormLabel.displayName = "FormLabel"; + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ); +}); +FormControl.displayName = "FormControl"; + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField(); + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ); +}); +FormDescription.displayName = "FormDescription"; + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message) : children; + + if (!body) { + return null; + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-destructive text-sm font-medium", className)} + {...props} + > + {body} + </p> + ); +}); +FormMessage.displayName = "FormMessage"; + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +}; diff --git a/apps/web/components/ui/imageCard.tsx b/apps/web/components/ui/imageCard.tsx new file mode 100644 index 00000000..f10ebdb5 --- /dev/null +++ b/apps/web/components/ui/imageCard.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export function ImageCard({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("h-96 overflow-hidden rounded-lg shadow-md", className)} + {...props} + /> + ); +} + +export function ImageCardBanner({ + className, + ...props +}: React.ImgHTMLAttributes<HTMLImageElement>) { + return ( + // eslint-disable-next-line @next/next/no-img-element + <img + className={cn("h-56 min-h-56 w-full object-cover", className)} + alt="card banner" + {...props} + /> + ); +} + +export function ImageCardContent({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn( + "flex h-40 min-h-40 flex-col justify-between p-2", + className, + )} + {...props} + /> + ); +} + +export function ImageCardTitle({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("order-first flex-none text-lg font-bold", className)} + {...props} + /> + ); +} + +export function ImageCardBody({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return <div className={cn("order-1", className)} {...props} />; +} + +export function ImageCardFooter({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return <div className={cn("order-last", className)} {...props} />; +} diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx new file mode 100644 index 00000000..21aac7ad --- /dev/null +++ b/apps/web/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes<HTMLInputElement> {} + +const Input = React.forwardRef<HTMLInputElement, InputProps>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + /> + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/web/components/ui/label.tsx b/apps/web/components/ui/label.tsx new file mode 100644 index 00000000..84f8b0c7 --- /dev/null +++ b/apps/web/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx new file mode 100644 index 00000000..a361ba7d --- /dev/null +++ b/apps/web/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none", + className, + )} + {...props} + /> + </PopoverPrimitive.Portal> +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/web/components/ui/scroll-area.tsx b/apps/web/components/ui/scroll-area.tsx new file mode 100644 index 00000000..32cb6022 --- /dev/null +++ b/apps/web/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> +>(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className, + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx new file mode 100644 index 00000000..efd4ff1e --- /dev/null +++ b/apps/web/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronUp className="size-4" /> + </SelectPrimitive.ScrollUpButton> +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronDown className="size-4" /> + </SelectPrimitive.ScrollDownButton> +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("bg-muted -mx-1 my-1 h-px", className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/web/components/ui/separator.tsx b/apps/web/components/ui/separator.tsx new file mode 100644 index 00000000..3b9f2b84 --- /dev/null +++ b/apps/web/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "bg-border shrink-0", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className, + )} + {...props} + /> + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx new file mode 100644 index 00000000..5fab2023 --- /dev/null +++ b/apps/web/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("bg-muted animate-pulse rounded-md", className)} + {...props} + /> + ); +} + +export { Skeleton }; diff --git a/apps/web/components/ui/spinner.tsx b/apps/web/components/ui/spinner.tsx new file mode 100644 index 00000000..adcd2807 --- /dev/null +++ b/apps/web/components/ui/spinner.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; + +export default function LoadingSpinner({ className }: { className?: string }) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={cn("animate-spin", className)} + > + <path d="M21 12a9 9 0 1 1-6.219-8.56" /> + </svg> + ); +} diff --git a/apps/web/components/ui/table.tsx b/apps/web/components/ui/table.tsx new file mode 100644 index 00000000..0fa9288e --- /dev/null +++ b/apps/web/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes<HTMLTableElement> +>(({ className, ...props }, ref) => ( + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + </div> +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tfoot + ref={ref} + className={cn( + "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes<HTMLTableRowElement> +>(({ className, ...props }, ref) => ( + <tr + ref={ref} + className={cn( + "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className, + )} + {...props} + /> +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <th + ref={ref} + className={cn( + "text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0", + className, + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <td + ref={ref} + className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} + {...props} + /> +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes<HTMLTableCaptionElement> +>(({ className, ...props }, ref) => ( + <caption + ref={ref} + className={cn("text-muted-foreground mt-4 text-sm", className)} + {...props} + /> +)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/apps/web/components/ui/tabs.tsx b/apps/web/components/ui/tabs.tsx new file mode 100644 index 00000000..990017db --- /dev/null +++ b/apps/web/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + "bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1", + className, + )} + {...props} + /> +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm", + className, + )} + {...props} + /> +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + className, + )} + {...props} + /> +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/components/ui/textarea.tsx b/apps/web/components/ui/textarea.tsx new file mode 100644 index 00000000..a0de3371 --- /dev/null +++ b/apps/web/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} + +const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( + ({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + /> + ); + }, +); +Textarea.displayName = "Textarea"; + +export { Textarea }; diff --git a/apps/web/components/ui/toast.tsx b/apps/web/components/ui/toast.tsx new file mode 100644 index 00000000..0d162dca --- /dev/null +++ b/apps/web/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className, + )} + {...props} + /> +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none", + { + variants: { + variant: { + default: "bg-background text-foreground border", + destructive: + "destructive border-destructive bg-destructive text-destructive-foreground group", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Toast = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "ring-offset-background hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + className, + )} + {...props} + /> +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className, + )} + toast-close="" + {...props} + > + <X className="size-4" /> + </ToastPrimitives.Close> +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold", className)} + {...props} + /> +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; + +type ToastActionElement = React.ReactElement<typeof ToastAction>; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/apps/web/components/ui/toaster.tsx b/apps/web/components/ui/toaster.tsx new file mode 100644 index 00000000..7d82ed55 --- /dev/null +++ b/apps/web/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; +import { useToast } from "@/components/ui/use-toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ); + })} + <ToastViewport /> + </ToastProvider> + ); +} diff --git a/apps/web/components/ui/use-toast.ts b/apps/web/components/ui/use-toast.ts new file mode 100644 index 00000000..5491e140 --- /dev/null +++ b/apps/web/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial<ToasterToast>; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t, + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(_state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit<ToasterToast, "id">; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState<State>(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/apps/web/lib/bookmarkUtils.tsx b/apps/web/lib/bookmarkUtils.tsx new file mode 100644 index 00000000..a2828c29 --- /dev/null +++ b/apps/web/lib/bookmarkUtils.tsx @@ -0,0 +1,22 @@ +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; + +const MAX_LOADING_MSEC = 30 * 1000; + +export function isBookmarkStillCrawling(bookmark: ZBookmark) { + return ( + bookmark.content.type == "link" && + !bookmark.content.crawledAt && + Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC + ); +} + +export function isBookmarkStillTagging(bookmark: ZBookmark) { + return ( + bookmark.taggingStatus == "pending" && + Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC + ); +} + +export function isBookmarkStillLoading(bookmark: ZBookmark) { + return isBookmarkStillTagging(bookmark) || isBookmarkStillCrawling(bookmark); +} diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts new file mode 100644 index 00000000..738e1bd8 --- /dev/null +++ b/apps/web/lib/hooks/bookmark-search.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { api } from "@/lib/trpc"; +import { useRouter, useSearchParams } from "next/navigation"; +import { keepPreviousData } from "@tanstack/react-query"; + +function useSearchQuery() { + const searchParams = useSearchParams(); + const searchQuery = searchParams.get("q") || ""; + return { searchQuery }; +} + +export function useDoBookmarkSearch() { + const router = useRouter(); + const { searchQuery } = useSearchQuery(); + const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>(); + + useEffect(() => { + return () => { + if (!timeoutId) { + return; + } + clearTimeout(timeoutId); + }; + }, [timeoutId]); + + const doSearch = (val: string) => { + setTimeoutId(undefined); + router.replace(`/dashboard/search?q=${val}`); + }; + + const debounceSearch = (val: string) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + const id = setTimeout(() => { + doSearch(val); + }, 200); + setTimeoutId(id); + }; + + return { + doSearch, + debounceSearch, + searchQuery, + }; +} + +export function useBookmarkSearch() { + const { searchQuery } = useSearchQuery(); + + const { data, isPending, isPlaceholderData, error } = + api.bookmarks.searchBookmarks.useQuery( + { + text: searchQuery, + }, + { + placeholderData: keepPreviousData, + gcTime: 0, + }, + ); + + if (error) { + throw error; + } + + return { + searchQuery, + error, + data, + isPending, + isPlaceholderData, + }; +} diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx new file mode 100644 index 00000000..5c4649b5 --- /dev/null +++ b/apps/web/lib/providers.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { useState } from "react"; +import { api } from "./trpc"; +import { loggerLink } from "@trpc/client"; +import { httpBatchLink } from "@trpc/client"; +import superjson from "superjson"; +import { SessionProvider } from "next-auth/react"; +import { Session } from "next-auth"; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 60 * 1000, + }, + }, + }); +} + +let browserQueryClient: QueryClient | undefined = undefined; + +function getQueryClient() { + if (typeof window === "undefined") { + // Server: always make a new query client + return makeQueryClient(); + } else { + // Browser: make a new query client if we don't already have one + // This is very important so we don't re-make a new client if React + // supsends during the initial render. This may not be needed if we + // have a suspense boundary BELOW the creation of the query client + if (!browserQueryClient) browserQueryClient = makeQueryClient(); + return browserQueryClient; + } +} + +export default function Providers({ + children, + session, +}: { + children: React.ReactNode; + session: Session | null; +}) { + const queryClient = getQueryClient(); + + const [trpcClient] = useState(() => + api.createClient({ + links: [ + loggerLink({ + enabled: (op) => + process.env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + httpBatchLink({ + // TODO: Change this to be a full URL exposed as a client side setting + url: `/api/trpc`, + transformer: superjson, + }), + ], + }), + ); + + return ( + <SessionProvider session={session}> + <api.Provider client={trpcClient} queryClient={queryClient}> + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + </api.Provider> + </SessionProvider> + ); +} diff --git a/apps/web/lib/trpc.tsx b/apps/web/lib/trpc.tsx new file mode 100644 index 00000000..79a2a9fe --- /dev/null +++ b/apps/web/lib/trpc.tsx @@ -0,0 +1,5 @@ +"use client"; +import type { AppRouter } from "@hoarder/trpc/routers/_app"; +import { createTRPCReact } from "@trpc/react-query"; + +export const api = createTRPCReact<AppRouter>(); diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts new file mode 100644 index 00000000..365058ce --- /dev/null +++ b/apps/web/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 00000000..fa0757dd --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,53 @@ +import pwa from "next-pwa"; + +const withPWA = pwa({ + dest: "public", + disable: process.env.NODE_ENV != "production", +}); + +/** @type {import('next').NextConfig} */ +const nextConfig = withPWA({ + output: "standalone", + async headers() { + return [ + { + // Routes this applies to + source: "/api/(.*)", + // Headers + headers: [ + // Allow for specific domains to have access or * for all + { + key: "Access-Control-Allow-Origin", + value: "chrome-extension://olmdabfolepgfmjhmikngmfekcdgjinp", + }, + // Allows for specific methods accepted + { + key: "Access-Control-Allow-Methods", + value: "GET, POST, PUT, DELETE, OPTIONS", + }, + // Allows for specific headers accepted (These are a few standard ones) + { + key: "Access-Control-Allow-Headers", + value: "Content-Type, Authorization", + }, + { + key: "Access-Control-Allow-Credentials", + value: "true", + }, + ], + }, + ]; + }, + + transpilePackages: [ + "@hoarder/shared", + "@hoarder/db", + "@hoarder/trpc", + ], + + /** We already do linting and typechecking as separate tasks in CI */ + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, +}); + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..28708c6c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@hoarder/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "clean": "git clean -xdf .next .turbo node_modules", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest", + "typecheck": "tsc --noEmit", + "format": "prettier --check . --ignore-path ../../.gitignore" + }, + "dependencies": { + "@auth/drizzle-adapter": "^0.8.0", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", + "@hoarder/db": "workspace:^0.1.0", + "@hoarder/shared": "workspace:^0.1.0", + "@hoarder/trpc": "workspace:^0.1.0", + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@tanstack/react-query": "^5.24.8", + "@tanstack/react-query-devtools": "^5.21.0", + "@trpc/client": "11.0.0-next-beta.308", + "@trpc/next": "11.0.0-next-beta.308", + "@trpc/react-query": "11.0.0-next-beta.308", + "@trpc/server": "11.0.0-next-beta.308", + "better-sqlite3": "^9.4.3", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "drizzle-orm": "^0.29.4", + "install": "^0.13.0", + "lucide-react": "^0.330.0", + "meilisearch": "^0.37.0", + "next": "14.1.1", + "next-auth": "^4.24.5", + "next-pwa": "^5.6.0", + "prettier": "^3.2.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.50.1", + "react-markdown": "^9.0.1", + "react-masonry-css": "^1.0.16", + "server-only": "^0.0.1", + "superjson": "^2.2.1", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4", + "zustand": "^4.5.1" + }, + "devDependencies": { + "@hoarder/eslint-config": "workspace:^0.2.0", + "@hoarder/prettier-config": "workspace:^0.1.0", + "@hoarder/tailwind-config": "workspace:^0.1.0", + "@hoarder/tsconfig": "workspace:^0.1.0", + "@tailwindcss/typography": "^0.5.10", + "@types/emoji-mart": "^3.0.14", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "ts-node": "^10.9.2", + "vite-tsconfig-paths": "^4.3.1", + "vitest": "^1.3.1" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@hoarder/eslint-config/base", + "@hoarder/eslint-config/nextjs", + "@hoarder/eslint-config/react" + ] + }, + "prettier": "@hoarder/prettier-config" +} diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/apps/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/blur.avif b/apps/web/public/blur.avif Binary files differnew file mode 100644 index 00000000..cbc6cd37 --- /dev/null +++ b/apps/web/public/blur.avif diff --git a/apps/web/public/icons/logo-128.png b/apps/web/public/icons/logo-128.png Binary files differnew file mode 100644 index 00000000..71ead90c --- /dev/null +++ b/apps/web/public/icons/logo-128.png diff --git a/apps/web/public/icons/logo-16.png b/apps/web/public/icons/logo-16.png Binary files differnew file mode 100644 index 00000000..dd864d44 --- /dev/null +++ b/apps/web/public/icons/logo-16.png diff --git a/apps/web/public/icons/logo-48.png b/apps/web/public/icons/logo-48.png Binary files differnew file mode 100644 index 00000000..7ba1cd49 --- /dev/null +++ b/apps/web/public/icons/logo-48.png diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 00000000..b42343f6 --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Hoarder", + "short_name": "Hoarder", + "icons": [ + { + "src": "/icons/logo-16.png", + "sizes": "16x16", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/logo-48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "/icons/logo-128.png", + "sizes": "128x128", + "type": "image/png" + } + ], + "start_url": "/", + "display": "standalone", + "orientation": "portrait" +} diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts new file mode 100644 index 00000000..88ea7a0e --- /dev/null +++ b/apps/web/server/api/client.ts @@ -0,0 +1,16 @@ +import { appRouter } from "@hoarder/trpc/routers/_app"; +import { getServerAuthSession } from "@/server/auth"; +import { Context, createCallerFactory } from "@hoarder/trpc"; +import { db } from "@hoarder/db"; + +export const createContext = async (database?: typeof db): Promise<Context> => { + const session = await getServerAuthSession(); + return { + user: session?.user ?? null, + db: database ?? db, + }; +}; + +const createCaller = createCallerFactory(appRouter); + +export const api = createCaller(createContext); diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts new file mode 100644 index 00000000..950443b9 --- /dev/null +++ b/apps/web/server/auth.ts @@ -0,0 +1,96 @@ +import NextAuth, { NextAuthOptions, getServerSession } from "next-auth"; +import type { Adapter } from "next-auth/adapters"; +import AuthentikProvider from "next-auth/providers/authentik"; +import serverConfig from "@hoarder/shared/config"; +import { validatePassword } from "@hoarder/trpc/auth"; +import { db } from "@hoarder/db"; +import { DefaultSession } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; + +import { Provider } from "next-auth/providers/index"; + +declare module "next-auth/jwt" { + export interface JWT { + user: { + id: string; + role: "admin" | "user"; + } & DefaultSession["user"]; + } +} + +declare module "next-auth" { + /** + * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context + */ + export interface Session { + user: { + id: string; + role: "admin" | "user"; + } & DefaultSession["user"]; + } + + export interface DefaultUser { + role: "admin" | "user" | null; + } +} + +const providers: Provider[] = [ + CredentialsProvider({ + // The name to display on the sign in form (e.g. "Sign in with...") + name: "Credentials", + credentials: { + email: { label: "Email", type: "email", placeholder: "Email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials) { + return null; + } + + try { + return await validatePassword( + credentials?.email, + credentials?.password, + ); + } catch (e) { + return null; + } + }, + }), +]; + +if (serverConfig.auth.authentik) { + providers.push(AuthentikProvider(serverConfig.auth.authentik)); +} + +export const authOptions: NextAuthOptions = { + // https://github.com/nextauthjs/next-auth/issues/9493 + adapter: DrizzleAdapter(db) as Adapter, + providers: providers, + session: { + strategy: "jwt", + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.user = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + role: user.role || "user", + }; + } + return token; + }, + async session({ session, token }) { + session.user = { ...token.user }; + return session; + }, + }, +}; + +export const authHandler = NextAuth(authOptions); + +export const getServerAuthSession = () => getServerSession(authOptions); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 00000000..521ba51c --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,89 @@ +import type { Config } from "tailwindcss"; + +const config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + "pulse-border": { + "0%, 100%": { + "box-shadow": "0 0 0 0 gray", + }, + "50%": { + "box-shadow": "0 0 0 2px gray", + }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + "pulse-border": "pulse-border 1s ease-in-out infinite", + }, + }, + }, + plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], +} satisfies Config; + +export default config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 00000000..db90cf17 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@hoarder/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..c3d02f71 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,14 @@ +/// <reference types="vitest" /> + +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + alias: { + "@/*": "./*", + }, + }, +}); |
