aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/app
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-03-13 21:43:44 +0000
committerMohamed Bassem <me@mbassem.com>2024-03-14 16:40:45 +0000
commit04572a8e5081b1e4871e273cde9dbaaa44c52fe0 (patch)
tree8e993acb732a50d1306d4d6953df96c165c57f57 /apps/web/app
parent2df08ed08c065e8b91bc8df0266bd4bcbb062be4 (diff)
downloadkarakeep-04572a8e5081b1e4871e273cde9dbaaa44c52fe0.tar.zst
structure: Create apps dir and copy tooling dir from t3-turbo repo
Diffstat (limited to 'apps/web/app')
-rw-r--r--apps/web/app/api/auth/[...nextauth]/route.tsx3
-rw-r--r--apps/web/app/api/trpc/[trpc]/route.ts36
-rw-r--r--apps/web/app/dashboard/admin/page.tsx203
-rw-r--r--apps/web/app/dashboard/archive/page.tsx9
-rw-r--r--apps/web/app/dashboard/bookmarks/layout.tsx23
-rw-r--r--apps/web/app/dashboard/bookmarks/loading.tsx11
-rw-r--r--apps/web/app/dashboard/bookmarks/page.tsx5
-rw-r--r--apps/web/app/dashboard/error.tsx9
-rw-r--r--apps/web/app/dashboard/favourites/page.tsx14
-rw-r--r--apps/web/app/dashboard/layout.tsx24
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx44
-rw-r--r--apps/web/app/dashboard/lists/page.tsx14
-rw-r--r--apps/web/app/dashboard/not-found.tsx7
-rw-r--r--apps/web/app/dashboard/preview/[bookmarkId]/page.tsx14
-rw-r--r--apps/web/app/dashboard/search/page.tsx41
-rw-r--r--apps/web/app/dashboard/settings/page.tsx9
-rw-r--r--apps/web/app/dashboard/tags/[tagName]/page.tsx55
-rw-r--r--apps/web/app/dashboard/tags/page.tsx56
-rw-r--r--apps/web/app/favicon.icobin0 -> 15406 bytes
-rw-r--r--apps/web/app/globals.css76
-rw-r--r--apps/web/app/layout.tsx51
-rw-r--r--apps/web/app/page.tsx12
-rw-r--r--apps/web/app/signin/page.tsx25
23 files changed, 741 insertions, 0 deletions
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
new file mode 100644
index 00000000..750e3c04
--- /dev/null
+++ b/apps/web/app/favicon.ico
Binary files differ
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
new file mode 100644
index 00000000..8abdb15c
--- /dev/null
+++ b/apps/web/app/globals.css
@@ -0,0 +1,76 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
new file mode 100644
index 00000000..b1790a1f
--- /dev/null
+++ b/apps/web/app/layout.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+import React from "react";
+import { Toaster } from "@/components/ui/toaster";
+import Providers from "@/lib/providers";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { getServerAuthSession } from "@/server/auth";
+import type { Viewport } from "next";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "Hoarder",
+ applicationName: "Hoarder",
+ description: "Your AI powered second brain",
+ manifest: "/manifest.json",
+ appleWebApp: {
+ capable: true,
+ title: "Hoarder",
+ },
+ formatDetection: {
+ telephone: false,
+ },
+};
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ userScalable: false,
+};
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const session = await getServerAuthSession();
+ return (
+ <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>
+ );
+}