├── app ├── favicon.ico ├── f │ └── [name] │ │ ├── [id] │ │ ├── loading.tsx │ │ └── page.tsx │ │ ├── page.tsx │ │ └── new │ │ └── page.tsx ├── components │ ├── welcome-toast.tsx │ ├── search.tsx │ ├── left-sidebar.tsx │ ├── menu.tsx │ ├── right-sidebar.tsx │ ├── thread-actions.tsx │ └── thread-list.tsx ├── layout.tsx ├── search │ └── page.tsx └── globals.css ├── postcss.config.mjs ├── public ├── x.svg ├── linkedin.svg ├── github.svg └── placeholder.svg ├── .prettierrc ├── drizzle.config.ts ├── lib ├── db │ ├── migrations │ │ ├── meta │ │ │ ├── _journal.json │ │ │ └── 0000_snapshot.json │ │ └── 0000_warm_warbird.sql │ ├── drizzle.ts │ ├── migrate.ts │ ├── setup.ts │ ├── schema.ts │ ├── actions.ts │ ├── seed.ts │ └── queries.ts └── utils.tsx ├── next.config.ts ├── components.json ├── .gitignore ├── tsconfig.json ├── package.json ├── components └── ui │ ├── alert.tsx │ ├── tooltip.tsx │ ├── button.tsx │ └── sheet.tsx ├── README.md └── pnpm-lock.yaml /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/next-email-client/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/x.svg: -------------------------------------------------------------------------------- 1 | X -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-organize-imports", 4 | "prettier-plugin-tailwindcss" 5 | ], 6 | "semi": true, 7 | "singleQuote": true, 8 | "tailwindStylesheet": "./app/globals.css", 9 | "tailwindFunctions": ["cn", "clsx", "cva"] 10 | } 11 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit'; 2 | 3 | export default { 4 | schema: './lib/db/schema.ts', 5 | out: './lib/db/migrations', 6 | dialect: 'postgresql', 7 | dbCredentials: { 8 | url: process.env.POSTGRES_URL!, 9 | }, 10 | } satisfies Config; 11 | -------------------------------------------------------------------------------- /lib/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1727742572262, 9 | "tag": "0000_warm_warbird", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /app/f/[name]/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LeftSidebar } from '@/app/components/left-sidebar'; 2 | 3 | export default function LoadingThreadSkeleton() { 4 | return ( 5 |
6 | 7 |
8 |
9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const nextConfig: NextConfig = { 4 | experimental: { 5 | ppr: true, 6 | dynamicIO: true, 7 | serverSourceMaps: true, 8 | }, 9 | async redirects() { 10 | return [ 11 | { 12 | source: '/', 13 | destination: '/f/inbox', 14 | permanent: false, 15 | }, 16 | ]; 17 | }, 18 | }; 19 | 20 | export default nextConfig; 21 | -------------------------------------------------------------------------------- /lib/db/drizzle.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { drizzle } from 'drizzle-orm/postgres-js'; 3 | import postgres from 'postgres'; 4 | import * as schema from './schema'; 5 | 6 | dotenv.config(); 7 | 8 | if (!process.env.POSTGRES_URL) { 9 | throw new Error('POSTGRES_URL environment variable is not set'); 10 | } 11 | 12 | export const client = postgres(process.env.POSTGRES_URL); 13 | export const db = drizzle(client, { schema }); 14 | -------------------------------------------------------------------------------- /lib/db/migrate.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { migrate } from 'drizzle-orm/postgres-js/migrator'; 3 | import path from 'path'; 4 | import { client, db } from './drizzle'; 5 | 6 | dotenv.config(); 7 | 8 | async function main() { 9 | await migrate(db, { 10 | migrationsFolder: path.join(process.cwd(), '/lib/db/migrations'), 11 | }); 12 | console.log(`Migrations complete`); 13 | await client.end(); 14 | } 15 | 16 | main(); 17 | -------------------------------------------------------------------------------- /public/linkedin.svg: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /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": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # env files 29 | .env* 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .vscode 38 | .idea 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 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 | "baseUrl": ".", 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | GitHub -------------------------------------------------------------------------------- /app/components/welcome-toast.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { toast } from 'sonner'; 5 | 6 | export function WelcomeToast() { 7 | useEffect(() => { 8 | if (!document.cookie.includes('email-toast=1')) { 9 | toast('📩 Welcome to Next.js Emails!', { 10 | duration: Infinity, 11 | onDismiss: () => 12 | (document.cookie = 'email-toast=1; max-age=31536000; path=/'), 13 | description: ( 14 |

15 | This is a demo of an email client UI with a Postgres database.{' '} 16 | 21 | Deploy your own 22 | 23 | . 24 |

