├── .gitignore ├── .prettierrc ├── README.md ├── app ├── components │ ├── left-sidebar.tsx │ ├── menu.tsx │ ├── right-sidebar.tsx │ ├── search.tsx │ ├── thread-actions.tsx │ ├── thread-list.tsx │ └── welcome-toast.tsx ├── f │ └── [name] │ │ ├── [id] │ │ ├── loading.tsx │ │ └── page.tsx │ │ ├── new │ │ └── page.tsx │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── search │ └── page.tsx ├── components.json ├── components └── ui │ ├── alert.tsx │ ├── button.tsx │ ├── sheet.tsx │ └── tooltip.tsx ├── drizzle.config.ts ├── lib ├── db │ ├── actions.ts │ ├── drizzle.ts │ ├── migrate.ts │ ├── migrations │ │ ├── 0000_warm_warbird.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ ├── queries.ts │ ├── schema.ts │ ├── seed.ts │ └── setup.ts └── utils.tsx ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── github.svg ├── linkedin.svg ├── placeholder.svg └── x.svg └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | 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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 |