├── components ├── notebooks.tsx ├── ui │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── collapsible.tsx │ ├── input.tsx │ ├── tooltip.tsx │ ├── button.tsx │ ├── card.tsx │ ├── breadcrumb.tsx │ ├── animated-group.tsx │ ├── form.tsx │ ├── alert-dialog.tsx │ ├── dialog.tsx │ ├── sheet.tsx │ ├── text-effect.tsx │ ├── dropdown-menu.tsx │ └── particles.tsx ├── theme-provider.tsx ├── logout.tsx ├── footer.tsx ├── mode-switcher.tsx ├── call-to-action.tsx ├── search-form.tsx ├── mode-toggle.tsx ├── app-sidebar.tsx ├── page-wrapper.tsx ├── sidebar-data.tsx ├── note-card.tsx ├── notebook-card.tsx ├── create-note-button.tsx ├── create-notebook-button.tsx ├── forms │ ├── forgot-password-form.tsx │ ├── reset-password-form.tsx │ ├── login-form.tsx │ └── signup-form.tsx ├── features.tsx ├── auth-page.tsx ├── logo.tsx ├── emails │ ├── verification-email.tsx │ └── reset-email.tsx ├── hero-section.tsx ├── header.tsx └── rich-text-editor.tsx ├── app ├── favicon.ico ├── api │ └── auth │ │ └── [...all] │ │ └── route.ts ├── login │ └── page.tsx ├── signup │ └── page.tsx ├── forgot-password │ └── page.tsx ├── reset-password │ └── page.tsx ├── page.tsx ├── dashboard │ ├── layout.tsx │ ├── page.tsx │ └── notebook │ │ └── [notebookId] │ │ ├── note │ │ └── [noteId] │ │ │ └── page.tsx │ │ └── page.tsx ├── layout.tsx └── globals.css ├── public ├── dark.png ├── light.png ├── noteforge-logo.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── next.config.ts ├── db ├── drizzle.ts └── schema.ts ├── lib ├── auth-client.ts ├── utils.ts └── auth.ts ├── drizzle.config.ts ├── eslint.config.mjs ├── middleware.ts ├── hooks └── use-mobile.ts ├── .gitignore ├── components.json ├── tsconfig.json ├── server ├── users.ts ├── notes.ts └── notebooks.ts ├── README.md ├── package.json └── auth-schema.ts /components/notebooks.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheOrcDev/noteforge/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheOrcDev/noteforge/HEAD/public/dark.png -------------------------------------------------------------------------------- /public/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheOrcDev/noteforge/HEAD/public/light.png -------------------------------------------------------------------------------- /public/noteforge-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheOrcDev/noteforge/HEAD/public/noteforge-logo.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /db/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/neon-http'; 2 | import { schema } from './schema'; 3 | 4 | export const db = drizzle(process.env.DATABASE_URL!, { schema }); 5 | -------------------------------------------------------------------------------- /lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react" 2 | 3 | export const authClient = createAuthClient({ 4 | baseURL: process.env.NEXT_PUBLIC_BASE_URL, 5 | }) -------------------------------------------------------------------------------- /app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { POST, GET } = toNextJsHandler(auth); -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | schema: "./db/schema.ts", 5 | out: "./migrations", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL!, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@/components/forms/login-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignupForm } from "@/components/forms/signup-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /app/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPasswordForm } from "@/components/forms/forgot-password-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPasswordForm } from "@/components/forms/reset-password-form"; 2 | import { Suspense } from "react"; 3 | 4 | export default async function Page() { 5 | return ( 6 |
7 |
8 | 9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import CallToAction from "@/components/call-to-action"; 2 | import Features from "@/components/features"; 3 | import Footer from "@/components/footer"; 4 | import { HeroHeader } from "@/components/header"; 5 | import HeroSection from "@/components/hero-section"; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /components/logout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { authClient } from "@/lib/auth-client"; 4 | import { Button } from "./ui/button"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export function Logout() { 8 | const router = useRouter(); 9 | 10 | const handleLogout = async () => { 11 | await authClient.signOut(); 12 | router.push("/"); 13 | }; 14 | 15 | return ( 16 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AppSidebar } from "@/components/app-sidebar"; 2 | import { SidebarProvider } from "@/components/ui/sidebar"; 3 | import { Suspense } from "react"; 4 | 5 | export default function DashboardLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 | 12 | Loading...
}> 13 | 14 | 15 |
{children}
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [{ 13 | ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"] 14 | }, ...compat.extends("next/core-web-vitals", "next/typescript")]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { headers } from "next/headers"; 3 | import { NextRequest, NextResponse } from "next/server"; 4 | 5 | export async function middleware(request: NextRequest) { 6 | const session = await auth.api.getSession({ 7 | headers: await headers() 8 | }) 9 | 10 | if (!session) { 11 | return NextResponse.redirect(new URL("/login", request.url)); 12 | } 13 | 14 | return NextResponse.next(); 15 | } 16 | 17 | export const config = { 18 | matcher: ["/dashboard/:path*"], 19 | }; -------------------------------------------------------------------------------- /hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | }, 21 | "registries": { 22 | "@efferd-ui": "https://ui.efferd.com/r/{name}.json", 23 | "@magicui": "https://magicui.design/r/{name}.json" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | export default function Footer() { 5 | return ( 6 |
7 |
8 | 13 | logo 14 | 15 | Noteforge 16 | 17 | 18 | 19 | {" "} 20 | © {new Date().getFullYear()} NoteForge, All rights reserved 21 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /components/mode-switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { MoonIcon, SunIcon } from "lucide-react"; 6 | import { useTheme } from "next-themes"; 7 | 8 | import { Button } from "@/components/ui/button"; 9 | 10 | export function ModeSwitcher() { 11 | const { setTheme, resolvedTheme } = useTheme(); 12 | 13 | const toggleTheme = React.useCallback(() => { 14 | setTheme(resolvedTheme === "dark" ? "light" : "dark"); 15 | }, [resolvedTheme, setTheme]); 16 | 17 | return ( 18 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | function Collapsible({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return 9 | } 10 | 11 | function CollapsibleTrigger({ 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 19 | ) 20 | } 21 | 22 | function CollapsibleContent({ 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 30 | ) 31 | } 32 | 33 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 34 | -------------------------------------------------------------------------------- /components/call-to-action.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | 4 | export default function CallToAction() { 5 | return ( 6 |
7 |
8 |
9 |

10 | 🛠️ Build Your Mind Like a Developer 11 |

12 |

13 | Fast, local-first, and built for code. Notes that work the way you 14 | think. 15 |

16 | 17 |
18 | 23 |
24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { CreateNotebookButton } from "@/components/create-notebook-button"; 2 | import NotebookCard from "@/components/notebook-card"; 3 | import { PageWrapper } from "@/components/page-wrapper"; 4 | import { getNotebooks } from "@/server/notebooks"; 5 | 6 | export default async function Page() { 7 | const notebooks = await getNotebooks(); 8 | 9 | return ( 10 | 11 |

Notebooks

12 | 13 | 14 | 15 |
16 | {notebooks.success && 17 | notebooks?.notebooks?.map((notebook) => ( 18 | 19 | ))} 20 |
21 | 22 | {notebooks.success && notebooks?.notebooks?.length === 0 && ( 23 |
No notebooks found
24 | )} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /app/dashboard/notebook/[notebookId]/note/[noteId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { PageWrapper } from "@/components/page-wrapper"; 2 | import RichTextEditor from "@/components/rich-text-editor"; 3 | import { getNoteById } from "@/server/notes"; 4 | import { JSONContent } from "@tiptap/react"; 5 | 6 | type Params = Promise<{ 7 | noteId: string; 8 | }>; 9 | 10 | export default async function NotePage({ params }: { params: Params }) { 11 | const { noteId } = await params; 12 | 13 | const { note } = await getNoteById(noteId); 14 | 15 | return ( 16 | 26 |

{note?.title}

27 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /server/users.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@/lib/auth"; 4 | 5 | export const signInUser = async (email: string, password: string) => { 6 | try { 7 | await auth.api.signInEmail({ 8 | body: { 9 | email, 10 | password 11 | }, 12 | }); 13 | 14 | return { success: true, message: "Signed in successfully" }; 15 | } catch (error) { 16 | const e = error as Error; 17 | return { success: false, message: e.message || "Failed to sign in" }; 18 | } 19 | }; 20 | 21 | export const signUpUser = async (email: string, password: string, name: string) => { 22 | try { 23 | await auth.api.signUpEmail({ 24 | body: { 25 | email, 26 | password, 27 | name, 28 | }, 29 | }); 30 | 31 | return { success: true, message: "Signed up successfully" }; 32 | } catch (error) { 33 | const e = error as Error; 34 | return { success: false, message: e.message || "Failed to sign up" }; 35 | } 36 | }; -------------------------------------------------------------------------------- /components/search-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Search } from "lucide-react"; 4 | 5 | import { Label } from "@/components/ui/label"; 6 | import { 7 | SidebarGroup, 8 | SidebarGroupContent, 9 | SidebarInput, 10 | } from "@/components/ui/sidebar"; 11 | import { useQueryState } from "nuqs"; 12 | 13 | export function SearchForm({ ...props }: React.ComponentProps<"form">) { 14 | const [search, setSearch] = useQueryState("search", { defaultValue: "" }); 15 | 16 | return ( 17 |
18 | 19 | 20 | 23 | setSearch(e.target.value)} 29 | /> 30 | 31 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/dashboard/notebook/[notebookId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { CreateNoteButton } from "@/components/create-note-button"; 2 | import NoteCard from "@/components/note-card"; 3 | import { PageWrapper } from "@/components/page-wrapper"; 4 | import { getNotebookById } from "@/server/notebooks"; 5 | 6 | type Params = Promise<{ 7 | notebookId: string; 8 | }>; 9 | 10 | export default async function NotebookPage({ params }: { params: Params }) { 11 | const { notebookId } = await params; 12 | 13 | const { notebook } = await getNotebookById(notebookId); 14 | 15 | return ( 16 | 25 |

{notebook?.name}

26 | 27 | 28 | 29 |
30 | {notebook?.notes?.map((note) => ( 31 | 32 | ))} 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import { Toaster } from "@/components/ui/sonner"; 6 | import { NuqsAdapter } from "nuqs/adapters/next/app"; 7 | import { Analytics } from "@vercel/analytics/react"; 8 | 9 | const geistSans = Geist({ 10 | variable: "--font-geist-sans", 11 | subsets: ["latin"], 12 | }); 13 | 14 | const geistMono = Geist_Mono({ 15 | variable: "--font-geist-mono", 16 | subsets: ["latin"], 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | title: "NoteForge - Your Dev Note-Taking App", 21 | description: 22 | "NoteForge is a digital notebook that allows you to take notes, create notebooks, and more.", 23 | }; 24 | 25 | export default function RootLayout({ 26 | children, 27 | }: Readonly<{ 28 | children: React.ReactNode; 29 | }>) { 30 | return ( 31 | 32 | 35 | 36 | 42 | 43 | {children} 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | 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. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /server/notes.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db/drizzle"; 4 | import { InsertNote, notes } from "@/db/schema"; 5 | import { eq } from "drizzle-orm"; 6 | 7 | export const createNote = async (values: InsertNote) => { 8 | try { 9 | await db.insert(notes).values(values); 10 | return { success: true, message: "Note created successfully" }; 11 | } catch { 12 | return { success: false, message: "Failed to create notebook" }; 13 | } 14 | }; 15 | 16 | export const getNoteById = async (id: string) => { 17 | try { 18 | const note = await db.query.notes.findFirst({ 19 | where: eq(notes.id, id), 20 | with: { 21 | notebook: true 22 | } 23 | }); 24 | 25 | return { success: true, note }; 26 | } catch { 27 | return { success: false, message: "Failed to get notebook" }; 28 | } 29 | }; 30 | 31 | export const updateNote = async (id: string, values: Partial) => { 32 | try { 33 | await db.update(notes).set(values).where(eq(notes.id, id)); 34 | return { success: true, message: "Notebook updated successfully" }; 35 | } catch { 36 | return { success: false, message: "Failed to update notebook" }; 37 | } 38 | }; 39 | 40 | export const deleteNote = async (id: string) => { 41 | try { 42 | await db.delete(notes).where(eq(notes.id, id)); 43 | return { success: true, message: "Notebook deleted successfully" }; 44 | } catch { 45 | return { success: false, message: "Failed to delete notebook" }; 46 | } 47 | }; -------------------------------------------------------------------------------- /components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { SearchForm } from "@/components/search-form"; 4 | 5 | import { 6 | Sidebar, 7 | SidebarContent, 8 | SidebarHeader, 9 | SidebarRail, 10 | } from "@/components/ui/sidebar"; 11 | import { getNotebooks } from "@/server/notebooks"; 12 | import Image from "next/image"; 13 | import { SidebarData } from "./sidebar-data"; 14 | import Link from "next/link"; 15 | 16 | export async function AppSidebar({ 17 | ...props 18 | }: React.ComponentProps) { 19 | const notebooks = await getNotebooks(); 20 | 21 | const data = { 22 | versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"], 23 | navMain: [ 24 | ...(notebooks.notebooks?.map((notebook) => ({ 25 | title: notebook.name, 26 | url: `/dashboard/${notebook.id}`, 27 | items: notebook.notes.map((note) => ({ 28 | title: note.title, 29 | url: `/dashboard/notebook/${notebook.id}/note/${note.id}`, 30 | })), 31 | })) ?? []), 32 | ], 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 39 | Logo 40 |

NoteForge

41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import PasswordResetEmail from "@/components/emails/reset-email"; 2 | import VerificationEmail from "@/components/emails/verification-email"; 3 | import { db } from "@/db/drizzle"; 4 | import { schema } from "@/db/schema"; 5 | import { betterAuth } from "better-auth"; 6 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 7 | import { nextCookies } from "better-auth/next-js"; 8 | import { Resend } from "resend"; 9 | 10 | const resend = new Resend(process.env.RESEND_API_KEY); 11 | 12 | export const auth = betterAuth({ 13 | emailVerification: { 14 | sendVerificationEmail: async ({ user, url }) => { 15 | await resend.emails.send({ 16 | from: 'NoteForge ', 17 | to: [user.email], 18 | subject: 'Verify your email address', 19 | react: VerificationEmail({ userName: user.name, verificationUrl: url }), 20 | }); 21 | }, 22 | sendOnSignUp: true, 23 | }, 24 | socialProviders: { 25 | google: { 26 | clientId: process.env.GOOGLE_CLIENT_ID as string, 27 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 28 | }, 29 | }, 30 | emailAndPassword: { 31 | enabled: true, 32 | sendResetPassword: async ({ user, url }) => { 33 | await resend.emails.send({ 34 | from: 'NoteForge ', 35 | to: [user.email], 36 | subject: 'Reset your password', 37 | react: PasswordResetEmail({ userName: user.name, resetUrl: url, requestTime: new Date().toLocaleString() }), 38 | }); 39 | }, 40 | }, 41 | database: drizzleAdapter(db, { 42 | provider: "pg", 43 | schema 44 | }), 45 | plugins: [nextCookies()] 46 | }); -------------------------------------------------------------------------------- /components/page-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | BreadcrumbItem, 4 | BreadcrumbLink, 5 | BreadcrumbList, 6 | BreadcrumbSeparator, 7 | } from "@/components/ui/breadcrumb"; 8 | import { SidebarTrigger } from "./ui/sidebar"; 9 | import { Logout } from "./logout"; 10 | import { ModeToggle } from "./mode-toggle"; 11 | import { Fragment } from "react"; 12 | 13 | interface PageWrapperProps { 14 | children: React.ReactNode; 15 | breadcrumbs: { 16 | label: string; 17 | href: string; 18 | }[]; 19 | } 20 | 21 | export function PageWrapper({ children, breadcrumbs }: PageWrapperProps) { 22 | return ( 23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | {breadcrumbs.map((breadcrumb, index) => ( 32 | 33 | 34 | 35 | {breadcrumb.label} 36 | 37 | 38 | {index !== breadcrumbs.length - 1 && ( 39 | 40 | )} 41 | 42 | ))} 43 | 44 | 45 |
46 | 47 |
48 | 49 | 50 |
51 |
52 |
53 | 54 |
{children}
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "noteforge", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build --turbopack", 8 | "start": "next start", 9 | "lint": "eslint ." 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^5.2.2", 13 | "@neondatabase/serverless": "^1.0.2", 14 | "@radix-ui/react-alert-dialog": "^1.1.15", 15 | "@radix-ui/react-collapsible": "^1.1.12", 16 | "@radix-ui/react-dialog": "^1.1.15", 17 | "@radix-ui/react-dropdown-menu": "^2.1.16", 18 | "@radix-ui/react-label": "^2.1.8", 19 | "@radix-ui/react-separator": "^1.1.8", 20 | "@radix-ui/react-slot": "^1.2.4", 21 | "@radix-ui/react-tooltip": "^1.2.8", 22 | "@react-email/components": "^1.0.1", 23 | "@tiptap/extension-document": "^3.13.0", 24 | "@tiptap/extension-paragraph": "^3.13.0", 25 | "@tiptap/extension-text": "^3.13.0", 26 | "@tiptap/pm": "^3.13.0", 27 | "@tiptap/react": "^3.13.0", 28 | "@tiptap/starter-kit": "^3.13.0", 29 | "@vercel/analytics": "^1.6.1", 30 | "better-auth": "^1.4.6", 31 | "class-variance-authority": "^0.7.1", 32 | "clsx": "^2.1.1", 33 | "drizzle-orm": "^0.45.1", 34 | "lucide-react": "^0.560.0", 35 | "motion": "^12.23.26", 36 | "next": "16.0.10", 37 | "next-themes": "^0.4.6", 38 | "nuqs": "^2.8.5", 39 | "react": "19.2.3", 40 | "react-dom": "19.2.3", 41 | "react-hook-form": "^7.68.0", 42 | "resend": "^6.6.0", 43 | "sonner": "^2.0.7", 44 | "tailwind-merge": "^3.4.0", 45 | "zod": "^4.1.13" 46 | }, 47 | "devDependencies": { 48 | "@eslint/eslintrc": "^3.3.3", 49 | "@tailwindcss/postcss": "^4.1.18", 50 | "@types/node": "^25.0.0", 51 | "@types/react": "^19.2.7", 52 | "@types/react-dom": "^19.2.3", 53 | "drizzle-kit": "^0.31.8", 54 | "eslint": "^9.39.1", 55 | "eslint-config-next": "16.0.10", 56 | "tailwindcss": "^4.1.18", 57 | "tw-animate-css": "^1.4.0", 58 | "typescript": "^5.9.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /auth-schema.ts: -------------------------------------------------------------------------------- 1 | import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 2 | 3 | export const user = pgTable("user", { 4 | id: text('id').primaryKey(), 5 | name: text('name').notNull(), 6 | email: text('email').notNull().unique(), 7 | emailVerified: boolean('email_verified').$defaultFn(() => false).notNull(), 8 | image: text('image'), 9 | createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(), 10 | updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull() 11 | }); 12 | 13 | export const session = pgTable("session", { 14 | id: text('id').primaryKey(), 15 | expiresAt: timestamp('expires_at').notNull(), 16 | token: text('token').notNull().unique(), 17 | createdAt: timestamp('created_at').notNull(), 18 | updatedAt: timestamp('updated_at').notNull(), 19 | ipAddress: text('ip_address'), 20 | userAgent: text('user_agent'), 21 | userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }) 22 | }); 23 | 24 | export const account = pgTable("account", { 25 | id: text('id').primaryKey(), 26 | accountId: text('account_id').notNull(), 27 | providerId: text('provider_id').notNull(), 28 | userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), 29 | accessToken: text('access_token'), 30 | refreshToken: text('refresh_token'), 31 | idToken: text('id_token'), 32 | accessTokenExpiresAt: timestamp('access_token_expires_at'), 33 | refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), 34 | scope: text('scope'), 35 | password: text('password'), 36 | createdAt: timestamp('created_at').notNull(), 37 | updatedAt: timestamp('updated_at').notNull() 38 | }); 39 | 40 | export const verification = pgTable("verification", { 41 | id: text('id').primaryKey(), 42 | identifier: text('identifier').notNull(), 43 | value: text('value').notNull(), 44 | expiresAt: timestamp('expires_at').notNull(), 45 | createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()), 46 | updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()) 47 | }); 48 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 62 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: 20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | "icon-sm": "size-8", 29 | "icon-lg": "size-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean 48 | }) { 49 | const Comp = asChild ? Slot : "button" 50 | 51 | return ( 52 | 57 | ) 58 | } 59 | 60 | export { Button, buttonVariants } 61 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /server/notebooks.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db/drizzle"; 4 | import { InsertNotebook, notebooks } from "@/db/schema"; 5 | import { auth } from "@/lib/auth"; 6 | import { eq } from "drizzle-orm"; 7 | import { headers } from "next/headers"; 8 | 9 | export const createNotebook = async (values: InsertNotebook) => { 10 | try { 11 | await db.insert(notebooks).values(values); 12 | return { success: true, message: "Notebook created successfully" }; 13 | } catch { 14 | return { success: false, message: "Failed to create notebook" }; 15 | } 16 | }; 17 | 18 | export const getNotebooks = async () => { 19 | try { 20 | const session = await auth.api.getSession({ 21 | headers: await headers() 22 | }); 23 | 24 | const userId = session?.user?.id; 25 | 26 | if (!userId) { 27 | return { success: false, message: "User not found" }; 28 | } 29 | 30 | const notebooksByUser = await db.query.notebooks.findMany({ 31 | where: eq(notebooks.userId, userId), 32 | with: { 33 | notes: true 34 | } 35 | }); 36 | 37 | return { success: true, notebooks: notebooksByUser }; 38 | } catch { 39 | return { success: false, message: "Failed to get notebooks" }; 40 | } 41 | }; 42 | 43 | export const getNotebookById = async (id: string) => { 44 | try { 45 | const notebook = await db.query.notebooks.findFirst({ 46 | where: eq(notebooks.id, id), 47 | with: { 48 | notes: true 49 | } 50 | }); 51 | 52 | return { success: true, notebook }; 53 | } catch { 54 | return { success: false, message: "Failed to get notebook" }; 55 | } 56 | }; 57 | 58 | export const updateNotebook = async (id: string, values: InsertNotebook) => { 59 | try { 60 | await db.update(notebooks).set(values).where(eq(notebooks.id, id)); 61 | return { success: true, message: "Notebook updated successfully" }; 62 | } catch { 63 | return { success: false, message: "Failed to update notebook" }; 64 | } 65 | }; 66 | 67 | export const deleteNotebook = async (id: string) => { 68 | try { 69 | await db.delete(notebooks).where(eq(notebooks.id, id)); 70 | return { success: true, message: "Notebook deleted successfully" }; 71 | } catch { 72 | return { success: false, message: "Failed to delete notebook" }; 73 | } 74 | }; -------------------------------------------------------------------------------- /components/sidebar-data.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Collapsible, 5 | CollapsibleContent, 6 | CollapsibleTrigger, 7 | } from "@/components/ui/collapsible"; 8 | import { 9 | SidebarGroup, 10 | SidebarGroupContent, 11 | SidebarGroupLabel, 12 | SidebarMenu, 13 | SidebarMenuButton, 14 | SidebarMenuItem, 15 | } from "./ui/sidebar"; 16 | import { ChevronRight, File } from "lucide-react"; 17 | import { useQueryState } from "nuqs"; 18 | 19 | interface SidebarDataProps { 20 | data: { 21 | navMain: { 22 | title: string; 23 | items: { title: string; url: string }[]; 24 | }[]; 25 | }; 26 | } 27 | 28 | export function SidebarData({ data }: SidebarDataProps) { 29 | const [search] = useQueryState("search", { defaultValue: "" }); 30 | 31 | const filteredData = data.navMain.filter((item) => { 32 | const notebookMatches = item.title 33 | .toLowerCase() 34 | .includes(search.toLowerCase()); 35 | 36 | const noteMatches = item.items.some((note) => 37 | note.title.toLowerCase().includes(search.toLowerCase()) 38 | ); 39 | 40 | return notebookMatches || noteMatches; 41 | }); 42 | 43 | return ( 44 | <> 45 | {filteredData.map((item) => ( 46 | 52 | 53 | 57 | 58 | {item.title}{" "} 59 | {item.items.length > 0 && ( 60 | 61 | )} 62 | 63 | 64 | 65 | 66 | 67 | {item.items.map((item) => ( 68 | 69 | 70 | 71 | 72 | {item.title} 73 | 74 | 75 | 76 | ))} 77 | 78 | 79 | 80 | 81 | 82 | ))} 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { 8 | return