25 | ), 26 | }); 27 | } 28 | }, []); 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /app/components/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Form from 'next/form'; 4 | import { useSearchParams } from 'next/navigation'; 5 | import { useEffect, useRef } from 'react'; 6 | 7 | export function Search() { 8 | let inputRef = useRef(null); 9 | let searchParams = useSearchParams(); 10 | 11 | useEffect(() => { 12 | if (inputRef.current) { 13 | inputRef.current.focus(); 14 | inputRef.current.setSelectionRange( 15 | inputRef.current.value.length, 16 | inputRef.current.value.length, 17 | ); 18 | } 19 | }, []); 20 | 21 | return ( 22 |
23 | 26 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import { Suspense } from 'react'; 4 | import { Toaster } from 'sonner'; 5 | import { RightSidebar } from './components/right-sidebar'; 6 | import { WelcomeToast } from './components/welcome-toast'; 7 | import './globals.css'; 8 | 9 | const inter = Inter({ subsets: ['latin'] }); 10 | 11 | export const metadata: Metadata = { 12 | title: 'Next.js Mail', 13 | description: 'An email client template using the Next.js App Router.', 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | 24 |
{children}
25 | }> 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | function RightSidebarSkeleton() { 36 | return ( 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /lib/utils.tsx: -------------------------------------------------------------------------------- 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 | 8 | export function formatEmailString( 9 | userEmail: { 10 | firstName: string | null; 11 | lastName: string | null; 12 | email: string; 13 | }, 14 | opts: { includeFullEmail: boolean } = { includeFullEmail: false }, 15 | ) { 16 | if (userEmail.firstName && userEmail.lastName) { 17 | return `${userEmail.firstName} ${userEmail.lastName} ${ 18 | opts.includeFullEmail ? `<${userEmail.email}>` : '' 19 | }`; 20 | } 21 | return userEmail.email; 22 | } 23 | 24 | export function toTitleCase(str: string) { 25 | return str.replace(/\w\S*/g, function (txt: string) { 26 | return txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase(); 27 | }); 28 | } 29 | 30 | export function highlightText(text: string, query: string | undefined) { 31 | if (!query) return text; 32 | const parts = text.split(new RegExp(`(${query})`, 'gi')); 33 | 34 | return parts.map((part, i) => 35 | part.toLowerCase() === query.toLowerCase() ? ( 36 | 37 | {part} 38 | 39 | ) : ( 40 | part 41 | ), 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev --turbo", 5 | "build": "next build", 6 | "start": "next start", 7 | "db:setup": "npx tsx lib/db/setup.ts", 8 | "db:seed": "npx tsx lib/db/seed.ts", 9 | "db:generate": "drizzle-kit generate", 10 | "db:migrate": "npx tsx lib/db/migrate.ts", 11 | "db:studio": "drizzle-kit studio" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-dialog": "^1.1.6", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-slot": "^1.1.2", 17 | "@radix-ui/react-tooltip": "^1.1.8", 18 | "@tailwindcss/postcss": "^4.0.13", 19 | "@types/node": "^22.10.1", 20 | "@types/react": "19.0.10", 21 | "@types/react-dom": "19.0.4", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "dotenv": "^16.4.5", 25 | "drizzle-kit": "^0.28.1", 26 | "drizzle-orm": "^0.36.4", 27 | "geist": "^1.3.1", 28 | "lucide-react": "^0.462.0", 29 | "next": "15.6.0-canary.59", 30 | "postcss": "^8.4.49", 31 | "postgres": "^3.4.5", 32 | "react": "19.0.0", 33 | "react-dom": "19.0.0", 34 | "sonner": "^1.7.0", 35 | "tailwind-merge": "^3.0.2", 36 | "tailwindcss": "^4.0.13", 37 | "tailwindcss-animate": "^1.0.7", 38 | "typescript": "^5.8.2", 39 | "zod": "^3.23.8" 40 | }, 41 | "devDependencies": { 42 | "prettier-plugin-organize-imports": "^4.1.0", 43 | "prettier-plugin-tailwindcss": "^0.6.11" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/components/left-sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'; 5 | import Link from 'next/link'; 6 | import { useParams } from 'next/navigation'; 7 | import { Suspense } from 'react'; 8 | 9 | function BackButton() { 10 | let { name } = useParams(); 11 | 12 | return ( 13 | 14 | 21 | 22 | ); 23 | } 24 | 25 | export function LeftSidebar() { 26 | return ( 27 |
28 | 29 | 30 | 31 | 38 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/f/[name]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ThreadHeader, ThreadList } from '@/app/components/thread-list'; 2 | import { getThreadsForFolder } from '@/lib/db/queries'; 3 | import { Suspense } from 'react'; 4 | 5 | export function generateStaticParams() { 6 | const folderNames = [ 7 | 'inbox', 8 | 'starred', 9 | 'drafts', 10 | 'sent', 11 | 'archive', 12 | 'trash', 13 | ]; 14 | 15 | return folderNames.map((name) => ({ name })); 16 | } 17 | 18 | export default function ThreadsPage({ 19 | params, 20 | searchParams, 21 | }: { 22 | params: Promise<{ name: string }>; 23 | searchParams: Promise<{ q?: string; id?: string }>; 24 | }) { 25 | return ( 26 |
27 | }> 28 | 29 | 30 |
31 | ); 32 | } 33 | 34 | function ThreadsSkeleton({ folderName }: { folderName: string }) { 35 | return ( 36 |
37 | 38 |
39 | ); 40 | } 41 | 42 | async function Threads({ 43 | params, 44 | searchParams, 45 | }: { 46 | params: Promise<{ name: string }>; 47 | searchParams: Promise<{ q?: string; id?: string }>; 48 | }) { 49 | let { name } = await params; 50 | let { q } = await searchParams; 51 | let threads = await getThreadsForFolder(name); 52 | 53 | return ; 54 | } 55 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const alertVariants = cva( 7 | 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | destructive: 13 | 'text-destructive-foreground *:data-[slot=alert-description]:text-destructive-foreground/80 [&>svg]:text-current', 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: 'default', 18 | }, 19 | }, 20 | ); 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<'div'> & VariantProps) { 27 | return ( 28 |
34 | ); 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { 38 | return ( 39 |
47 | ); 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<'div'>) { 54 | return ( 55 |
63 | ); 64 | } 65 | 66 | export { Alert, AlertDescription, AlertTitle }; 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Email Client 2 | 3 | This is an email client template built with Next.js and Postgres. It's built to show off some of the features of the App Router, which enable you to build products that: 4 | 5 | - Navigate between routes in a column layout while maintaining scroll position (layouts support) 6 | - Submit forms without JavaScript enabled (progressive enhancement) 7 | - Navigate between routes extremely fast (prefetching and caching) 8 | - Retain your UI position on reload (URL state) 9 | 10 | **Demo: https://next-email-client.vercel.app** 11 | 12 | ## Tech Stack 13 | 14 | - **Framework**: [Next.js](https://nextjs.org/) 15 | - **Database**: [Postgres](https://www.postgresql.org/) 16 | - **ORM**: [Drizzle](https://orm.drizzle.team/) 17 | - **Styling**: [Tailwind CSS](https://tailwindcss.com/) 18 | - **UI Library**: [shadcn/ui](https://ui.shadcn.com/) 19 | 20 | ## Getting Started 21 | 22 | ```bash 23 | git clone https://github.com/leerob/next-email-client 24 | cd next-email-client 25 | pnpm install 26 | ``` 27 | 28 | ## Running Locally 29 | 30 | Use the included setup script to create your `.env` file: 31 | 32 | ```bash 33 | pnpm db:setup 34 | ``` 35 | 36 | Then, run the database migrations and seed the database with emails and folders: 37 | 38 | ```bash 39 | pnpm db:migrate 40 | pnpm db:seed 41 | ``` 42 | 43 | Finally, run the Next.js development server: 44 | 45 | ```bash 46 | pnpm dev 47 | ``` 48 | 49 | Open [http://localhost:3000](http://localhost:3000) in your browser to see the app in action. 50 | 51 | ## Implemented 52 | 53 | - ✅ Search for emails 54 | - ✅ Profile sidebar with user information 55 | - ✅ View all threads 56 | - ✅ View all emails in a thread 57 | - ✅ Compose view 58 | - ✅ Seed and setup script 59 | - ✅ Highlight searched text 60 | - ✅ Hook up compose view 61 | - ✅ Delete emails (move to trash) 62 | - Make side profile dynamic 63 | - Support Markdown? 64 | - Make up/down arrows work for threads 65 | - Global keyboard shortcuts 66 | - Better date formatting 67 | - Dark mode styles 68 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 4 | import * as React from 'react'; 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, TooltipContent, TooltipProvider, TooltipTrigger }; 62 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40', 16 | outline: 17 | 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 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 gap-1.5 rounded-md px-3 text-xs has-[>svg]:px-2.5', 26 | lg: 'h-8 rounded-md px-2 has-[>svg]:px-2 sm:h-10', 27 | icon: 'size-9', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ); 36 | 37 | function Button({ 38 | className, 39 | variant, 40 | size, 41 | asChild = false, 42 | ...props 43 | }: React.ComponentProps<'button'> & 44 | VariantProps & { 45 | asChild?: boolean; 46 | }) { 47 | const Comp = asChild ? Slot : 'button'; 48 | 49 | return ( 50 | 55 | ); 56 | } 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /app/f/[name]/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { LeftSidebar } from '@/app/components/left-sidebar'; 2 | import { ThreadActions } from '@/app/components/thread-actions'; 3 | import { getEmailsForThread } from '@/lib/db/queries'; 4 | import { notFound } from 'next/navigation'; 5 | 6 | export default async function EmailPage({ 7 | params, 8 | }: { 9 | params: Promise<{ name: string; id: string }>; 10 | }) { 11 | let id = (await params).id; 12 | let thread = await getEmailsForThread(id); 13 | 14 | if (!thread || thread.emails.length === 0) { 15 | notFound(); 16 | } 17 | 18 | return ( 19 |
20 | 21 |
22 |
23 |
24 |

25 | {thread.subject} 26 |

27 |
28 | 31 | 32 |
33 |
34 |
35 | {thread.emails.map((email) => ( 36 |
37 |
38 |
39 | {email.sender.firstName} {email.sender.lastName} to{' '} 40 | {email.recipientId === thread.emails[0].sender.id 41 | ? 'Me' 42 | : 'All'} 43 |
44 |
45 | {new Date(email.sentDate!).toLocaleString()} 46 |
47 |
48 |
{email.body}
49 |
50 | ))} 51 |
52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/components/menu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Sheet, 3 | SheetContent, 4 | SheetTitle, 5 | SheetTrigger, 6 | } from '@/components/ui/sheet'; 7 | import { Check, FileText, Menu, Send, Star, Trash } from 'lucide-react'; 8 | import Link from 'next/link'; 9 | 10 | export function NavMenu() { 11 | return ( 12 | 13 | 14 | 17 | 18 | 22 | Menu 23 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/components/right-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { getUserProfile } from '@/lib/db/queries'; 2 | import Image from 'next/image'; 3 | 4 | export async function RightSidebar({ userId }: { userId: number }) { 5 | let user = await getUserProfile(userId); 6 | 7 | if (!user) { 8 | return null; 9 | } 10 | 11 | return ( 12 |
13 |
14 |

{`${user.firstName} ${user.lastName}`}

15 |
16 | {`${user.firstName} 21 |
22 |

{user.email}

23 |

{user.location}

24 |
25 |
26 |

{`${user.jobTitle} at ${user.company}`}

27 | 28 |

Mail

29 |
    30 | {user.latestThreads.map((thread, index) => ( 31 |
  • {thread.subject}
  • 32 | ))} 33 |
34 | 35 |
36 | {user.linkedin && ( 37 | 43 | LinkedIn 50 | LinkedIn 51 | 52 | )} 53 | {user.twitter && ( 54 | 60 | X/Twitter 67 | Twitter/X 68 | 69 | )} 70 | {user.github && ( 71 | 77 | GitHub 84 | GitHub 85 | 86 | )} 87 |
88 |
89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /public/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/thread-actions.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from '@/components/ui/tooltip'; 9 | import { moveThreadToDone, moveThreadToTrash } from '@/lib/db/actions'; 10 | import { Archive, Check, Clock } from 'lucide-react'; 11 | import { useActionState } from 'react'; 12 | 13 | interface ThreadActionsProps { 14 | threadId: number; 15 | } 16 | 17 | export function ThreadActions({ threadId }: ThreadActionsProps) { 18 | const initialState = { 19 | error: null, 20 | success: false, 21 | }; 22 | 23 | const [doneState, doneAction, donePending] = useActionState( 24 | moveThreadToDone, 25 | initialState, 26 | ); 27 | const [trashState, trashAction, trashPending] = useActionState( 28 | moveThreadToTrash, 29 | initialState, 30 | ); 31 | 32 | const isProduction = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'; 33 | 34 | return ( 35 | 36 |
37 | 38 | 39 |
40 | 41 | 48 |
49 |
50 | {isProduction && ( 51 | 52 |

Marking as done is disabled in production

53 |
54 | )} 55 |
56 | 57 | 58 | 64 | 65 | 66 |

This feature is not yet implemented

67 |
68 |
69 | 70 | 71 |
72 | 73 | 80 |
81 |
82 | {isProduction && ( 83 | 84 |

Moving to trash is disabled in production

85 |
86 | )} 87 |
88 |
89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { searchThreads } from '@/lib/db/queries'; 2 | import { formatEmailString, highlightText } from '@/lib/utils'; 3 | import { X } from 'lucide-react'; 4 | import Link from 'next/link'; 5 | import { Suspense } from 'react'; 6 | import { NavMenu } from '../components/menu'; 7 | import { Search } from '../components/search'; 8 | 9 | async function Threads({ 10 | searchParams, 11 | }: { 12 | searchParams: Promise<{ q?: string; id?: string }>; 13 | }) { 14 | let q = (await searchParams).q; 15 | let threads = await searchThreads(q); 16 | 17 | return ( 18 |
19 | {threads.map((thread) => { 20 | const latestEmail = thread.latestEmail; 21 | return ( 22 | 26 |
29 |
30 |
31 | 32 | {highlightText(formatEmailString(latestEmail.sender), q)} 33 | 34 |
35 |
36 | 37 | {highlightText(thread.subject, q)} 38 | 39 | 40 | {highlightText(latestEmail.body, q)} 41 | 42 |
43 |
44 |
45 | 46 | {new Date(thread.lastActivityDate).toLocaleDateString()} 47 | 48 |
49 |
50 | 51 | ); 52 | })} 53 |
54 | ); 55 | } 56 | 57 | export default async function SearchPage({ 58 | searchParams, 59 | }: { 60 | searchParams: Promise<{ q?: string; id?: string }>; 61 | }) { 62 | return ( 63 |
64 |
65 |
66 |
67 | 68 | 69 | 70 | 71 |
72 |
73 | 74 | 80 | 81 |
82 |
83 | 84 | 85 | 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /lib/db/setup.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promises as fs } from 'node:fs'; 3 | import path from 'node:path'; 4 | import readline from 'node:readline'; 5 | import { promisify } from 'node:util'; 6 | 7 | const execAsync = promisify(exec); 8 | 9 | function question(query: string): Promise { 10 | const rl = readline.createInterface({ 11 | input: process.stdin, 12 | output: process.stdout, 13 | }); 14 | 15 | return new Promise((resolve) => 16 | rl.question(query, (ans) => { 17 | rl.close(); 18 | resolve(ans); 19 | }), 20 | ); 21 | } 22 | 23 | async function getPostgresURL(): Promise { 24 | console.log('Step 1: Setting up Postgres'); 25 | const dbChoice = await question( 26 | 'Do you want to use a local Postgres instance with Docker (L) or a remote Postgres instance (R)? (L/R): ', 27 | ); 28 | 29 | if (dbChoice.toLowerCase() === 'l') { 30 | console.log('Setting up local Postgres instance with Docker...'); 31 | await setupLocalPostgres(); 32 | return 'postgres://postgres:postgres@localhost:54322/postgres'; 33 | } else { 34 | console.log( 35 | 'You can find Postgres databases at: https://vercel.com/marketplace?category=databases', 36 | ); 37 | return await question('Enter your POSTGRES_URL: '); 38 | } 39 | } 40 | 41 | async function setupLocalPostgres() { 42 | console.log('Checking if Docker is installed...'); 43 | try { 44 | await execAsync('docker --version'); 45 | console.log('Docker is installed.'); 46 | } catch (error) { 47 | console.error( 48 | 'Docker is not installed. Please install Docker and try again.', 49 | ); 50 | console.log( 51 | 'To install Docker, visit: https://docs.docker.com/get-docker/', 52 | ); 53 | process.exit(1); 54 | } 55 | 56 | console.log('Creating docker-compose.yml file...'); 57 | const dockerComposeContent = ` 58 | services: 59 | postgres: 60 | image: postgres:16.4-alpine 61 | container_name: music_player_postgres 62 | environment: 63 | POSTGRES_DB: postgres 64 | POSTGRES_USER: postgres 65 | POSTGRES_PASSWORD: postgres 66 | ports: 67 | - "54322:5432" 68 | volumes: 69 | - postgres_data:/var/lib/postgresql/data 70 | 71 | volumes: 72 | postgres_data: 73 | `; 74 | 75 | await fs.writeFile( 76 | path.join(process.cwd(), 'docker-compose.yml'), 77 | dockerComposeContent, 78 | ); 79 | console.log('docker-compose.yml file created.'); 80 | 81 | console.log('Starting Docker container with `docker compose up -d`...'); 82 | try { 83 | await execAsync('docker compose up -d'); 84 | console.log('Docker container started successfully.'); 85 | } catch (error) { 86 | console.error( 87 | 'Failed to start Docker container. Please check your Docker installation and try again.', 88 | ); 89 | process.exit(1); 90 | } 91 | } 92 | 93 | async function writeEnvFile(envVars: Record) { 94 | console.log('Step 3: Writing environment variables to .env'); 95 | const envContent = Object.entries(envVars) 96 | .map(([key, value]) => `${key}=${value}`) 97 | .join('\n'); 98 | 99 | await fs.writeFile(path.join(process.cwd(), '.env'), envContent); 100 | console.log('.env file created with the necessary variables.'); 101 | } 102 | 103 | async function main() { 104 | const POSTGRES_URL = await getPostgresURL(); 105 | 106 | await writeEnvFile({ 107 | POSTGRES_URL, 108 | }); 109 | 110 | console.log('🎉 Setup completed successfully!'); 111 | } 112 | 113 | main().catch(console.error); 114 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin "tailwindcss-animate"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @layer base { 8 | :root { 9 | --background: hsl(0 0% 100%); 10 | --foreground: hsl(222.2 84% 4.9%); 11 | --card: hsl(0 0% 100%); 12 | --card-foreground: hsl(222.2 84% 4.9%); 13 | --popover: hsl(0 0% 100%); 14 | --popover-foreground: hsl(222.2 84% 4.9%); 15 | --primary: hsl(222.2 47.4% 11.2%); 16 | --primary-foreground: hsl(210 40% 98%); 17 | --secondary: hsl(210 40% 96.1%); 18 | --secondary-foreground: hsl(222.2 47.4% 11.2%); 19 | --muted: hsl(210 40% 96.1%); 20 | --muted-foreground: hsl(215.4 16.3% 46.9%); 21 | --accent: hsl(210 40% 96.1%); 22 | --accent-foreground: hsl(222.2 47.4% 11.2%); 23 | --destructive: hsl(0 84.2% 60.2%); 24 | --destructive-foreground: hsl(210 40% 98%); 25 | --border: hsl(214.3 31.8% 91.4%); 26 | --input: hsl(214.3 31.8% 91.4%); 27 | --ring: hsl(222.2 84% 4.9%); 28 | --chart-1: hsl(12 76% 61%); 29 | --chart-2: hsl(173 58% 39%); 30 | --chart-3: hsl(197 37% 24%); 31 | --chart-4: hsl(43 74% 66%); 32 | --chart-5: hsl(27 87% 67%); 33 | --radius: 0.5rem; 34 | } 35 | 36 | .dark { 37 | --background: hsl(222.2 84% 4.9%); 38 | --foreground: hsl(210 40% 98%); 39 | --card: hsl(222.2 84% 4.9%); 40 | --card-foreground: hsl(210 40% 98%); 41 | --popover: hsl(222.2 84% 4.9%); 42 | --popover-foreground: hsl(210 40% 98%); 43 | --primary: hsl(210 40% 98%); 44 | --primary-foreground: hsl(222.2 47.4% 11.2%); 45 | --secondary: hsl(217.2 32.6% 17.5%); 46 | --secondary-foreground: hsl(210 40% 98%); 47 | --muted: hsl(217.2 32.6% 17.5%); 48 | --muted-foreground: hsl(215 20.2% 65.1%); 49 | --accent: hsl(217.2 32.6% 17.5%); 50 | --accent-foreground: hsl(210 40% 98%); 51 | --destructive: hsl(0 62.8% 30.6%); 52 | --destructive-foreground: hsl(210 40% 98%); 53 | --border: hsl(217.2 32.6% 17.5%); 54 | --input: hsl(217.2 32.6% 17.5%); 55 | --ring: hsl(212.7 26.8% 83.9%); 56 | --chart-1: hsl(220 70% 50%); 57 | --chart-2: hsl(160 60% 45%); 58 | --chart-3: hsl(30 80% 55%); 59 | --chart-4: hsl(280 65% 60%); 60 | --chart-5: hsl(340 75% 55%); 61 | } 62 | } 63 | 64 | @theme inline { 65 | --color-background: var(--background); 66 | --color-foreground: var(--foreground); 67 | --color-card: var(--card); 68 | --color-card-foreground: var(--card-foreground); 69 | --color-popover: var(--popover); 70 | --color-popover-foreground: var(--popover-foreground); 71 | --color-primary: var(--primary); 72 | --color-primary-foreground: var(--primary-foreground); 73 | --color-secondary: var(--secondary); 74 | --color-secondary-foreground: var(--secondary-foreground); 75 | --color-muted: var(--muted); 76 | --color-muted-foreground: var(--muted-foreground); 77 | --color-accent: var(--accent); 78 | --color-accent-foreground: var(--accent-foreground); 79 | --color-destructive: var(--destructive); 80 | --color-destructive-foreground: var(--destructive-foreground); 81 | --color-border: var(--border); 82 | --color-input: var(--input); 83 | --color-ring: var(--ring); 84 | --color-chart-1: var(--chart-1); 85 | --color-chart-2: var(--chart-2); 86 | --color-chart-3: var(--chart-3); 87 | --color-chart-4: var(--chart-4); 88 | --color-chart-5: var(--chart-5); 89 | --radius-sm: calc(var(--radius) - 4px); 90 | --radius-md: calc(var(--radius) - 2px); 91 | --radius-lg: var(--radius); 92 | --radius-xl: calc(var(--radius) + 4px); 93 | } 94 | 95 | @layer base { 96 | * { 97 | @apply border-border outline-ring/50; 98 | } 99 | 100 | body { 101 | @apply bg-background text-foreground; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/db/migrations/0000_warm_warbird.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "emails" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "thread_id" integer, 4 | "sender_id" integer, 5 | "recipient_id" integer, 6 | "subject" varchar(255), 7 | "body" text, 8 | "sent_date" timestamp DEFAULT now() 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE IF NOT EXISTS "folders" ( 12 | "id" serial PRIMARY KEY NOT NULL, 13 | "name" varchar(50) NOT NULL 14 | ); 15 | --> statement-breakpoint 16 | CREATE TABLE IF NOT EXISTS "thread_folders" ( 17 | "id" serial PRIMARY KEY NOT NULL, 18 | "thread_id" integer, 19 | "folder_id" integer 20 | ); 21 | --> statement-breakpoint 22 | CREATE TABLE IF NOT EXISTS "threads" ( 23 | "id" serial PRIMARY KEY NOT NULL, 24 | "subject" varchar(255), 25 | "last_activity_date" timestamp DEFAULT now() 26 | ); 27 | --> statement-breakpoint 28 | CREATE TABLE IF NOT EXISTS "user_folders" ( 29 | "id" serial PRIMARY KEY NOT NULL, 30 | "user_id" integer, 31 | "folder_id" integer 32 | ); 33 | --> statement-breakpoint 34 | CREATE TABLE IF NOT EXISTS "users" ( 35 | "id" serial PRIMARY KEY NOT NULL, 36 | "first_name" varchar(50), 37 | "last_name" varchar(50), 38 | "email" varchar(255) NOT NULL, 39 | "job_title" varchar(100), 40 | "company" varchar(100), 41 | "location" varchar(100), 42 | "twitter" varchar(100), 43 | "linkedin" varchar(100), 44 | "github" varchar(100), 45 | "avatar_url" varchar(255) 46 | ); 47 | --> statement-breakpoint 48 | DO $$ BEGIN 49 | ALTER TABLE "emails" ADD CONSTRAINT "emails_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE no action ON UPDATE no action; 50 | EXCEPTION 51 | WHEN duplicate_object THEN null; 52 | END $$; 53 | --> statement-breakpoint 54 | DO $$ BEGIN 55 | ALTER TABLE "emails" ADD CONSTRAINT "emails_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; 56 | EXCEPTION 57 | WHEN duplicate_object THEN null; 58 | END $$; 59 | --> statement-breakpoint 60 | DO $$ BEGIN 61 | ALTER TABLE "emails" ADD CONSTRAINT "emails_recipient_id_users_id_fk" FOREIGN KEY ("recipient_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; 62 | EXCEPTION 63 | WHEN duplicate_object THEN null; 64 | END $$; 65 | --> statement-breakpoint 66 | DO $$ BEGIN 67 | ALTER TABLE "thread_folders" ADD CONSTRAINT "thread_folders_thread_id_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."threads"("id") ON DELETE no action ON UPDATE no action; 68 | EXCEPTION 69 | WHEN duplicate_object THEN null; 70 | END $$; 71 | --> statement-breakpoint 72 | DO $$ BEGIN 73 | ALTER TABLE "thread_folders" ADD CONSTRAINT "thread_folders_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE no action ON UPDATE no action; 74 | EXCEPTION 75 | WHEN duplicate_object THEN null; 76 | END $$; 77 | --> statement-breakpoint 78 | DO $$ BEGIN 79 | ALTER TABLE "user_folders" ADD CONSTRAINT "user_folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; 80 | EXCEPTION 81 | WHEN duplicate_object THEN null; 82 | END $$; 83 | --> statement-breakpoint 84 | DO $$ BEGIN 85 | ALTER TABLE "user_folders" ADD CONSTRAINT "user_folders_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE no action ON UPDATE no action; 86 | EXCEPTION 87 | WHEN duplicate_object THEN null; 88 | END $$; 89 | --> statement-breakpoint 90 | CREATE INDEX IF NOT EXISTS "thread_id_idx" ON "emails" USING btree ("thread_id");--> statement-breakpoint 91 | CREATE INDEX IF NOT EXISTS "sender_id_idx" ON "emails" USING btree ("sender_id");--> statement-breakpoint 92 | CREATE INDEX IF NOT EXISTS "recipient_id_idx" ON "emails" USING btree ("recipient_id");--> statement-breakpoint 93 | CREATE INDEX IF NOT EXISTS "sent_date_idx" ON "emails" USING btree ("sent_date");--> statement-breakpoint 94 | CREATE UNIQUE INDEX IF NOT EXISTS "email_idx" ON "users" USING btree ("email"); -------------------------------------------------------------------------------- /lib/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations } from 'drizzle-orm'; 2 | import { 3 | index, 4 | integer, 5 | pgTable, 6 | serial, 7 | text, 8 | timestamp, 9 | uniqueIndex, 10 | varchar, 11 | } from 'drizzle-orm/pg-core'; 12 | 13 | export const users = pgTable( 14 | 'users', 15 | { 16 | id: serial('id').primaryKey(), 17 | firstName: varchar('first_name', { length: 50 }), 18 | lastName: varchar('last_name', { length: 50 }), 19 | email: varchar('email', { length: 255 }).notNull(), 20 | jobTitle: varchar('job_title', { length: 100 }), 21 | company: varchar('company', { length: 100 }), 22 | location: varchar('location', { length: 100 }), 23 | twitter: varchar('twitter', { length: 100 }), 24 | linkedin: varchar('linkedin', { length: 100 }), 25 | github: varchar('github', { length: 100 }), 26 | avatarUrl: varchar('avatar_url', { length: 255 }), 27 | }, 28 | (table) => { 29 | return { 30 | emailIndex: uniqueIndex('email_idx').on(table.email), 31 | }; 32 | }, 33 | ); 34 | 35 | export const threads = pgTable('threads', { 36 | id: serial('id').primaryKey(), 37 | subject: varchar('subject', { length: 255 }), 38 | lastActivityDate: timestamp('last_activity_date').defaultNow(), 39 | }); 40 | 41 | export const emails = pgTable( 42 | 'emails', 43 | { 44 | id: serial('id').primaryKey(), 45 | threadId: integer('thread_id').references(() => threads.id), 46 | senderId: integer('sender_id').references(() => users.id), 47 | recipientId: integer('recipient_id').references(() => users.id), 48 | subject: varchar('subject', { length: 255 }), 49 | body: text('body'), 50 | sentDate: timestamp('sent_date').defaultNow(), 51 | }, 52 | (table) => { 53 | return { 54 | threadIdIndex: index('thread_id_idx').on(table.threadId), 55 | senderIdIndex: index('sender_id_idx').on(table.senderId), 56 | recipientIdIndex: index('recipient_id_idx').on(table.recipientId), 57 | sentDateIndex: index('sent_date_idx').on(table.sentDate), 58 | }; 59 | }, 60 | ); 61 | 62 | export const folders = pgTable('folders', { 63 | id: serial('id').primaryKey(), 64 | name: varchar('name', { length: 50 }).notNull(), 65 | }); 66 | 67 | export const userFolders = pgTable('user_folders', { 68 | id: serial('id').primaryKey(), 69 | userId: integer('user_id').references(() => users.id), 70 | folderId: integer('folder_id').references(() => folders.id), 71 | }); 72 | 73 | export const threadFolders = pgTable('thread_folders', { 74 | id: serial('id').primaryKey(), 75 | threadId: integer('thread_id').references(() => threads.id), 76 | folderId: integer('folder_id').references(() => folders.id), 77 | }); 78 | 79 | export const usersRelations = relations(users, ({ many }) => ({ 80 | sentEmails: many(emails, { relationName: 'sender' }), 81 | receivedEmails: many(emails, { relationName: 'recipient' }), 82 | userFolders: many(userFolders), 83 | })); 84 | 85 | export const threadsRelations = relations(threads, ({ many }) => ({ 86 | emails: many(emails), 87 | threadFolders: many(threadFolders), 88 | })); 89 | 90 | export const emailsRelations = relations(emails, ({ one }) => ({ 91 | thread: one(threads, { 92 | fields: [emails.threadId], 93 | references: [threads.id], 94 | }), 95 | sender: one(users, { 96 | fields: [emails.senderId], 97 | references: [users.id], 98 | relationName: 'sender', 99 | }), 100 | recipient: one(users, { 101 | fields: [emails.recipientId], 102 | references: [users.id], 103 | relationName: 'recipient', 104 | }), 105 | })); 106 | 107 | export const foldersRelations = relations(folders, ({ many }) => ({ 108 | userFolders: many(userFolders), 109 | threadFolders: many(threadFolders), 110 | })); 111 | 112 | export const userFoldersRelations = relations(userFolders, ({ one }) => ({ 113 | user: one(users, { fields: [userFolders.userId], references: [users.id] }), 114 | folder: one(folders, { 115 | fields: [userFolders.folderId], 116 | references: [folders.id], 117 | }), 118 | })); 119 | 120 | export const threadFoldersRelations = relations(threadFolders, ({ one }) => ({ 121 | thread: one(threads, { 122 | fields: [threadFolders.threadId], 123 | references: [threads.id], 124 | }), 125 | folder: one(folders, { 126 | fields: [threadFolders.folderId], 127 | references: [folders.id], 128 | }), 129 | })); 130 | -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as SheetPrimitive from '@radix-ui/react-dialog'; 4 | import { XIcon } from 'lucide-react'; 5 | import * as React from 'react'; 6 | 7 | import { Button } from '@/components/ui/button'; 8 | import { cn } from '@/lib/utils'; 9 | 10 | function Sheet({ ...props }: React.ComponentProps) { 11 | return ; 12 | } 13 | 14 | function SheetTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return ; 18 | } 19 | 20 | function SheetClose({ 21 | ...props 22 | }: React.ComponentProps) { 23 | return ; 24 | } 25 | 26 | function SheetPortal({ 27 | ...props 28 | }: React.ComponentProps) { 29 | return ; 30 | } 31 | 32 | function SheetOverlay({ 33 | className, 34 | ...props 35 | }: React.ComponentProps) { 36 | return ( 37 | 45 | ); 46 | } 47 | 48 | function SheetContent({ 49 | className, 50 | children, 51 | side = 'right', 52 | ...props 53 | }: React.ComponentProps & { 54 | side?: 'top' | 'right' | 'bottom' | 'left'; 55 | }) { 56 | return ( 57 | 58 | 59 | 75 | {children} 76 | 77 | 85 | 86 | 87 | 88 | ); 89 | } 90 | 91 | function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { 92 | return ( 93 |
98 | ); 99 | } 100 | 101 | function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { 102 | return ( 103 |
108 | ); 109 | } 110 | 111 | function SheetTitle({ 112 | className, 113 | ...props 114 | }: React.ComponentProps) { 115 | return ( 116 | 121 | ); 122 | } 123 | 124 | function SheetDescription({ 125 | className, 126 | ...props 127 | }: React.ComponentProps) { 128 | return ( 129 | 134 | ); 135 | } 136 | 137 | export { 138 | Sheet, 139 | SheetClose, 140 | SheetContent, 141 | SheetDescription, 142 | SheetFooter, 143 | SheetHeader, 144 | SheetTitle, 145 | SheetTrigger, 146 | }; 147 | -------------------------------------------------------------------------------- /app/components/thread-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ThreadActions } from '@/app/components/thread-actions'; 4 | import { emails, users } from '@/lib/db/schema'; 5 | import { formatEmailString } from '@/lib/utils'; 6 | import { PenSquare, Search } from 'lucide-react'; 7 | import Link from 'next/link'; 8 | import { useEffect, useState } from 'react'; 9 | import { NavMenu } from './menu'; 10 | 11 | type Email = Omit & { 12 | sender: Pick; 13 | }; 14 | type User = typeof users.$inferSelect; 15 | 16 | type ThreadWithEmails = { 17 | id: number; 18 | subject: string | null; 19 | lastActivityDate: Date | null; 20 | emails: Email[]; 21 | }; 22 | 23 | interface ThreadListProps { 24 | folderName: string; 25 | threads: ThreadWithEmails[]; 26 | searchQuery?: string; 27 | } 28 | 29 | export function ThreadHeader({ 30 | folderName, 31 | count, 32 | }: { 33 | folderName: string; 34 | count?: number | undefined; 35 | }) { 36 | return ( 37 |
38 |
39 | 40 |

41 | {folderName} 42 | {count} 43 |

44 |
45 |
46 | 50 | 51 | 52 | 56 | 57 | 58 |
59 |
60 | ); 61 | } 62 | 63 | export function ThreadList({ folderName, threads }: ThreadListProps) { 64 | const [hoveredThread, setHoveredThread] = useState(null); 65 | const [isMobile, setIsMobile] = useState(false); 66 | 67 | useEffect(() => { 68 | const checkIsMobile = () => { 69 | setIsMobile(window.matchMedia('(hover: none)').matches); 70 | }; 71 | 72 | checkIsMobile(); 73 | window.addEventListener('resize', checkIsMobile); 74 | 75 | return () => { 76 | window.removeEventListener('resize', checkIsMobile); 77 | }; 78 | }, []); 79 | 80 | const handleMouseEnter = (threadId: number) => { 81 | if (!isMobile) { 82 | setHoveredThread(threadId); 83 | } 84 | }; 85 | 86 | const handleMouseLeave = () => { 87 | if (!isMobile) { 88 | setHoveredThread(null); 89 | } 90 | }; 91 | 92 | return ( 93 |
94 | 95 |
96 | {threads.map((thread) => { 97 | const latestEmail = thread.emails[0]; 98 | 99 | return ( 100 | 105 |
handleMouseEnter(thread.id)} 108 | onMouseLeave={handleMouseLeave} 109 | > 110 |
111 |
112 | 113 | {formatEmailString(latestEmail.sender)} 114 | 115 |
116 |
117 | 118 | {thread.subject} 119 | 120 | 121 | {latestEmail.body} 122 | 123 |
124 |
125 |
126 | {!isMobile && hoveredThread === thread.id ? ( 127 | 128 | ) : ( 129 | 130 | {new Date(thread.lastActivityDate!).toLocaleDateString()} 131 | 132 | )} 133 |
134 |
135 | 136 | ); 137 | })} 138 |
139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /lib/db/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { eq } from 'drizzle-orm'; 4 | import { revalidatePath } from 'next/cache'; 5 | import { redirect } from 'next/navigation'; 6 | import { z } from 'zod'; 7 | import { db } from './drizzle'; 8 | import { emails, folders, threadFolders, threads, users } from './schema'; 9 | 10 | const sendEmailSchema = z.object({ 11 | subject: z.string().min(1, 'Subject is required'), 12 | body: z.string().min(1, 'Body is required'), 13 | recipientEmail: z.string().email('Invalid email address'), 14 | }); 15 | 16 | export async function sendEmailAction(_: any, formData: FormData) { 17 | let newThread; 18 | let rawFormData = { 19 | subject: formData.get('subject'), 20 | body: formData.get('body'), 21 | recipientEmail: formData.get('recipientEmail'), 22 | }; 23 | 24 | if (process.env.VERCEL_ENV === 'production') { 25 | return { 26 | error: 'Only works on localhost for now', 27 | previous: rawFormData, 28 | }; 29 | } 30 | 31 | try { 32 | let validatedFields = sendEmailSchema.parse({ 33 | subject: formData.get('subject'), 34 | body: formData.get('body'), 35 | recipientEmail: formData.get('recipientEmail'), 36 | }); 37 | 38 | let { subject, body, recipientEmail } = validatedFields; 39 | 40 | let [recipient] = await db 41 | .select() 42 | .from(users) 43 | .where(eq(users.email, recipientEmail)); 44 | 45 | if (!recipient) { 46 | [recipient] = await db 47 | .insert(users) 48 | .values({ email: recipientEmail }) 49 | .returning(); 50 | } 51 | 52 | let result = await db 53 | .insert(threads) 54 | .values({ 55 | subject, 56 | lastActivityDate: new Date(), 57 | }) 58 | .returning(); 59 | newThread = result[0]; 60 | 61 | await db.insert(emails).values({ 62 | threadId: newThread.id, 63 | senderId: 1, // Assuming the current user's ID is 1. Replace this with the actual user ID. 64 | recipientId: recipient.id, 65 | subject, 66 | body, 67 | sentDate: new Date(), 68 | }); 69 | 70 | let [sentFolder] = await db 71 | .select() 72 | .from(folders) 73 | .where(eq(folders.name, 'Sent')); 74 | 75 | await db.insert(threadFolders).values({ 76 | threadId: newThread.id, 77 | folderId: sentFolder.id, 78 | }); 79 | } catch (error) { 80 | if (error instanceof z.ZodError) { 81 | return { error: error.errors[0].message, previous: rawFormData }; 82 | } 83 | return { 84 | error: 'Failed to send email. Please try again.', 85 | previous: rawFormData, 86 | }; 87 | } 88 | 89 | revalidatePath('/', 'layout'); 90 | redirect(`/f/sent/${newThread.id}`); 91 | } 92 | 93 | export async function moveThreadToDone(_: any, formData: FormData) { 94 | if (process.env.VERCEL_ENV === 'production') { 95 | return { 96 | error: 'Only works on localhost for now', 97 | }; 98 | } 99 | 100 | let threadId = formData.get('threadId'); 101 | 102 | if (!threadId || typeof threadId !== 'string') { 103 | return { error: 'Invalid thread ID', success: false }; 104 | } 105 | 106 | try { 107 | let doneFolder = await db.query.folders.findFirst({ 108 | where: eq(folders.name, 'Archive'), 109 | }); 110 | 111 | if (!doneFolder) { 112 | return { error: 'Done folder not found', success: false }; 113 | } 114 | 115 | let parsedThreadId = parseInt(threadId, 10); 116 | 117 | await db 118 | .delete(threadFolders) 119 | .where(eq(threadFolders.threadId, parsedThreadId)); 120 | 121 | await db.insert(threadFolders).values({ 122 | threadId: parsedThreadId, 123 | folderId: doneFolder.id, 124 | }); 125 | 126 | revalidatePath('/f/[name]'); 127 | revalidatePath('/f/[name]/[id]'); 128 | return { success: true, error: null }; 129 | } catch (error) { 130 | console.error('Failed to move thread to Done:', error); 131 | return { success: false, error: 'Failed to move thread to Done' }; 132 | } 133 | } 134 | 135 | export async function moveThreadToTrash(_: any, formData: FormData) { 136 | if (process.env.VERCEL_ENV === 'production') { 137 | return { 138 | error: 'Only works on localhost for now', 139 | }; 140 | } 141 | 142 | let threadId = formData.get('threadId'); 143 | 144 | if (!threadId || typeof threadId !== 'string') { 145 | return { error: 'Invalid thread ID', success: false }; 146 | } 147 | 148 | try { 149 | let trashFolder = await db.query.folders.findFirst({ 150 | where: eq(folders.name, 'Trash'), 151 | }); 152 | 153 | if (!trashFolder) { 154 | return { error: 'Trash folder not found', success: false }; 155 | } 156 | 157 | let parsedThreadId = parseInt(threadId, 10); 158 | 159 | await db 160 | .delete(threadFolders) 161 | .where(eq(threadFolders.threadId, parsedThreadId)); 162 | 163 | await db.insert(threadFolders).values({ 164 | threadId: parsedThreadId, 165 | folderId: trashFolder.id, 166 | }); 167 | 168 | revalidatePath('/f/[name]'); 169 | revalidatePath('/f/[name]/[id]'); 170 | return { success: true, error: null }; 171 | } catch (error) { 172 | console.error('Failed to move thread to Trash:', error); 173 | return { success: false, error: 'Failed to move thread to Trash' }; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/db/seed.ts: -------------------------------------------------------------------------------- 1 | import { db } from './drizzle'; 2 | import { 3 | emails, 4 | folders, 5 | threadFolders, 6 | threads, 7 | userFolders, 8 | users, 9 | } from './schema'; 10 | 11 | async function seed() { 12 | console.log('Starting seed process...'); 13 | await seedUsers(); 14 | await seedFolders(); 15 | await seedThreadsAndEmails(); 16 | console.log('Seed process completed successfully.'); 17 | } 18 | 19 | async function seedUsers() { 20 | await db.insert(users).values([ 21 | { 22 | firstName: 'Lee', 23 | lastName: 'Robinson', 24 | email: 'lee@leerob.com', 25 | jobTitle: 'VP of Product', 26 | company: 'Vercel', 27 | location: 'Des Moines, Iowa', 28 | avatarUrl: 'https://github.com/leerob.png', 29 | linkedin: 'https://www.linkedin.com/in/leeerob/', 30 | twitter: 'https://x.com/leerob', 31 | github: 'https://github.com/leerob', 32 | }, 33 | { 34 | firstName: 'Guillermo', 35 | lastName: 'Rauch', 36 | email: 'rauchg@vercel.com', 37 | jobTitle: 'CEO', 38 | company: 'Vercel', 39 | location: 'San Francisco, California', 40 | avatarUrl: 'https://github.com/rauchg.png', 41 | }, 42 | { 43 | firstName: 'Delba', 44 | lastName: 'de Oliveira', 45 | email: 'delba.oliveira@vercel.com', 46 | jobTitle: 'Staff DX Engineer', 47 | company: 'Vercel', 48 | location: 'London, UK', 49 | avatarUrl: 'https://github.com/delbaoliveira.png', 50 | }, 51 | { 52 | firstName: 'Tim', 53 | lastName: 'Neutkens', 54 | email: 'tim@vercel.com', 55 | jobTitle: 'Next.js Lead', 56 | company: 'Vercel', 57 | location: 'Amsterdam, Netherlands', 58 | avatarUrl: 'https://github.com/timneutkens.png', 59 | }, 60 | ]); 61 | } 62 | 63 | async function seedFolders() { 64 | await db 65 | .insert(folders) 66 | .values([ 67 | { name: 'Inbox' }, 68 | { name: 'Flagged' }, 69 | { name: 'Sent' }, 70 | { name: 'Archive' }, 71 | { name: 'Spam' }, 72 | { name: 'Trash' }, 73 | ]); 74 | 75 | const userFolderValues = []; 76 | for (let userId = 1; userId <= 4; userId++) { 77 | for (let folderId = 1; folderId <= 6; folderId++) { 78 | userFolderValues.push({ userId, folderId }); 79 | } 80 | } 81 | await db.insert(userFolders).values(userFolderValues); 82 | } 83 | 84 | async function seedThreadsAndEmails() { 85 | // Thread 1: Guillermo talking about Vercel customer feedback 86 | const thread1 = await db 87 | .insert(threads) 88 | .values({ 89 | subject: 'Vercel Customer Feedback', 90 | lastActivityDate: new Date('2023-05-15T10:00:00'), 91 | }) 92 | .returning(); 93 | 94 | await db.insert(emails).values([ 95 | { 96 | threadId: thread1[0].id, 97 | senderId: 2, // Guillermo 98 | recipientId: 1, // Lee 99 | subject: 'Vercel Customer Feedback', 100 | body: 'Met with Daniel today. He had some great feedback. After you make a change to your environment variables, he wants to immediately redeploy the application. We should make a toast that has a CTA to redeploy. Thoughts?', 101 | sentDate: new Date('2023-05-15T10:00:00'), 102 | }, 103 | { 104 | threadId: thread1[0].id, 105 | senderId: 1, // Lee 106 | recipientId: 2, // Guillermo 107 | subject: 'Re: Vercel Customer Feedback', 108 | body: "Good call. I've seen this multiple times now. Let's do it.", 109 | sentDate: new Date('2023-05-15T11:30:00'), 110 | }, 111 | { 112 | threadId: thread1[0].id, 113 | senderId: 2, // Guillermo 114 | recipientId: 1, // Lee 115 | subject: 'Re: Vercel Customer Feedback', 116 | body: "Amazing. Let me know when it shipped and I'll follow up.", 117 | sentDate: new Date('2023-05-15T13:45:00'), 118 | }, 119 | ]); 120 | 121 | // Thread 2: Delba talking about Next.js and testing out new features 122 | const thread2 = await db 123 | .insert(threads) 124 | .values({ 125 | subject: 'New Next.js RFC', 126 | lastActivityDate: new Date('2023-05-16T09:00:00'), 127 | }) 128 | .returning(); 129 | 130 | await db.insert(emails).values([ 131 | { 132 | threadId: thread2[0].id, 133 | senderId: 3, // Delba 134 | recipientId: 1, // Lee 135 | subject: 'New Next.js RFC', 136 | body: "I'm working on the first draft of the Dynamic IO docs and examples. Do you want to take a look?", 137 | sentDate: new Date('2023-05-16T09:00:00'), 138 | }, 139 | { 140 | threadId: thread2[0].id, 141 | senderId: 1, // Lee 142 | recipientId: 3, // Delba 143 | subject: 'Re: New Next.js RFC', 144 | body: "Absolutely. Let me take a look later tonight and I'll send over feedback.", 145 | sentDate: new Date('2023-05-16T10:15:00'), 146 | }, 147 | { 148 | threadId: thread2[0].id, 149 | senderId: 3, // Delba 150 | recipientId: 1, // Lee 151 | subject: 'Re: New Next.js RFC', 152 | body: 'Thank you!', 153 | sentDate: new Date('2023-05-16T11:30:00'), 154 | }, 155 | ]); 156 | 157 | // Thread 3: Tim with steps to test out Turbopack 158 | const thread3 = await db 159 | .insert(threads) 160 | .values({ 161 | subject: 'Turbopack Testing', 162 | lastActivityDate: new Date('2023-05-17T14:00:00'), 163 | }) 164 | .returning(); 165 | 166 | await db.insert(emails).values([ 167 | { 168 | threadId: thread3[0].id, 169 | senderId: 4, // Tim 170 | recipientId: 1, // Lee 171 | subject: 'Turbopack Testing Steps', 172 | body: `Hi Lee, 173 | 174 | Here are the steps to test out Turbopack: 175 | 176 | 1. npx create-next-app@canary 177 | 2. Select Turbopack when prompted 178 | 3. Run 'npm install' to install dependencies 179 | 4. Start the development server with 'npm run dev -- --turbo' 180 | 5. That's it! 181 | 182 | Let me know if you encounter any issues or have any questions. 183 | 184 | Best, 185 | Tim`, 186 | sentDate: new Date('2023-05-17T14:00:00'), 187 | }, 188 | ]); 189 | 190 | // Add threads to folders 191 | await db.insert(threadFolders).values([ 192 | { threadId: thread1[0].id, folderId: 1 }, // Inbox 193 | { threadId: thread2[0].id, folderId: 1 }, // Inbox 194 | { threadId: thread3[0].id, folderId: 1 }, // Inbox 195 | ]); 196 | } 197 | 198 | seed() 199 | .catch((error) => { 200 | console.error('Seed process failed:', error); 201 | process.exit(1); 202 | }) 203 | .finally(async () => { 204 | console.log('Seed process finished. Exiting...'); 205 | process.exit(0); 206 | }); 207 | -------------------------------------------------------------------------------- /app/f/[name]/new/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { LeftSidebar } from '@/app/components/left-sidebar'; 4 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; 5 | import { 6 | Tooltip, 7 | TooltipContent, 8 | TooltipProvider, 9 | TooltipTrigger, 10 | } from '@/components/ui/tooltip'; 11 | import { sendEmailAction } from '@/lib/db/actions'; 12 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 13 | import { Paperclip, Trash2 } from 'lucide-react'; 14 | import Link from 'next/link'; 15 | import { useParams } from 'next/navigation'; 16 | import { Suspense, useActionState } from 'react'; 17 | 18 | function DiscardDraftLink() { 19 | let { name } = useParams(); 20 | 21 | return ( 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | function EmailBody({ defaultValue = '' }: { defaultValue?: string }) { 29 | const handleKeyDown = (e: React.KeyboardEvent) => { 30 | if ( 31 | (e.ctrlKey || e.metaKey) && 32 | (e.key === 'Enter' || e.key === 'NumpadEnter') 33 | ) { 34 | e.preventDefault(); 35 | e.currentTarget.form?.requestSubmit(); 36 | } 37 | }; 38 | 39 | return ( 40 |
41 |