aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web/app
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web/app')
-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
13 files changed, 481 insertions, 0 deletions
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>
+ );
+}