aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web')
-rw-r--r--packages/web/.env.sample8
-rw-r--r--packages/web/Dockerfile57
-rw-r--r--packages/web/README.md36
-rw-r--r--packages/web/app/api/auth/[...nextauth]/route.tsx3
-rw-r--r--packages/web/app/api/v1/links/[linkId]/route.ts32
-rw-r--r--packages/web/app/api/v1/links/route.ts49
-rw-r--r--packages/web/app/dashboard/bookmarks/components/AddLink.tsx67
-rw-r--r--packages/web/app/dashboard/bookmarks/components/LinkCard.tsx96
-rw-r--r--packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx21
-rw-r--r--packages/web/app/dashboard/bookmarks/page.tsx20
-rw-r--r--packages/web/app/dashboard/components/Sidebar.tsx56
-rw-r--r--packages/web/app/dashboard/layout.tsx15
-rw-r--r--packages/web/app/favicon.icobin0 -> 25931 bytes
-rw-r--r--packages/web/app/globals.css76
-rw-r--r--packages/web/app/layout.tsx27
-rw-r--r--packages/web/app/page.tsx19
-rwxr-xr-xpackages/web/bun.lockbbin0 -> 158558 bytes
-rw-r--r--packages/web/components.json17
-rw-r--r--packages/web/components/auth/login.tsx17
-rw-r--r--packages/web/components/auth/logout.tsx17
-rw-r--r--packages/web/components/ui/badge.tsx36
-rw-r--r--packages/web/components/ui/button.tsx56
-rw-r--r--packages/web/components/ui/card.tsx86
-rw-r--r--packages/web/components/ui/dropdown-menu.tsx200
-rw-r--r--packages/web/components/ui/form.tsx176
-rw-r--r--packages/web/components/ui/imageCard.tsx56
-rw-r--r--packages/web/components/ui/input.tsx25
-rw-r--r--packages/web/components/ui/label.tsx26
-rw-r--r--packages/web/components/ui/toast.tsx127
-rw-r--r--packages/web/components/ui/toaster.tsx35
-rw-r--r--packages/web/components/ui/use-toast.ts189
-rw-r--r--packages/web/lib/api.ts81
-rw-r--r--packages/web/lib/auth.ts25
-rw-r--r--packages/web/lib/config.ts22
-rw-r--r--packages/web/lib/services/links.ts78
-rw-r--r--packages/web/lib/types/api/links.ts33
-rw-r--r--packages/web/lib/types/api/tags.ts6
-rw-r--r--packages/web/lib/types/next-auth.d.ts12
-rw-r--r--packages/web/lib/utils.ts6
-rw-r--r--packages/web/next.config.mjs4
-rw-r--r--packages/web/package.json41
-rw-r--r--packages/web/postcss.config.js6
-rw-r--r--packages/web/public/next.svg1
-rw-r--r--packages/web/public/vercel.svg1
-rw-r--r--packages/web/tailwind.config.ts80
-rw-r--r--packages/web/tsconfig.json27
46 files changed, 2068 insertions, 0 deletions
diff --git a/packages/web/.env.sample b/packages/web/.env.sample
new file mode 100644
index 00000000..a48054f0
--- /dev/null
+++ b/packages/web/.env.sample
@@ -0,0 +1,8 @@
+DATABASE_URL="file:./dev.db"
+NEXTAUTH_URL=
+NEXTAUTH_SECRET=
+
+# Oauth
+AUTHENTIK_ID=
+AUTHENTIK_SECRET=
+AUTHENTIK_ISSUER=
diff --git a/packages/web/Dockerfile b/packages/web/Dockerfile
new file mode 100644
index 00000000..30a46bd3
--- /dev/null
+++ b/packages/web/Dockerfile
@@ -0,0 +1,57 @@
+FROM oven/bun:1.0-alpine AS base
+
+# Install dependencies only when needed
+FROM base AS deps
+# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
+# RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# Install dependencies based on the preferred package manager
+COPY package.json bun.lockb ./
+RUN bun install --frozen-lockfile
+
+# Rebuild the source code only when needed
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+ENV NODE_ENV production
+
+ENV NEXT_TELEMETRY_DISABLED 1
+
+RUN bun run build
+
+# Production image, copy all the files and run next
+FROM base AS runner
+WORKDIR /app
+
+ENV NODE_ENV production
+
+ENV NEXT_TELEMETRY_DISABLED 1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+
+# Set the correct permission for prerender cache
+RUN mkdir .next
+RUN chown nextjs:nodejs .next
+
+# Automatically leverage output traces to reduce image size
+# https://nextjs.org/docs/advanced-features/output-file-tracing
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT 3000
+# set hostname to localhost
+ENV HOSTNAME "0.0.0.0"
+
+# server.js is created by next build from the standalone output
+# https://nextjs.org/docs/pages/api-reference/next-config-js/output
+CMD ["node", "server.js"]
diff --git a/packages/web/README.md b/packages/web/README.md
new file mode 100644
index 00000000..c4033664
--- /dev/null
+++ b/packages/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/packages/web/app/api/auth/[...nextauth]/route.tsx b/packages/web/app/api/auth/[...nextauth]/route.tsx
new file mode 100644
index 00000000..e722926b
--- /dev/null
+++ b/packages/web/app/api/auth/[...nextauth]/route.tsx
@@ -0,0 +1,3 @@
+import { authHandler } from "@/lib/auth";
+
+export { authHandler as GET, authHandler as POST };
diff --git a/packages/web/app/api/v1/links/[linkId]/route.ts b/packages/web/app/api/v1/links/[linkId]/route.ts
new file mode 100644
index 00000000..39449d6d
--- /dev/null
+++ b/packages/web/app/api/v1/links/[linkId]/route.ts
@@ -0,0 +1,32 @@
+import { authOptions } from "@/lib/auth";
+import { unbookmarkLink } from "@/lib/services/links";
+import { Prisma } from "@remember/db";
+
+import { getServerSession } from "next-auth";
+import { NextRequest } from "next/server";
+
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: { linkId: string } },
+) {
+ // TODO: We probably should be using an API key here instead of the session;
+ const session = await getServerSession(authOptions);
+ if (!session) {
+ return new Response(null, { status: 401 });
+ }
+
+ try {
+ await unbookmarkLink(params.linkId, session.user.id);
+ } catch (e: unknown) {
+ if (
+ e instanceof Prisma.PrismaClientKnownRequestError &&
+ e.code === "P2025" // RecordNotFound
+ ) {
+ return new Response(null, { status: 404 });
+ } else {
+ throw e;
+ }
+ }
+
+ return new Response(null, { status: 201 });
+}
diff --git a/packages/web/app/api/v1/links/route.ts b/packages/web/app/api/v1/links/route.ts
new file mode 100644
index 00000000..87541634
--- /dev/null
+++ b/packages/web/app/api/v1/links/route.ts
@@ -0,0 +1,49 @@
+import { authOptions } from "@/lib/auth";
+import { bookmarkLink, getLinks } from "@/lib/services/links";
+
+import {
+ zNewBookmarkedLinkRequestSchema,
+ ZGetLinksResponse,
+ ZBookmarkedLink,
+} from "@/lib/types/api/links";
+import { getServerSession } from "next-auth";
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(request: NextRequest) {
+ // TODO: We probably should be using an API key here instead of the session;
+ const session = await getServerSession(authOptions);
+ if (!session) {
+ return new Response(null, { status: 401 });
+ }
+
+ const linkRequest = zNewBookmarkedLinkRequestSchema.safeParse(
+ await request.json(),
+ );
+
+ if (!linkRequest.success) {
+ return NextResponse.json(
+ {
+ error: linkRequest.error.toString(),
+ },
+ { status: 400 },
+ );
+ }
+
+ const link = await bookmarkLink(linkRequest.data.url, session.user.id);
+
+ let response: ZBookmarkedLink = { ...link };
+ return NextResponse.json(response, { status: 201 });
+}
+
+export async function GET() {
+ // TODO: We probably should be using an API key here instead of the session;
+ const session = await getServerSession(authOptions);
+ if (!session) {
+ return new Response(null, { status: 401 });
+ }
+
+ const links = await getLinks(session.user.id);
+
+ let response: ZGetLinksResponse = { links };
+ return NextResponse.json(response);
+}
diff --git a/packages/web/app/dashboard/bookmarks/components/AddLink.tsx b/packages/web/app/dashboard/bookmarks/components/AddLink.tsx
new file mode 100644
index 00000000..fb77786c
--- /dev/null
+++ b/packages/web/app/dashboard/bookmarks/components/AddLink.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import APIClient from "@/lib/api";
+import { Plus } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useForm, SubmitErrorHandler } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "@/components/ui/use-toast";
+
+const formSchema = z.object({
+ url: z.string().url({ message: "The link must be a valid URL" }),
+});
+
+export default function AddLink() {
+ const router = useRouter();
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ });
+
+ async function onSubmit(value: z.infer<typeof formSchema>) {
+ const [_resp, error] = await APIClient.bookmarkLink(value.url);
+ if (error) {
+ toast({ description: error.message, variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ }
+
+ 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)}>
+ <div className="py-4 container flex w-full items-center space-x-2">
+ <FormField
+ control={form.control}
+ name="url"
+ render={({ field }) => {
+ return (
+ <FormItem className="flex-1">
+ <FormControl>
+ <Input type="text" placeholder="Link" {...field} />
+ </FormControl>
+ </FormItem>
+ );
+ }}
+ />
+ <Button type="submit">
+ <Plus />
+ </Button>
+ </div>
+ </form>
+ </Form>
+ );
+}
diff --git a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx
new file mode 100644
index 00000000..da59d9da
--- /dev/null
+++ b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ ImageCard,
+ ImageCardBody,
+ ImageCardFooter,
+ ImageCardTitle,
+} from "@/components/ui/imageCard";
+import { useToast } from "@/components/ui/use-toast";
+import APIClient from "@/lib/api";
+import { ZBookmarkedLink } from "@/lib/types/api/links";
+import { MoreHorizontal, Trash2 } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+export function LinkOptions({ linkId }: { linkId: string }) {
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const unbookmarkLink = async () => {
+ let [_, error] = await APIClient.unbookmarkLink(linkId);
+
+ if (error) {
+ toast({
+ variant: "destructive",
+ title: "Something went wrong",
+ description: "There was a problem with your request.",
+ });
+ } else {
+ toast({
+ description: "The link has been deleted!",
+ });
+ }
+
+ router.refresh();
+ };
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost">
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-fit">
+ <DropdownMenuItem className="text-destructive" onClick={unbookmarkLink}>
+ <Trash2 className="mr-2 h-4 w-4" />
+ <span>Delete</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+export default function LinkCard({ link }: { link: ZBookmarkedLink }) {
+ const parsedUrl = new URL(link.url);
+
+ return (
+ <ImageCard
+ className={
+ "bg-gray-50 duration-300 ease-in border border-grey-100 hover:transition-all hover:border-blue-300"
+ }
+ image={link.details?.imageUrl ?? undefined}
+ >
+ <ImageCardTitle>
+ <Link className="line-clamp-3" href={link.url}>
+ {link.details?.title ?? parsedUrl.host}
+ </Link>
+ </ImageCardTitle>
+ <ImageCardBody className="py-2 overflow-clip">
+ {link.tags.map((t) => (
+ <Badge variant="default" className="bg-gray-300 text-gray-500" key={t.id}>
+ #{t.name}
+ </Badge>
+ ))}
+ </ImageCardBody>
+ <ImageCardFooter>
+ <div className="flex justify-between text-gray-500">
+ <div className="my-auto">
+ <Link className="line-clamp-1 hover:text-black" href={link.url}>
+ {parsedUrl.host}
+ </Link>
+ </div>
+ <LinkOptions linkId={link.id} />
+ </div>
+ </ImageCardFooter>
+ </ImageCard>
+ );
+}
diff --git a/packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx b/packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx
new file mode 100644
index 00000000..66f0d766
--- /dev/null
+++ b/packages/web/app/dashboard/bookmarks/components/LinksGrid.tsx
@@ -0,0 +1,21 @@
+import { getServerSession } from "next-auth";
+import { redirect } from "next/navigation";
+import { authOptions } from "@/lib/auth";
+import { getLinks } from "@/lib/services/links";
+import LinkCard from "./LinkCard";
+
+export default async function LinksGrid() {
+ const session = await getServerSession(authOptions);
+ if (!session) {
+ redirect("/");
+ }
+ const links = await getLinks(session.user.id);
+
+ return (
+ <div className="container grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
+ {links.map((l) => (
+ <LinkCard key={l.id} link={l} />
+ ))}
+ </div>
+ );
+}
diff --git a/packages/web/app/dashboard/bookmarks/page.tsx b/packages/web/app/dashboard/bookmarks/page.tsx
new file mode 100644
index 00000000..b4158893
--- /dev/null
+++ b/packages/web/app/dashboard/bookmarks/page.tsx
@@ -0,0 +1,20 @@
+import AddLink from "./components/AddLink";
+import LinksGrid from "./components/LinksGrid";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Remember - Bookmarks",
+};
+
+export default async function Bookmarks() {
+ return (
+ <div className="flex flex-col">
+ <div>
+ <AddLink />
+ </div>
+ <div>
+ <LinksGrid />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx
new file mode 100644
index 00000000..0ed87daf
--- /dev/null
+++ b/packages/web/app/dashboard/components/Sidebar.tsx
@@ -0,0 +1,56 @@
+import { Button } from "@/components/ui/button";
+import { authOptions } from "@/lib/auth";
+import { Archive, MoreHorizontal, Star, Tag, Home, Brain} from "lucide-react";
+import { getServerSession } from "next-auth";
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+function SidebarItem({
+ name,
+ logo,
+ path,
+}: {
+ name: string;
+ logo: React.ReactNode;
+ path: string;
+}) {
+ return (
+ <li className="rounded-lg px-3 py-2 hover:bg-slate-100">
+ <Link href={path} className="flex w-full space-x-2">
+ {logo}
+ <span className="my-auto"> {name} </span>
+ </Link>
+ </li>
+ );
+}
+
+export default async function Sidebar() {
+ const session = await getServerSession(authOptions);
+ if (!session) {
+ redirect("/");
+ }
+
+ return (
+ <aside className="flex flex-col h-full w-60 border-r p-4">
+ <div className="flex px-1 mb-5 items-center rounded-lg text-slate-900">
+ <Brain />
+ <span className="ml-2 text-base font-semibold">Remember</span>
+ </div>
+ <hr />
+ <div>
+ <ul className="space-y-2 mt-5 text-sm font-medium">
+ <SidebarItem logo={<Home />} name="Home" path="#" />
+ <SidebarItem logo={<Star />} name="Favourites" path="#" />
+ <SidebarItem logo={<Archive />} name="Archived" path="#" />
+ <SidebarItem logo={<Tag />} name="Tags" path="#" />
+ </ul>
+ </div>
+ <div className="mt-auto flex justify-between">
+ <div className="my-auto"> {session.user.name} </div>
+ <Button variant="ghost" className="h-10 w-30">
+ <MoreHorizontal />
+ </Button>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/web/app/dashboard/layout.tsx b/packages/web/app/dashboard/layout.tsx
new file mode 100644
index 00000000..9b21271e
--- /dev/null
+++ b/packages/web/app/dashboard/layout.tsx
@@ -0,0 +1,15 @@
+import Bookmarks from "@/app/dashboard/bookmarks/page";
+import Sidebar from "@/app/dashboard/components/Sidebar";
+
+export default async function Dashboard() {
+ return (
+ <div className="flex w-screen h-screen">
+ <div className="flex-none">
+ <Sidebar />
+ </div>
+ <div className="flex-1 bg-gray-100">
+ <Bookmarks />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/app/favicon.ico b/packages/web/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
--- /dev/null
+++ b/packages/web/app/favicon.ico
Binary files differ
diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css
new file mode 100644
index 00000000..8abdb15c
--- /dev/null
+++ b/packages/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/packages/web/app/layout.tsx b/packages/web/app/layout.tsx
new file mode 100644
index 00000000..a2d34046
--- /dev/null
+++ b/packages/web/app/layout.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+import React from "react";
+import { Toaster } from "@/components/ui/toaster";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "Remember",
+ description: "Your AI powered second brain",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ <html lang="en">
+ <body className={inter.className}>
+ {children}
+ <Toaster />
+ </body>
+ </html>
+ );
+}
diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx
new file mode 100644
index 00000000..ffc128a5
--- /dev/null
+++ b/packages/web/app/page.tsx
@@ -0,0 +1,19 @@
+import { LoginButton } from "@/components/auth/login";
+import { LogoutButton } from "@/components/auth/logout";
+import Link from "next/link";
+
+export default function Home() {
+ return (
+ <main className="flex min-h-screen flex-col items-center justify-between p-24">
+ <div>
+ <LoginButton />
+ <br />
+ <br />
+ <LogoutButton />
+ <br />
+ <br />
+ <Link href="/bookmarks">Bookmarks</Link>
+ </div>
+ </main>
+ );
+}
diff --git a/packages/web/bun.lockb b/packages/web/bun.lockb
new file mode 100755
index 00000000..7925e942
--- /dev/null
+++ b/packages/web/bun.lockb
Binary files differ
diff --git a/packages/web/components.json b/packages/web/components.json
new file mode 100644
index 00000000..fa674c93
--- /dev/null
+++ b/packages/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/packages/web/components/auth/login.tsx b/packages/web/components/auth/login.tsx
new file mode 100644
index 00000000..4cd55546
--- /dev/null
+++ b/packages/web/components/auth/login.tsx
@@ -0,0 +1,17 @@
+"use client";
+import { signIn } from "next-auth/react";
+
+export const LoginButton = () => {
+ return (
+ <button
+ className="btn btn-primary"
+ onClick={() =>
+ signIn(undefined, {
+ callbackUrl: "/",
+ })
+ }
+ >
+ Sign in
+ </button>
+ );
+};
diff --git a/packages/web/components/auth/logout.tsx b/packages/web/components/auth/logout.tsx
new file mode 100644
index 00000000..8d627f68
--- /dev/null
+++ b/packages/web/components/auth/logout.tsx
@@ -0,0 +1,17 @@
+"use client";
+import { signOut } from "next-auth/react";
+
+export const LogoutButton = () => {
+ return (
+ <button
+ className="btn btn-ghost normal-case"
+ onClick={() =>
+ signOut({
+ callbackUrl: "/",
+ })
+ }
+ >
+ Sign Out
+ </button>
+ );
+};
diff --git a/packages/web/components/ui/badge.tsx b/packages/web/components/ui/badge.tsx
new file mode 100644
index 00000000..d3d5d604
--- /dev/null
+++ b/packages/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(
+ "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-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ 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/packages/web/components/ui/button.tsx b/packages/web/components/ui/button.tsx
new file mode 100644
index 00000000..57c9fe47
--- /dev/null
+++ b/packages/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(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 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 border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ 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: "h-10 w-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/packages/web/components/ui/card.tsx b/packages/web/components/ui/card.tsx
new file mode 100644
index 00000000..dc3b01de
--- /dev/null
+++ b/packages/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(
+ "rounded-lg border bg-card text-card-foreground 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-sm text-muted-foreground", 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/packages/web/components/ui/dropdown-menu.tsx b/packages/web/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..3a0c7fed
--- /dev/null
+++ b/packages/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(
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRight className="ml-auto h-4 w-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(
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
+ 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(
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
+ 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(
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground 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(
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Check className="h-4 w-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(
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Circle className="h-2 w-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("-mx-1 my-1 h-px bg-muted", 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/packages/web/components/ui/form.tsx b/packages/web/components/ui/form.tsx
new file mode 100644
index 00000000..4603f8b3
--- /dev/null
+++ b/packages/web/components/ui/form.tsx
@@ -0,0 +1,176 @@
+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-sm text-muted-foreground", 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-sm font-medium text-destructive", className)}
+ {...props}
+ >
+ {body}
+ </p>
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/packages/web/components/ui/imageCard.tsx b/packages/web/components/ui/imageCard.tsx
new file mode 100644
index 00000000..1394ae08
--- /dev/null
+++ b/packages/web/components/ui/imageCard.tsx
@@ -0,0 +1,56 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export function ImageCard({
+ children,
+ image,
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement> & { image?: string }) {
+ return (
+ <div
+ className={cn("h-96 rounded-lg overflow-hidden shadow-md", className)}
+ {...props}
+ >
+ <div
+ className="h-3/5 bg-cover bg-center"
+ style={{
+ backgroundImage: image ? `url(${image})` : undefined,
+ }}
+ ></div>
+ <div className="flex flex-col h-2/5 p-2">{children}</div>
+ </div>
+ );
+}
+
+export function ImageCardTitle({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("order-first flex-none font-bold text-lg", className)}
+ {...props}
+ />
+ );
+}
+
+export function ImageCardBody({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("grow order-1 font-bold text-lg", className)}
+ {...props}
+ />
+ );
+}
+
+export function ImageCardFooter({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("order-last", className)} {...props} />;
+}
diff --git a/packages/web/components/ui/input.tsx b/packages/web/components/ui/input.tsx
new file mode 100644
index 00000000..9d631e7f
--- /dev/null
+++ b/packages/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(
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ ref={ref}
+ {...props}
+ />
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/packages/web/components/ui/label.tsx b/packages/web/components/ui/label.tsx
new file mode 100644
index 00000000..53418217
--- /dev/null
+++ b/packages/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/packages/web/components/ui/toast.tsx b/packages/web/components/ui/toast.tsx
new file mode 100644
index 00000000..2bc23c1f
--- /dev/null
+++ b/packages/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(
+ "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 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",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ 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(
+ "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 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",
+ 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(
+ "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground 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="h-4 w-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/packages/web/components/ui/toaster.tsx b/packages/web/components/ui/toaster.tsx
new file mode 100644
index 00000000..7d82ed55
--- /dev/null
+++ b/packages/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/packages/web/components/ui/use-toast.ts b/packages/web/components/ui/use-toast.ts
new file mode 100644
index 00000000..5491e140
--- /dev/null
+++ b/packages/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/packages/web/lib/api.ts b/packages/web/lib/api.ts
new file mode 100644
index 00000000..56686cde
--- /dev/null
+++ b/packages/web/lib/api.ts
@@ -0,0 +1,81 @@
+"use client";
+
+import { ZodTypeAny, z } from "zod";
+import {
+ ZNewBookmarkedLinkRequest,
+ zGetLinksResponseSchema,
+} from "./types/api/links";
+
+import serverConfig from "./config";
+
+const BASE_URL = `${serverConfig.api_url}/api/v1`;
+
+export type FetchError = {
+ status?: number;
+ message?: string;
+};
+
+type InputSchema<T> = T extends ZodTypeAny ? T : undefined;
+
+async function doRequest<T>(
+ path: string,
+ respSchema?: InputSchema<T>,
+ opts?: RequestInit,
+): Promise<
+ | (InputSchema<T> extends ZodTypeAny
+ ? [z.infer<InputSchema<T>>, undefined]
+ : [undefined, undefined])
+ | [undefined, FetchError]
+> {
+ try {
+ const res = await fetch(`${BASE_URL}${path}`, opts);
+ if (!res.ok) {
+ return [
+ undefined,
+ { status: res.status, message: await res.text() },
+ ] as const;
+ }
+ if (!respSchema) {
+ return [undefined, undefined] as const;
+ }
+
+ let parsed = respSchema.safeParse(await res.json());
+ if (!parsed.success) {
+ return [
+ undefined,
+ { message: `Failed to parse response: ${parsed.error.toString()}` },
+ ] as const;
+ }
+
+ return [parsed.data, undefined] as const;
+ } catch (error: any) {
+ return [
+ undefined,
+ { message: `Failed to execute fetch request: ${error}` },
+ ] as const;
+ }
+}
+
+export default class APIClient {
+ static async getLinks() {
+ return await doRequest(`/links`, zGetLinksResponseSchema, {
+ next: { tags: ["links"] },
+ });
+ }
+
+ static async bookmarkLink(url: string) {
+ const body: ZNewBookmarkedLinkRequest = {
+ url,
+ };
+ return await doRequest(`/links`, undefined, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ }
+
+ static async unbookmarkLink(linkId: string) {
+ return await doRequest(`/links/${linkId}`, undefined, {
+ method: "DELETE",
+ });
+ }
+}
diff --git a/packages/web/lib/auth.ts b/packages/web/lib/auth.ts
new file mode 100644
index 00000000..cd6404de
--- /dev/null
+++ b/packages/web/lib/auth.ts
@@ -0,0 +1,25 @@
+import NextAuth, { NextAuthOptions } from "next-auth";
+import { PrismaAdapter } from "@next-auth/prisma-adapter";
+import AuthentikProvider from "next-auth/providers/authentik";
+import serverConfig from "@/lib/config";
+import prisma from "@remember/db";
+
+let providers = [];
+
+if (serverConfig.auth.authentik) {
+ providers.push(AuthentikProvider(serverConfig.auth.authentik));
+}
+
+export const authOptions: NextAuthOptions = {
+ // Configure one or more authentication providers
+ adapter: PrismaAdapter(prisma),
+ providers: providers,
+ callbacks: {
+ session({ session, user }) {
+ session.user = { ...user };
+ return session;
+ },
+ },
+};
+
+export const authHandler = NextAuth(authOptions);
diff --git a/packages/web/lib/config.ts b/packages/web/lib/config.ts
new file mode 100644
index 00000000..ec042b54
--- /dev/null
+++ b/packages/web/lib/config.ts
@@ -0,0 +1,22 @@
+function buildAuthentikConfig() {
+ let { AUTHENTIK_ID, AUTHENTIK_SECRET, AUTHENTIK_ISSUER } = process.env;
+
+ if (!AUTHENTIK_ID || !AUTHENTIK_SECRET || !AUTHENTIK_ISSUER) {
+ return undefined;
+ }
+
+ return {
+ clientId: AUTHENTIK_ID,
+ clientSecret: AUTHENTIK_SECRET,
+ issuer: AUTHENTIK_ISSUER,
+ };
+}
+
+const serverConfig = {
+ api_url: process.env.API_URL || "http://localhost:3000",
+ auth: {
+ authentik: buildAuthentikConfig(),
+ },
+};
+
+export default serverConfig;
diff --git a/packages/web/lib/services/links.ts b/packages/web/lib/services/links.ts
new file mode 100644
index 00000000..d273b118
--- /dev/null
+++ b/packages/web/lib/services/links.ts
@@ -0,0 +1,78 @@
+import { LinkCrawlerQueue } from "@remember/shared/queues";
+import prisma from "@remember/db";
+import { ZBookmarkedLink } from "@/lib/types/api/links";
+
+const defaultLinkFields = {
+ id: true,
+ url: true,
+ createdAt: true,
+ details: {
+ select: {
+ title: true,
+ description: true,
+ imageUrl: true,
+ favicon: true,
+ },
+ },
+ tags: {
+ include: {
+ tag: true,
+ },
+ },
+};
+
+async function dummyPrismaReturnType() {
+ return await prisma.bookmarkedLink.findFirstOrThrow({
+ select: defaultLinkFields,
+ });
+}
+
+function toZodSchema(
+ link: Awaited<ReturnType<typeof dummyPrismaReturnType>>,
+): ZBookmarkedLink {
+ return {
+ id: link.id,
+ url: link.url,
+ createdAt: link.createdAt,
+ details: link.details,
+ tags: link.tags.map((t) => t.tag),
+ };
+}
+
+export async function unbookmarkLink(linkId: string, userId: string) {
+ await prisma.bookmarkedLink.delete({
+ where: {
+ id: linkId,
+ userId,
+ },
+ });
+}
+
+export async function bookmarkLink(url: string, userId: string) {
+ const link = await prisma.bookmarkedLink.create({
+ data: {
+ url,
+ userId,
+ },
+ select: defaultLinkFields,
+ });
+
+ // Enqueue crawling request
+ await LinkCrawlerQueue.add("crawl", {
+ linkId: link.id,
+ url: link.url,
+ });
+
+ return toZodSchema(link);
+}
+
+export async function getLinks(userId: string) {
+ return (
+ await prisma.bookmarkedLink.findMany({
+ where: {
+ userId,
+ },
+ select: defaultLinkFields,
+ })
+ ).map(toZodSchema);
+}
diff --git a/packages/web/lib/types/api/links.ts b/packages/web/lib/types/api/links.ts
new file mode 100644
index 00000000..f84445f6
--- /dev/null
+++ b/packages/web/lib/types/api/links.ts
@@ -0,0 +1,33 @@
+import { z } from "zod";
+import { zBookmarkTagSchema } from "@/lib/types/api/tags";
+
+export const zBookmarkedLinkSchema = z.object({
+ id: z.string(),
+ url: z.string().url(),
+ createdAt: z.coerce.date(),
+
+ details: z
+ .object({
+ title: z.string().nullish(),
+ description: z.string().nullish(),
+ imageUrl: z.string().url().nullish(),
+ favicon: z.string().url().nullish(),
+ })
+ .nullish(),
+ tags: z.array(zBookmarkTagSchema),
+});
+export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>;
+
+// POST /v1/links
+export const zNewBookmarkedLinkRequestSchema = zBookmarkedLinkSchema.pick({
+ url: true,
+});
+export type ZNewBookmarkedLinkRequest = z.infer<
+ typeof zNewBookmarkedLinkRequestSchema
+>;
+
+// GET /v1/links
+export const zGetLinksResponseSchema = z.object({
+ links: z.array(zBookmarkedLinkSchema),
+});
+export type ZGetLinksResponse = z.infer<typeof zGetLinksResponseSchema>;
diff --git a/packages/web/lib/types/api/tags.ts b/packages/web/lib/types/api/tags.ts
new file mode 100644
index 00000000..f2d2bc18
--- /dev/null
+++ b/packages/web/lib/types/api/tags.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const zBookmarkTagSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+});
diff --git a/packages/web/lib/types/next-auth.d.ts b/packages/web/lib/types/next-auth.d.ts
new file mode 100644
index 00000000..cd47dfce
--- /dev/null
+++ b/packages/web/lib/types/next-auth.d.ts
@@ -0,0 +1,12 @@
+import { DefaultSession } from "next-auth";
+
+declare module "next-auth" {
+ /**
+ * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
+ */
+ export interface Session {
+ user: {
+ id: string;
+ } & DefaultSession["user"];
+ }
+}
diff --git a/packages/web/lib/utils.ts b/packages/web/lib/utils.ts
new file mode 100644
index 00000000..365058ce
--- /dev/null
+++ b/packages/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/packages/web/next.config.mjs b/packages/web/next.config.mjs
new file mode 100644
index 00000000..4678774e
--- /dev/null
+++ b/packages/web/next.config.mjs
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+export default nextConfig;
diff --git a/packages/web/package.json b/packages/web/package.json
new file mode 100644
index 00000000..8a315beb
--- /dev/null
+++ b/packages/web/package.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@remember/web",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^3.3.4",
+ "@next-auth/prisma-adapter": "^1.0.7",
+ "@next/eslint-plugin-next": "^14.1.0",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-toast": "^1.1.5",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "install": "^0.13.0",
+ "lucide-react": "^0.322.0",
+ "next": "14.1.0",
+ "next-auth": "^4.24.5",
+ "prettier": "^3.2.5",
+ "react": "^18",
+ "react-dom": "^18",
+ "react-hook-form": "^7.50.1",
+ "tailwind-merge": "^2.2.1",
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "autoprefixer": "^10.0.1",
+ "postcss": "^8",
+ "tailwindcss": "^3.3.0"
+ }
+}
diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js
new file mode 100644
index 00000000..12a703d9
--- /dev/null
+++ b/packages/web/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/packages/web/public/next.svg b/packages/web/public/next.svg
new file mode 100644
index 00000000..5174b28c
--- /dev/null
+++ b/packages/web/public/next.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file
diff --git a/packages/web/public/vercel.svg b/packages/web/public/vercel.svg
new file mode 100644
index 00000000..d2f84222
--- /dev/null
+++ b/packages/web/public/vercel.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg> \ No newline at end of file
diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts
new file mode 100644
index 00000000..41668a3b
--- /dev/null
+++ b/packages/web/tailwind.config.ts
@@ -0,0 +1,80 @@
+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" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+} satisfies Config;
+
+export default config;
diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json
new file mode 100644
index 00000000..a25dbc14
--- /dev/null
+++ b/packages/web/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}