From 04572a8e5081b1e4871e273cde9dbaaa44c52fe0 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Wed, 13 Mar 2024 21:43:44 +0000 Subject: structure: Create apps dir and copy tooling dir from t3-turbo repo --- apps/web/app/api/auth/[...nextauth]/route.tsx | 3 + apps/web/app/api/trpc/[trpc]/route.ts | 36 ++++ apps/web/app/dashboard/admin/page.tsx | 203 +++++++++++++++++++++ apps/web/app/dashboard/archive/page.tsx | 9 + apps/web/app/dashboard/bookmarks/layout.tsx | 23 +++ apps/web/app/dashboard/bookmarks/loading.tsx | 11 ++ apps/web/app/dashboard/bookmarks/page.tsx | 5 + apps/web/app/dashboard/error.tsx | 9 + apps/web/app/dashboard/favourites/page.tsx | 14 ++ apps/web/app/dashboard/layout.tsx | 24 +++ apps/web/app/dashboard/lists/[listId]/page.tsx | 44 +++++ apps/web/app/dashboard/lists/page.tsx | 14 ++ apps/web/app/dashboard/not-found.tsx | 7 + .../app/dashboard/preview/[bookmarkId]/page.tsx | 14 ++ apps/web/app/dashboard/search/page.tsx | 41 +++++ apps/web/app/dashboard/settings/page.tsx | 9 + apps/web/app/dashboard/tags/[tagName]/page.tsx | 55 ++++++ apps/web/app/dashboard/tags/page.tsx | 56 ++++++ apps/web/app/favicon.ico | Bin 0 -> 15406 bytes apps/web/app/globals.css | 76 ++++++++ apps/web/app/layout.tsx | 51 ++++++ apps/web/app/page.tsx | 12 ++ apps/web/app/signin/page.tsx | 25 +++ 23 files changed, 741 insertions(+) create mode 100644 apps/web/app/api/auth/[...nextauth]/route.tsx create mode 100644 apps/web/app/api/trpc/[trpc]/route.ts create mode 100644 apps/web/app/dashboard/admin/page.tsx create mode 100644 apps/web/app/dashboard/archive/page.tsx create mode 100644 apps/web/app/dashboard/bookmarks/layout.tsx create mode 100644 apps/web/app/dashboard/bookmarks/loading.tsx create mode 100644 apps/web/app/dashboard/bookmarks/page.tsx create mode 100644 apps/web/app/dashboard/error.tsx create mode 100644 apps/web/app/dashboard/favourites/page.tsx create mode 100644 apps/web/app/dashboard/layout.tsx create mode 100644 apps/web/app/dashboard/lists/[listId]/page.tsx create mode 100644 apps/web/app/dashboard/lists/page.tsx create mode 100644 apps/web/app/dashboard/not-found.tsx create mode 100644 apps/web/app/dashboard/preview/[bookmarkId]/page.tsx create mode 100644 apps/web/app/dashboard/search/page.tsx create mode 100644 apps/web/app/dashboard/settings/page.tsx create mode 100644 apps/web/app/dashboard/tags/[tagName]/page.tsx create mode 100644 apps/web/app/dashboard/tags/page.tsx create mode 100644 apps/web/app/favicon.ico create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/signin/page.tsx (limited to 'apps/web/app') 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 ( + <> +

Actions

+ recrawlLinks()} + > + Recrawl All Links + + reindexBookmarks()} + > + Reindex All Bookmarks + + + ); +} + +function ServerStatsSection() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return ; + } + + return ( + <> +

Server Stats

+ + + + Num Users + {serverStats.numUsers} + + + Num Bookmarks + {serverStats.numBookmarks} + + +
+
+

Background Jobs

+ + + + Pending Crawling Jobs + {serverStats.pendingCrawls} + + + Pending Indexing Jobs + {serverStats.pendingIndexing} + + + Pending OpenAI Jobs + {serverStats.pendingOpenai} + + +
+ + ); +} + +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 ; + } + + return ( + <> +

Users

+ + + Name + Email + Role + Action + + + {users.users.map((u) => ( + + {u.name} + {u.email} + {u.role} + + deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + + + + + ))} + +
+ + ); +} + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return ; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + return ( +
+

Admin

+
+ +
+ +
+ +
+ ); +} 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 ( +
+ +
+ ); +} 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 ( +
+
+ +
+
+
{children}
+
+ ); +} 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 ( +
+
+ +
+
+ ); +} 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 ; +} 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 ( +
+
Something went wrong
+
+ ); +} 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 ( +
+ +
+ ); +} 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 ( +
+
+ +
+
+
+ + +
+ {children} +
+
+ ); +} 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 ( +
+
+ + {list.icon} {list.name} + + +
+
+ +
+ ); +} 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 ( +
+

📋 All Lists

+
+ +
+ ); +} 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 ( +
+
Not Found :(
+
+ ); +} 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 ; +} 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 = + useRef(null); + + return ( +
+ +
+ {data ? ( + b.id) }} + bookmarks={data.bookmarks} + /> + ) : ( + + )} +
+ ); +} + +export default function SearchPage() { + return ( + + + + ); +} 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 ( +
+

Settings

+ +
+ ); +} 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 ( +
+ {tagName} +
+ +
+ ); +} 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 ( + + {name} {count} + + ); +} + +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) => ( + + )); + } else { + tagPill = "No Tags"; + } + + return ( +
+ All Tags +
+
{tagPill}
+
+ ); +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 00000000..750e3c04 Binary files /dev/null and b/apps/web/app/favicon.ico 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 ( + + + + {children} + + + + + + ); +} 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 ( +
+
+ + + +

Hoarder

+
+
+ +
+
+ ); +} -- cgit v1.2.3-70-g09d2