├── .env.example ├── .env.local.example ├── .gitignore ├── README.md ├── app ├── _examples │ ├── client-component │ │ └── page.tsx │ ├── protected-route │ │ └── page.tsx │ ├── route-handler │ │ └── route.ts │ ├── server-action │ │ └── page.tsx │ └── server-component │ │ └── page.tsx ├── account │ └── page.tsx ├── auth-button.tsx ├── auth │ └── callback │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── login │ └── page.tsx ├── mailbox │ └── page.tsx ├── members │ └── page.tsx ├── opengraph-image.tsx ├── page.tsx └── send-email │ └── route.ts ├── components.json ├── components ├── account.tsx ├── add-member.tsx ├── edit-member.tsx ├── email-composer.tsx ├── icons.tsx ├── main-nav.tsx ├── members-table.tsx ├── new-message.tsx ├── secondary-nav.tsx ├── site-header.tsx ├── styles.css ├── tailwind-indicator.tsx ├── theme-provider.tsx ├── theme-toggle.tsx ├── tiptap.js ├── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── table.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── tooltip.tsx │ └── use-toast.ts └── user-nav.tsx ├── config └── site.ts ├── lib └── utils.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── rebase.code-workspace ├── supabase ├── .gitignore ├── config.toml ├── migrations │ ├── 20230716202819_remote_commit.sql │ ├── 20230716203001_remote_commit.sql │ ├── 20230819040448_create_emails_table.sql │ ├── 20230820053817_create_member_groupings_table.sql │ ├── 20230820054446_add_group_id_to_members_table.sql │ ├── 20230820060355_create_member_group_join.sql │ ├── 20230906225600_created_by_group.sql │ ├── 20230907000406_alter_member_groups.sql │ ├── 20230907015108_edit-group-table.sql │ └── 20230907020105_group-references-profiles.sql └── seed.sql ├── tailwind.config.js ├── tsconfig.json └── types ├── nav.ts └── supabase.ts /.env.example: -------------------------------------------------------------------------------- 1 | SITE_URL="" 2 | SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID="" 3 | SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET="" 4 | NEXT_PUBLIC_RESEND_API_KEY="" -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL="" 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key" -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env.example 38 | .env.local.example 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rebase is an open-source email platform purpose-built for sending investor updates. As a founder, I send monthly update emails to my investors to share what I've been up to using a product called [Cabal](https://getcabal.com). I’m a huge fan of their product, so I wanted to try to build my own from scratch. 2 | 3 | Please note that the product is only designed for verified domains. If you want to use it, you'll need to register a domain and verify it with [Resend](https://resend.com) (see below). 4 | 5 | ## Getting Started 6 | 7 | 1. Clone the repository `git clone https://github.com/alanagoyal/rebase` 8 | 2. Use `cd` to change into the app's directory 9 | 3. Run `npm install` to install dependencies 10 | 4. Rename `.env.example` to `.env` and update the API keys. Rename `.env.local.example` to `.env.local` and do the same. 11 | 12 | ## Supabase 13 | 14 | This project uses [Supabase](https://supabase.com) to store users, members, and groups. Follow [these instructions](https://supabase.com/docs/guides/getting-started/local-development) to apply the migration and get started with your own project. 15 | 16 | ## Resend 17 | 18 | This project uses [Resend](https://resend.com) to send the emails. You can sign up for a free account, register a domain, and paste the API key into `.env` to start sending emails. 19 | 20 | ## Deploy 21 | 22 | Deploy using [Vercel](https://vercel.com). 23 | -------------------------------------------------------------------------------- /app/_examples/client-component/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | // TODO: Duplicate or move this file outside the `_examples` folder to make it a route 4 | 5 | import { createClientComponentClient } from '@supabase/auth-helpers-nextjs' 6 | import { useEffect, useState } from 'react' 7 | 8 | export default function ClientComponent() { 9 | const [todos, setTodos] = useState([]) 10 | 11 | // Create a Supabase client configured to use cookies 12 | const supabase = createClientComponentClient() 13 | 14 | useEffect(() => { 15 | const getTodos = async () => { 16 | // This assumes you have a `todos` table in Supabase. Check out 17 | // the `Create Table and seed with data` section of the README 👇 18 | // https://github.com/vercel/next.js/blob/canary/examples/with-supabase/README.md 19 | const { data } = await supabase.from('todos').select() 20 | if (data) { 21 | setTodos(data) 22 | } 23 | } 24 | 25 | getTodos() 26 | }, [supabase, setTodos]) 27 | 28 | return
{JSON.stringify(todos, null, 2)}
29 | } 30 | -------------------------------------------------------------------------------- /app/_examples/protected-route/page.tsx: -------------------------------------------------------------------------------- 1 | // TODO: Duplicate or move this file outside the `_examples` folder to make it a route 2 | 3 | import { 4 | createServerActionClient, 5 | createServerComponentClient, 6 | } from "@supabase/auth-helpers-nextjs"; 7 | import { cookies } from "next/headers"; 8 | import Image from "next/image"; 9 | import { redirect } from "next/navigation"; 10 | 11 | export default async function ProtectedRoute() { 12 | const supabase = createServerComponentClient({ cookies }); 13 | 14 | const { 15 | data: { user }, 16 | } = await supabase.auth.getUser(); 17 | 18 | if (!user) { 19 | // This route can only be accessed by authenticated users. 20 | // Unauthenticated users will be redirected to the `/login` route. 21 | redirect("/login"); 22 | } 23 | 24 | const signOut = async () => { 25 | "use server"; 26 | const supabase = createServerActionClient({ cookies }); 27 | await supabase.auth.signOut(); 28 | redirect("/login"); 29 | }; 30 | 31 | return ( 32 |
33 |

34 | Supabase and Next.js Starter Template 35 |

36 | 37 |
38 |
39 | 40 | Protected page 41 | 42 | 43 | Hey, {user.email}! {" "} 44 |
45 | 46 |
47 |
48 |
49 |
50 | 51 |
52 | Supabase Logo 59 |
60 | Vercel Logo 67 |
68 | 69 |

70 | The fastest way to get started building apps with{" "} 71 | Supabase and Next.js 72 |

73 | 74 |
75 | 76 | Get started by editing app/page.tsx 77 | 78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /app/_examples/route-handler/route.ts: -------------------------------------------------------------------------------- 1 | // TODO: Duplicate or move this file outside the `_examples` folder to make it a route 2 | 3 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' 4 | import { cookies } from 'next/headers' 5 | import { NextResponse } from 'next/server' 6 | 7 | export async function GET() { 8 | // Create a Supabase client configured to use cookies 9 | const supabase = createRouteHandlerClient({ cookies }) 10 | 11 | // This assumes you have a `todos` table in Supabase. Check out 12 | // the `Create Table and seed with data` section of the README 👇 13 | // https://github.com/vercel/next.js/blob/canary/examples/with-supabase/README.md 14 | const { data: todos } = await supabase.from('todos').select() 15 | 16 | return NextResponse.json(todos) 17 | } 18 | -------------------------------------------------------------------------------- /app/_examples/server-action/page.tsx: -------------------------------------------------------------------------------- 1 | // TODO: Duplicate or move this file outside the `_examples` folder to make it a route 2 | 3 | import { createServerActionClient } from '@supabase/auth-helpers-nextjs' 4 | import { revalidatePath } from 'next/cache' 5 | import { cookies } from 'next/headers' 6 | 7 | export default async function ServerAction() { 8 | const addTodo = async (formData: FormData) => { 9 | 'use server' 10 | const title = formData.get('title') 11 | 12 | if (title) { 13 | // Create a Supabase client configured to use cookies 14 | const supabase = createServerActionClient({ cookies }) 15 | 16 | // This assumes you have a `todos` table in Supabase. Check out 17 | // the `Create Table and seed with data` section of the README 👇 18 | // https://github.com/vercel/next.js/blob/canary/examples/with-supabase/README.md 19 | await supabase.from('todos').insert({ title }) 20 | revalidatePath('/server-action-example') 21 | } 22 | } 23 | 24 | return ( 25 |
26 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/_examples/server-component/page.tsx: -------------------------------------------------------------------------------- 1 | // TODO: Duplicate or move this file outside the `_examples` folder to make it a route 2 | 3 | import { createServerComponentClient } from '@supabase/auth-helpers-nextjs' 4 | import { cookies } from 'next/headers' 5 | 6 | export default async function ServerComponent() { 7 | // Create a Supabase client configured to use cookies 8 | const supabase = createServerComponentClient({ cookies }) 9 | 10 | // This assumes you have a `todos` table in Supabase. Check out 11 | // the `Create Table and seed with data` section of the README 👇 12 | // https://github.com/vercel/next.js/blob/canary/examples/with-supabase/README.md 13 | const { data: todos } = await supabase.from('todos').select() 14 | 15 | return
{JSON.stringify(todos, null, 2)}
16 | } 17 | -------------------------------------------------------------------------------- /app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import AccountForm from "@/components/account"; 2 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; 3 | import { cookies } from "next/headers"; 4 | 5 | export default async function Account() { 6 | const supabase = createServerComponentClient({ cookies }); 7 | 8 | const { 9 | data: { user }, 10 | } = await supabase.auth.getUser(); 11 | const { 12 | data: userData, 13 | error, 14 | status, 15 | } = await supabase.from("profiles").select("*").eq("id", user?.id).single(); 16 | const name = userData?.full_name; 17 | const email = userData?.email; 18 | 19 | return ( 20 |
21 |

Your Account

22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/auth-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button, buttonVariants } from "@/components/ui/button"; 4 | import { siteConfig } from "@/config/site"; 5 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; 6 | import Link from "next/link"; 7 | 8 | export default function AuthButton() { 9 | const supabase = createClientComponentClient(); 10 | 11 | async function signInWithGoogle() { 12 | const { data, error } = await supabase.auth.signInWithOAuth({ 13 | provider: "google", 14 | options: { 15 | redirectTo: `${location.origin}/auth/callback`, 16 | }, 17 | }); 18 | } 19 | 20 | return ( 21 |
22 | 31 | 35 | View on Github 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 2 | import { cookies } from "next/headers"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET(request: Request) { 6 | // The `/auth/callback` route is required for the server-side auth flow implemented 7 | // by the Auth Helpers package. It exchanges an auth code for the user's session. 8 | // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange 9 | const requestUrl = new URL(request.url); 10 | const code = requestUrl.searchParams.get("code"); 11 | 12 | if (code) { 13 | const supabase = createRouteHandlerClient({ cookies }); 14 | await supabase.auth.exchangeCodeForSession(code); 15 | } 16 | 17 | // URL to redirect to after sign in process completes 18 | return NextResponse.redirect(`${requestUrl.origin}/account`); 19 | } 20 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/rebase/67c0677c028fca7ba6a7f72612bff904f5cad36a/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --border: 214.3 31.8% 91.4%; 20 | --input: 214.3 31.8% 91.4%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 222.2 84% 4.9%; 41 | --foreground: 210 40% 98%; 42 | 43 | --muted: 217.2 32.6% 17.5%; 44 | --muted-foreground: 215 20.2% 65.1%; 45 | 46 | --popover: 222.2 84% 4.9%; 47 | --popover-foreground: 210 40% 98%; 48 | 49 | --card: 222.2 84% 4.9%; 50 | --card-foreground: 210 40% 98%; 51 | 52 | --border: 217.2 32.6% 17.5%; 53 | --input: 217.2 32.6% 17.5%; 54 | 55 | --primary: 210 40% 98%; 56 | --primary-foreground: 222.2 47.4% 11.2%; 57 | 58 | --secondary: 217.2 32.6% 17.5%; 59 | --secondary-foreground: 210 40% 98%; 60 | 61 | --accent: 217.2 32.6% 17.5%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 85.7% 97.3%; 66 | 67 | --ring: 217.2 32.6% 17.5%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | 80 | h1 { 81 | font-size: 24px; 82 | } 83 | 84 | h2 { 85 | font-size: 20px; 86 | } 87 | 88 | h3 { 89 | font-size: 18px; 90 | } 91 | 92 | h4 { 93 | font-size: 16px; 94 | } 95 | 96 | .banner { 97 | background-color: #9face6; 98 | color: white; 99 | padding: 10px; 100 | text-align: center; 101 | } 102 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@/components/theme-provider"; 2 | import "./globals.css"; 3 | import { SiteHeader } from "@/components/site-header"; 4 | import { Toaster } from "@/components/ui/toaster"; 5 | import { TailwindIndicator } from "@/components/tailwind-indicator"; 6 | import { Metadata } from "next"; 7 | import { siteConfig } from "@/config/site"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | 11 | export const metadata: Metadata = { 12 | title: { 13 | default: siteConfig.name, 14 | template: `%s | ${siteConfig.name}`, 15 | }, 16 | description: siteConfig.description, 17 | themeColor: [ 18 | { media: "(prefers-color-scheme: light)", color: "white" }, 19 | { media: "(prefers-color-scheme: dark)", color: "black" }, 20 | ], 21 | icons: { 22 | icon: "/favicon.ico", 23 | apple: "/apple-touch-icon.png", 24 | }, 25 | openGraph: { 26 | type: "website", 27 | locale: "en_US", 28 | url: siteConfig.url, 29 | title: siteConfig.name, 30 | description: siteConfig.description, 31 | siteName: siteConfig.name, 32 | }, 33 | twitter: { 34 | card: "summary_large_image", 35 | site: "@alanaagoyal", 36 | creator: "@alanaagoyal", 37 | title: siteConfig.name, 38 | description: siteConfig.description, 39 | images: siteConfig.ogImage, 40 | }, 41 | }; 42 | 43 | export default function RootLayout({ 44 | children, 45 | }: { 46 | children: React.ReactNode; 47 | }) { 48 | return ( 49 | 50 | 51 | 52 |
53 | 58 | 59 | 60 | {children} 61 |
62 |
63 | 64 |
65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { useRouter } from 'next/navigation' 5 | import { createClientComponentClient } from '@supabase/auth-helpers-nextjs' 6 | import Link from 'next/link' 7 | 8 | export default function Login() { 9 | const [email, setEmail] = useState('') 10 | const [password, setPassword] = useState('') 11 | const [view, setView] = useState('sign-in') 12 | const router = useRouter() 13 | const supabase = createClientComponentClient() 14 | 15 | const handleSignUp = async (e: React.FormEvent) => { 16 | e.preventDefault() 17 | await supabase.auth.signUp({ 18 | email, 19 | password, 20 | options: { 21 | emailRedirectTo: `${location.origin}/auth/callback`, 22 | }, 23 | }) 24 | setView('check-email') 25 | } 26 | 27 | const handleSignIn = async (e: React.FormEvent) => { 28 | e.preventDefault() 29 | await supabase.auth.signInWithPassword({ 30 | email, 31 | password, 32 | }) 33 | router.push('/') 34 | router.refresh() 35 | } 36 | 37 | return ( 38 |
39 | 43 | 55 | 56 | {' '} 57 | Back 58 | 59 | {view === 'check-email' ? ( 60 |

61 | Check {email} to continue signing 62 | up 63 |

64 | ) : ( 65 |
69 | 72 | setEmail(e.target.value)} 76 | value={email} 77 | placeholder="you@example.com" 78 | /> 79 | 82 | setPassword(e.target.value)} 87 | value={password} 88 | placeholder="••••••••" 89 | /> 90 | {view === 'sign-in' && ( 91 | <> 92 | 95 |

96 | Don't have an account? 97 | 103 |

104 | 105 | )} 106 | {view === 'sign-up' && ( 107 | <> 108 | 111 |

112 | Already have an account? 113 | 119 |

120 | 121 | )} 122 |
123 | )} 124 |
125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /app/mailbox/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // This is a client component 👈🏽 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Tiptap from "@/components/tiptap"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | DialogTrigger, 13 | } from "@/components/ui/dialog"; 14 | import { 15 | Form, 16 | FormControl, 17 | FormField, 18 | FormItem, 19 | FormLabel, 20 | } from "@/components/ui/form"; 21 | import { cookies } from "next/headers"; 22 | import { zodResolver } from "@hookform/resolvers/zod"; 23 | import { Input } from "@/components/ui/input"; 24 | import { 25 | createClientComponentClient, 26 | createServerActionClient, 27 | createServerComponentClient, 28 | } from "@supabase/auth-helpers-nextjs"; 29 | import React, { useEffect, useState } from "react"; 30 | 31 | import { Controller, useForm } from "react-hook-form"; 32 | 33 | import * as z from "zod"; 34 | import EmailComposer from "@/components/email-composer"; 35 | 36 | export default async function Mailbox() { 37 | const supabase = createClientComponentClient(); 38 | 39 | const [name, setName] = useState(null); 40 | const [email, setEmail] = useState(null); 41 | 42 | const { 43 | data: { user }, 44 | } = await supabase.auth.getUser(); 45 | 46 | const [isTiptapOpen, setIsTiptapOpen] = React.useState(false); 47 | 48 | const handleNewMessageClick = () => { 49 | setIsTiptapOpen(true); 50 | }; 51 | 52 | const handleSend = () => { 53 | setIsTiptapOpen(false); 54 | }; 55 | 56 | if (!user) { 57 | return
Loading...
; 58 | } 59 | 60 | return ( 61 |
62 |
63 |

Mailbox

64 |
65 |
66 | 67 | 68 | 69 | 70 | 71 | 77 | 78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /app/members/page.tsx: -------------------------------------------------------------------------------- 1 | import AddMemberForm from "@/components/add-member"; 2 | import { MembersTable } from "@/components/members-table"; 3 | import NewMessage from "@/components/new-message"; 4 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; 5 | import { cookies } from "next/headers"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export default async function Members() { 9 | const supabase = createServerComponentClient({ cookies }); 10 | const { 11 | data: { user }, 12 | } = await supabase.auth.getUser(); 13 | 14 | if (!user) { 15 | redirect("/"); 16 | } 17 | 18 | const { 19 | data: members, 20 | error, 21 | status, 22 | } = await supabase.from("members").select("*").eq("created_by", user.id); 23 | 24 | // Fetch group information 25 | const { data: groupMappings, error: groupError } = await supabase 26 | .from("member_group_joins") 27 | .select("member_id, group_id"); 28 | 29 | if (error || groupError) { 30 | console.error("Error fetching data:", error || groupError); 31 | } 32 | 33 | // Create a mapping of member IDs to an array of group IDs 34 | const memberGroupInfo: Record = {}; 35 | groupMappings?.forEach((groupJoin) => { 36 | const memberId = groupJoin.member_id; 37 | const groupId = groupJoin.group_id; 38 | if (!memberGroupInfo[memberId]) { 39 | memberGroupInfo[memberId] = []; 40 | } 41 | memberGroupInfo[memberId].push(groupId); 42 | }); 43 | 44 | type GroupName = { 45 | id: string; 46 | name: string; 47 | }; 48 | let groupNamesData: any = []; 49 | 50 | if (groupMappings) { 51 | const groupIds: string[] = groupMappings.map((mapping) => mapping.group_id); 52 | const { data: groupNamesResponse, error: groupNamesError } = await supabase 53 | .from("member_groups") 54 | .select("id, name") 55 | .in("id", groupIds); 56 | 57 | if (groupNamesResponse) { 58 | groupNamesData = groupNamesResponse; 59 | } else { 60 | groupNamesData = []; 61 | } 62 | } 63 | 64 | return ( 65 |
66 |
67 |

Members

68 |
69 | 70 | 71 |
72 |
73 |
74 | 80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /app/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | 3 | export default async function handler() { 4 | return new ImageResponse( 5 | ( 6 |
19 | Re:base 📧 20 |
21 | ), 22 | { 23 | width: 1200, 24 | height: 630, 25 | // Supported options: 'twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent' and 'fluentFlat' 26 | // Default to 'twemoji' 27 | emoji: "twemoji", 28 | } 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site"; 2 | import AuthButton from "./auth-button"; 3 | 4 | export default function Index() { 5 | return ( 6 |
7 |
8 |
9 |
10 |

11 | {siteConfig.name} 12 |

13 |

14 | {siteConfig.description} 15 |

16 |
17 | 18 |
19 |
20 | 21 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/send-email/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 2 | import { cookies } from "next/headers"; 3 | import { NextResponse } from "next/server"; 4 | import { Resend } from "resend"; 5 | 6 | interface CreateEmailResponse { 7 | statusCode: number; 8 | message: string; 9 | } 10 | const resend = new Resend(process.env.NEXT_PUBLIC_RESEND_API_KEY); 11 | 12 | export async function POST(req: Request, res: NextResponse) { 13 | try { 14 | const body = await req.json(); 15 | const payload = { 16 | from: body.from_email, 17 | to: body.to_emails, 18 | subject: body.subject, 19 | html: body.body, 20 | }; 21 | 22 | const emailData = await resend.emails.send(payload); 23 | 24 | return NextResponse.json(emailData); 25 | } catch (error) { 26 | return NextResponse.json({ error }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/account.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import * as z from "zod"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | } from "@/components/ui/form"; 15 | import { Input } from "@/components/ui/input"; 16 | import { toast } from "@/components/ui/use-toast"; 17 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; 18 | 19 | const accountFormSchema = z.object({ 20 | email: z.string().optional(), 21 | name: z.string().optional(), 22 | }); 23 | 24 | type AccountFormValues = z.infer; 25 | 26 | export default function AccountForm({ 27 | user, 28 | name, 29 | email, 30 | }: { 31 | user: { id: string }; 32 | name: string; 33 | email: string; 34 | }) { 35 | const supabase = createClientComponentClient(); 36 | const form = useForm({ 37 | resolver: zodResolver(accountFormSchema), 38 | defaultValues: { 39 | email: email || "", 40 | name: name || "", 41 | }, 42 | }); 43 | 44 | async function onSubmit(data: AccountFormValues) { 45 | try { 46 | const updates = { 47 | email: data.email, 48 | full_name: data.name, 49 | }; 50 | 51 | let { error } = await supabase 52 | .from("profiles") 53 | .update(updates) 54 | .eq("id", user.id); 55 | if (error) { 56 | toast({ 57 | description: "An error occurred while updating your profile", 58 | }); 59 | } else { 60 | toast({ 61 | description: "Your profile has been updated", 62 | }); 63 | } 64 | } catch (error) { 65 | toast({ 66 | description: "An error occurred while updating your profile", 67 | }); 68 | } 69 | } 70 | 71 | return ( 72 |
73 |
74 | 75 | {" "} 76 | ( 80 | 81 |
82 | Email 83 |
84 | 85 | 86 | 87 |
88 | )} 89 | /> 90 | ( 94 | 95 |
96 | Name 97 |
98 | 99 | 100 | 101 |
102 | )} 103 | /> 104 |
105 | 111 |
112 | 113 | 114 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /components/add-member.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import * as z from "zod"; 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogDescription, 10 | DialogFooter, 11 | DialogHeader, 12 | DialogTitle, 13 | DialogTrigger, 14 | } from "@/components/ui/dialog"; 15 | import { Button } from "@/components/ui/button"; 16 | import { 17 | Form, 18 | FormControl, 19 | FormField, 20 | FormItem, 21 | FormLabel, 22 | } from "@/components/ui/form"; 23 | import { Input } from "@/components/ui/input"; 24 | import { toast } from "@/components/ui/use-toast"; 25 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; 26 | import React from "react"; 27 | import { useRouter } from "next/navigation"; 28 | import CreatableSelect from "react-select/creatable"; 29 | import { MultiValue } from "react-select"; 30 | import { User } from "lucide-react"; 31 | 32 | const memberFormSchema = z.object({ 33 | email: z 34 | .string({ 35 | required_error: "Please select an email to display.", 36 | }) 37 | .email(), 38 | first_name: z.string().optional(), 39 | last_name: z.string().optional(), 40 | }); 41 | 42 | type MemberFormValues = z.infer; 43 | 44 | export default function AddMemberForm({ user, setUser }: { user: any }) { 45 | const supabase = createClientComponentClient(); 46 | const [memberGroups, setMemberGroups] = React.useState([]); // State to store member groups 47 | const [selectedGroups, setSelectedGroups] = 48 | React.useState | null>(null); // State to track selected group 49 | const [open, setOpen] = React.useState(false); 50 | const [submitted, setSubmitted] = React.useState(false); 51 | const router = useRouter(); 52 | const form = useForm({ 53 | resolver: zodResolver(memberFormSchema), 54 | defaultValues: { 55 | email: "", 56 | first_name: "", 57 | last_name: "", 58 | }, 59 | }); 60 | 61 | React.useEffect(() => { 62 | async function fetchMemberGroups() { 63 | const { data, error } = await supabase 64 | .from("member_groups") 65 | .select() 66 | .eq("created_by", user.id); 67 | if (error) { 68 | console.error("Error fetching member groups:", error); 69 | } else { 70 | setSelectedGroups(null); 71 | setMemberGroups(data); 72 | } 73 | } 74 | fetchMemberGroups(); 75 | }, [supabase, submitted]); 76 | 77 | interface MemberGroup { 78 | id: number; 79 | name: string; 80 | } 81 | 82 | async function onSubmit(data: MemberFormValues) { 83 | try { 84 | // Get the IDs of the selected groups that already exist in memberGroups 85 | const existingGroupIds = selectedGroups 86 | ?.filter((group) => 87 | memberGroups?.map(({ name }) => name).includes(group.value) 88 | ) 89 | .map( 90 | (group) => memberGroups.find(({ name }) => name === group.value)?.id 91 | ); 92 | 93 | // let groupIds = []; 94 | // groupIds = groupIds.concat(existingGroupIds); 95 | 96 | // Add the IDs of the existing groups to groupIds 97 | // Check if the selectedGroups exists in memberGroups 98 | const newGroups = 99 | selectedGroups 100 | ?.filter( 101 | (group) => 102 | !memberGroups?.map(({ name }) => name).includes(group.value) 103 | ) 104 | .map(({ value }) => value) || []; 105 | 106 | // groupIds = selectedGroups?.map((group) => group.value); 107 | 108 | // If selectedGroups doesn't exist, create a new group 109 | let groupIds = []; 110 | if (!!newGroups.length) { 111 | const groupResponse = await supabase 112 | .from("member_groups") 113 | .insert(newGroups.map((name) => ({ name, created_by: user.id }))) 114 | .select(); 115 | const { data: createdGroups, error: createGroupError } = groupResponse; 116 | if (createGroupError) { 117 | console.error("Error creating new group:", createGroupError); 118 | } else { 119 | if (Array.isArray(createdGroups) && createdGroups.length > 0) { 120 | groupIds = createdGroups.map(({ id }) => id); 121 | } 122 | } 123 | } 124 | 125 | // Create the new member 126 | const memberUpdates = { 127 | email: data.email, 128 | first_name: data.first_name, 129 | last_name: data.last_name, 130 | created_by: user?.id, 131 | }; 132 | 133 | // Insert the new member and get the member_id 134 | const { data: newMember, error: memberError } = await supabase 135 | .from("members") 136 | .insert([memberUpdates]) 137 | .select(); 138 | if (memberError) { 139 | throw memberError; 140 | } 141 | const memberID = newMember[0].id; 142 | 143 | // Update the joins table to associate the member with the group 144 | if (!!selectedGroups?.length && memberID) { 145 | const joinUpdates = memberGroups 146 | ?.filter((memberGroup) => 147 | selectedGroups 148 | .map((selectedGroup) => selectedGroup.value) 149 | .includes(memberGroup.name) 150 | ) 151 | .map(({ id }) => id) 152 | .concat(groupIds) 153 | .map((memberGroupId) => ({ 154 | member_id: memberID, 155 | group_id: memberGroupId, 156 | })); 157 | 158 | const { error: joinError } = await supabase 159 | .from("member_group_joins") 160 | .insert(joinUpdates); 161 | if (joinError) { 162 | console.error("Error updating member_group_joins:", joinError); 163 | } 164 | } 165 | form.reset(); 166 | setSelectedGroups(null); 167 | 168 | toast({ 169 | description: "Your member has been added", 170 | }); 171 | setSubmitted(true); 172 | router.refresh(); 173 | } catch (error) {} 174 | } 175 | 176 | return ( 177 |
178 | 179 | 180 | 185 | 186 | 187 | 188 | New Member 189 | 190 | Please enter the first name, last name, and email 191 | 192 | 193 |
194 |
195 | 199 | {" "} 200 | ( 204 | 205 |
206 | Email 207 |
208 | 209 | 210 | 211 |
212 | )} 213 | /> 214 | ( 218 | 219 |
220 | 221 | First Name 222 | 223 |
224 | 225 | 226 | 227 |
228 | )} 229 | /> 230 | ( 234 | 235 |
236 | 237 | Last Name 238 | 239 |
240 | 241 | 242 | 243 |
244 | )} 245 | /> 246 | ( 250 | 251 |
252 | Group 253 |
254 | 255 | ({ 259 | //badly typed pkg, ignore squiggly 260 | label: group.name, 261 | value: group.name, 262 | }))} // Convert memberGroups to options 263 | value={selectedGroups} 264 | onChange={(value) => { 265 | setSelectedGroups(value); 266 | }} 267 | /> 268 | 269 |
270 | )} 271 | /> 272 |
273 | 282 |
283 | 284 | 285 |
286 |
287 |
288 |
289 | ); 290 | } 291 | -------------------------------------------------------------------------------- /components/edit-member.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import * as z from "zod"; 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogDescription, 10 | DialogFooter, 11 | DialogHeader, 12 | DialogTitle, 13 | DialogTrigger, 14 | } from "@/components/ui/dialog"; 15 | import { Button } from "@/components/ui/button"; 16 | import { 17 | Form, 18 | FormControl, 19 | FormField, 20 | FormItem, 21 | FormLabel, 22 | } from "@/components/ui/form"; 23 | import { Input } from "@/components/ui/input"; 24 | import { toast } from "@/components/ui/use-toast"; 25 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; 26 | import React from "react"; 27 | import { useRouter } from "next/navigation"; 28 | import { Edit } from "lucide-react"; 29 | import CreatableSelect from "react-select/creatable"; 30 | import { useEffect } from "react"; 31 | import { MultiValue } from "react-select"; 32 | 33 | const memberFormSchema = z.object({ 34 | email: z 35 | .string({ 36 | required_error: "Please select an email to display.", 37 | }) 38 | .email(), 39 | first_name: z.string().optional(), 40 | last_name: z.string().optional(), 41 | }); 42 | 43 | type MemberFormValues = z.infer; 44 | 45 | interface MemberGroup { 46 | id: number; 47 | name: string; 48 | } 49 | 50 | export default function EditMemberForm({ 51 | user, 52 | member, 53 | existingGroups, 54 | }: { 55 | member: { id: string; email: string; first_name: string; last_name: string }; 56 | existingGroups: string[]; 57 | user: any; 58 | }) { 59 | //track all the groups 60 | const [memberGroups, setMemberGroups] = React.useState([]); 61 | 62 | const [open, setOpen] = React.useState(false); 63 | const supabase = createClientComponentClient(); 64 | const router = useRouter(); 65 | const form = useForm({ 66 | resolver: zodResolver(memberFormSchema), 67 | defaultValues: { 68 | email: member.email || "", 69 | first_name: member.first_name || "", 70 | last_name: member.last_name || "", 71 | }, 72 | }); 73 | 74 | // TODO: Can probably remove this later and do the fetching once 75 | React.useEffect(() => { 76 | async function fetchMemberGroups() { 77 | const { data, error } = await supabase 78 | .from("member_groups") 79 | .select() 80 | .eq("created_by", user.id); 81 | if (error) { 82 | console.error("Error fetching member groups:", error); 83 | } else { 84 | setMemberGroups(data); 85 | } 86 | } 87 | fetchMemberGroups(); 88 | }, [supabase]); 89 | 90 | const [selectedGroups, setSelectedGroups] = 91 | React.useState | null>(null); // State to track selected group 92 | 93 | React.useEffect(() => { 94 | // Format existingGroups into an array of objects with label and value properties 95 | const formattedGroups = existingGroups 96 | ? existingGroups.split(",").map((groupName) => ({ 97 | label: groupName, 98 | value: groupName, 99 | })) 100 | : null; 101 | 102 | // Check if any of the group names is "none" 103 | if ( 104 | formattedGroups && 105 | formattedGroups.some((group) => group.value === "None") 106 | ) { 107 | setSelectedGroups(null); // Set selectedGroups to null 108 | } else { 109 | setSelectedGroups(formattedGroups); 110 | } 111 | }, [existingGroups]); 112 | 113 | async function onSubmit(data: MemberFormValues) { 114 | const { 115 | data: { user }, 116 | } = await supabase.auth.getUser(); 117 | 118 | let groupIds = []; 119 | 120 | // Delete all existing associations for the member 121 | const { error: deleteError } = await supabase 122 | .from("member_group_joins") 123 | .delete() 124 | .eq("member_id", member.id); 125 | if (deleteError) { 126 | console.error("Error deleting existing associations:", deleteError); 127 | } 128 | 129 | // Get the IDs of the selected groups that already exist in memberGroups 130 | // Get the IDs of the selected groups that already exist in memberGroups 131 | const existingGroupIds = selectedGroups 132 | ?.filter((group) => 133 | memberGroups?.map(({ name }) => name).includes(group.value) 134 | ) 135 | .map( 136 | (group) => memberGroups.find(({ name }) => name === group.value)?.id 137 | ); 138 | 139 | // Add the IDs of the existing groups to groupIds 140 | 141 | // Save new groups - if the member group doesn't hold any of the selected groups 142 | const newGroups = 143 | selectedGroups 144 | ?.filter( 145 | (group) => 146 | !memberGroups?.map(({ name }) => name).includes(group.value) 147 | ) 148 | .map(({ value }) => value) || []; 149 | 150 | //Creating a new group if there are elements in the newgroup array 151 | if (!!newGroups.length) { 152 | const groupResponse = await supabase 153 | .from("member_groups") 154 | .insert(newGroups.map((name) => ({ name, created_by: user?.id }))) 155 | .select(); 156 | const { data: createdGroups, error: createGroupError } = groupResponse; 157 | if (createGroupError) { 158 | console.error("Error creating new group:", createGroupError); 159 | } else { 160 | if (Array.isArray(createdGroups) && createdGroups.length > 0) { 161 | groupIds = createdGroups.map(({ id }) => id); 162 | } 163 | } 164 | } 165 | 166 | try { 167 | const updates = { 168 | email: data.email, 169 | first_name: data.first_name, 170 | last_name: data.last_name, 171 | created_by: user?.id, 172 | }; 173 | 174 | const selectedGroupIds = selectedGroups 175 | ? selectedGroups.map((group) => group.value) 176 | : []; 177 | 178 | if (!!selectedGroups?.length && member.id) { 179 | // Insert new associations 180 | const joinUpdates = selectedGroups 181 | ?.map((group) => memberGroups.find((g) => g.name === group.value)?.id) 182 | .concat(groupIds) 183 | .filter((id) => id !== undefined) 184 | .map((memberGroupId) => ({ 185 | member_id: member.id, 186 | group_id: memberGroupId, 187 | })); 188 | 189 | const { error: joinError } = await supabase 190 | .from("member_group_joins") 191 | .insert(joinUpdates); 192 | if (joinError) { 193 | console.error("Error updating member_group_joins:", joinError); 194 | } 195 | } 196 | let { error } = await supabase 197 | .from("members") 198 | .update(updates) 199 | .eq("id", member.id); 200 | if (error) { 201 | toast({ 202 | description: "An error occurred while updating the member", 203 | }); 204 | } else { 205 | toast({ 206 | description: "Your member has been updated", 207 | }); 208 | router.refresh(); 209 | } 210 | } catch (error) { 211 | toast({ 212 | description: "An error occurred while updating the member", 213 | }); 214 | } 215 | } 216 | 217 | return ( 218 |
219 | 220 | 221 | 222 | 223 | 224 | 225 | Edit Member 226 | 227 | Please enter the first name, last name, and email 228 | 229 | 230 |
231 |
232 | 236 | {" "} 237 | ( 241 | 242 |
243 | Email 244 |
245 | 246 | 247 | 248 |
249 | )} 250 | /> 251 | ( 255 | 256 |
257 | 258 | First Name 259 | 260 |
261 | 262 | 263 | 264 |
265 | )} 266 | /> 267 | ( 271 | 272 |
273 | 274 | Last Name 275 | 276 |
277 | 278 | 279 | 280 |
281 | )} 282 | /> 283 | ( 287 | 288 |
289 | Group 290 |
291 | 292 | ({ 296 | label: group.name, 297 | value: group.name, 298 | }))} 299 | value={selectedGroups} 300 | onChange={(value) => { 301 | setSelectedGroups(value); 302 | }} 303 | /> 304 | 305 |
306 | )} 307 | /> 308 |
309 | 318 |
319 | 320 | 321 |
322 |
323 |
324 |
325 | ); 326 | } 327 | -------------------------------------------------------------------------------- /components/email-composer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import Tiptap from "@/components/tiptap"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | } from "@/components/ui/form"; 20 | import { cookies } from "next/headers"; 21 | import { zodResolver } from "@hookform/resolvers/zod"; 22 | import { Input } from "@/components/ui/input"; 23 | import { 24 | SupabaseClient, 25 | createClientComponentClient, 26 | createServerActionClient, 27 | createServerComponentClient, 28 | } from "@supabase/auth-helpers-nextjs"; 29 | import Select from "react-select"; 30 | import CreatableSelect from "react-select/creatable"; 31 | import { toast } from "@/components/ui/use-toast"; 32 | 33 | import { Controller, useForm } from "react-hook-form"; 34 | import * as z from "zod"; 35 | 36 | export default function EmailComposer({ 37 | user, 38 | supabase, 39 | userEmail, 40 | onSend, 41 | }: { 42 | user: any; 43 | supabase: SupabaseClient; 44 | userEmail: string; 45 | onSend: () => void; 46 | }) { 47 | useEffect(() => { 48 | // Fetch members from your Supabase database 49 | async function fetchMembers() { 50 | try { 51 | const { data: members, error } = await supabase 52 | .from("members") 53 | .select("id, first_name, last_name, email") 54 | .eq("created_by", user.id); 55 | if (error) { 56 | console.error("Error fetching members:", error); 57 | return; 58 | } 59 | 60 | // Transform the member data into options for Select component 61 | const options = members.map((member) => ({ 62 | value: member.email, 63 | label: `${member.first_name} ${member.last_name} - ${member.email}`, 64 | })); 65 | 66 | setSelectedMembers(options); 67 | } catch (error) { 68 | console.error("Error fetching members:", error); 69 | } 70 | } 71 | 72 | fetchMembers(); 73 | }, []); // Run this effect only once on component mount 74 | 75 | const [selectedMembers, setSelectedMembers] = useState([]); 76 | const [allMemberGroups, setAllMemberGroups] = useState([]); 77 | 78 | const [memberEmailsArray, setMemberEmailsArray] = useState([]); 79 | 80 | useEffect(() => { 81 | async function fetchMemberGroups() { 82 | const { data: member_groups, error } = await supabase 83 | .from("member_groups") 84 | .select() 85 | .eq("created_by", user.id); 86 | 87 | // Transform the memebrGroup data for the selected component 88 | const options = member_groups.map((member_group) => ({ 89 | value: member_group.id, 90 | label: member_group.name, 91 | })); 92 | 93 | if (error) { 94 | console.error("Error fetching member groups:", error); 95 | } else { 96 | setAllMemberGroups(options); 97 | } 98 | } 99 | if (user) { 100 | fetchMemberGroups(); 101 | } 102 | }, [supabase, user]); 103 | 104 | async function fetchMemberGroupEmails(group_ids) { 105 | // Extract group_ids from the fetched member_groups 106 | const groupIds = allMemberGroups 107 | .map((allMemberGroup) => allMemberGroup.value) 108 | .filter((id) => group_ids.includes(id)); 109 | 110 | // Fetch member_group_joins records with these group_ids 111 | const { data: memberGroupJoins, error: joinError } = await supabase 112 | .from("member_group_joins") 113 | .select("member_id") 114 | .in("group_id", groupIds); 115 | 116 | const memberGroupArray = Object.values(memberGroupJoins); 117 | const memberIds = memberGroupArray.map((join) => join.member_id); 118 | 119 | // Fetch member emails from the members table based on the extracted member_ids 120 | const { data: memberEmails, error: memberEmailError } = await supabase 121 | .from("members") 122 | .select("email") 123 | .in("id", memberIds); 124 | const array = memberEmails.map((join) => join.email); 125 | setMemberEmailsArray(array); 126 | // const memberEmailsArray = Object.values(memberEmails); 127 | return array; 128 | } 129 | 130 | interface OptionType { 131 | value: any; 132 | label: string; 133 | } 134 | 135 | const { register, handleSubmit, reset } = useForm(); 136 | const [isSending, setIsSending] = useState(false); 137 | const emailFormSchema = z.object({ 138 | to_emails: z 139 | .array( 140 | z.object({ 141 | value: z.union([z.string(), z.number()]), 142 | label: z.string(), 143 | }) 144 | ) 145 | .refine( 146 | (value) => { 147 | const emails = value.map((item) => item.value.toString().trim()); 148 | const areAllEmailsValid = emails.every((email) => { 149 | ``; 150 | const isValid = 151 | !isNaN(parseInt(email)) || 152 | z.string().email().safeParse(email).success; 153 | return isValid; 154 | }); 155 | return areAllEmailsValid; 156 | }, 157 | { 158 | message: "Please enter valid email addresses.", 159 | } 160 | ), 161 | subject: z.string(), 162 | body: z.string(), 163 | cc: z 164 | .array( 165 | z.string().email({ message: "Please enter a valid email address." }) 166 | ) 167 | .optional(), 168 | bcc: z 169 | .array( 170 | z.string().email({ message: "Please enter a valid email address." }) 171 | ) 172 | .optional(), 173 | attachments: z.array(z.string()).optional(), 174 | group_emails: z 175 | .array( 176 | z.object({ 177 | value: z.number(), 178 | label: z.string(), 179 | }) 180 | ) 181 | .optional(), 182 | }); 183 | 184 | type EmailFormValues = z.infer; 185 | 186 | const form = useForm({ 187 | resolver: zodResolver(emailFormSchema), 188 | defaultValues: { 189 | to_emails: [], 190 | subject: "", 191 | body: "", 192 | cc: [], 193 | bcc: [], 194 | attachments: [], 195 | }, 196 | }); 197 | 198 | const onSubmit = async (data: EmailFormValues) => { 199 | // check that domain is verified 200 | console.log(userEmail); 201 | // check that userEmail contains @basecase.vc 202 | if (!userEmail.includes("@basecase.vc")) { 203 | toast({ 204 | description: ( 205 | 206 | To start sending, please head over to{" "} 207 | Resend to verify your domain. 208 | 209 | ), 210 | }); 211 | setIsSending(false); 212 | return; 213 | } 214 | 215 | try { 216 | setIsSending(true); 217 | 218 | const toEmails = data.to_emails.map((email) => email.value); 219 | let mergedEmails = toEmails; 220 | const memberEmails = await fetchMemberGroupEmails( 221 | toEmails.filter((em) => !isNaN(em)) 222 | ); 223 | // if (memberEmails.length > 0) { 224 | mergedEmails = [...new Set([...toEmails, ...memberEmails])].filter((v) => 225 | isNaN(v) 226 | ); 227 | // } 228 | const newEmailData = { 229 | to_emails: mergedEmails, 230 | subject: data.subject ? data.subject : "empty subject", 231 | body: data.body ? data.body : "

", 232 | cc_emails: [], 233 | bcc_emails: [], 234 | attachments: [], 235 | from_email: userEmail, 236 | }; 237 | 238 | const response = await fetch("/send-email", { 239 | method: "POST", 240 | headers: { 241 | "Content-Type": "application/json", 242 | }, 243 | body: JSON.stringify(newEmailData), 244 | }); 245 | 246 | const { data: newEmail, error: insertError } = await supabase 247 | .from("emails") 248 | .insert([newEmailData]); 249 | 250 | if (response.ok) { 251 | onSend(); 252 | form.reset(); 253 | toast({ 254 | description: "Your email has been sent successfully", 255 | }); 256 | } else { 257 | console.error("Failed to send email", response.statusText); 258 | toast({ 259 | description: response.statusText, 260 | }); 261 | } 262 | } catch (error) { 263 | toast({ 264 | description: "An error occurred while sending the email", 265 | }); 266 | } finally { 267 | setIsSending(false); 268 | } 269 | }; 270 | 271 | return ( 272 | 273 | 274 | Compose Email 275 |
276 |
277 | 278 | ( 282 | 283 |
284 | To 285 |
286 | 287 | 307 | 308 |
309 | )} 310 | /> 311 | ( 315 | <> 316 | 317 | 318 | )} 319 | /> 320 |
321 | 327 |
328 | 329 | 330 |
331 |
332 |
333 | ); 334 | } 335 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LucideProps, 3 | Moon, 4 | SunMedium, 5 | Twitter, 6 | type Icon as LucideIcon, 7 | } from "lucide-react"; 8 | 9 | export type Icon = LucideIcon; 10 | 11 | export const Icons = { 12 | sun: SunMedium, 13 | moon: Moon, 14 | twitter: Twitter, 15 | logo: (props: LucideProps) => ( 16 | 17 | 21 | 22 | ), 23 | gitHub: (props: LucideProps) => ( 24 | 25 | 29 | 30 | ), 31 | }; 32 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Link from "next/link"; 3 | import { User, Mail } from "lucide-react"; 4 | import { NavItem } from "@/types/nav"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | interface MainNavProps { 8 | items?: NavItem[]; 9 | } 10 | 11 | export function MainNav({ items }: MainNavProps) { 12 | return ( 13 |
14 | {items?.length ? ( 15 | 32 | ) : null} 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/members-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Table, 4 | TableBody, 5 | TableCaption, 6 | TableCell, 7 | TableHead, 8 | TableHeader, 9 | TableRow, 10 | } from "@/components/ui/table"; 11 | import { Database } from "@/types/supabase"; 12 | import { Trash } from "lucide-react"; 13 | import { 14 | Tooltip, 15 | TooltipContent, 16 | TooltipProvider, 17 | TooltipTrigger, 18 | } from "./ui/tooltip"; 19 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; 20 | import { toast } from "./ui/use-toast"; 21 | import { useRouter } from "next/navigation"; 22 | import EditMemberForm from "./edit-member"; 23 | import { useEffect, useState } from "react"; 24 | 25 | type Member = Database["public"]["Tables"]["members"]["Row"]; 26 | type GroupMappings = Record; 27 | type GroupName = { 28 | id: number; 29 | name: string; 30 | }; 31 | 32 | export function MembersTable({ 33 | members, 34 | groupMappings, 35 | groupNamesData, 36 | user, 37 | }: { 38 | members: Member[]; 39 | groupMappings: GroupMappings; 40 | groupNamesData: GroupName; 41 | user: any; 42 | }) { 43 | const supabase = createClientComponentClient(); 44 | const [groupMemberships, setGroupMemberships] = useState< 45 | Record 46 | >({}); 47 | 48 | const router = useRouter(); 49 | 50 | async function onDelete(id: string) { 51 | try { 52 | let { error } = await supabase.from("members").delete().eq("id", id); 53 | if (error) throw error; 54 | toast({ 55 | description: "Your member has been deleted", 56 | }); 57 | router.refresh(); 58 | } catch (error) {} 59 | } 60 | 61 | useEffect(() => { 62 | const groupMemberships: Record = {}; 63 | members.forEach((member) => { 64 | if (groupMappings[member.id]) { 65 | const formattedMembership = groupMappings[member.id] 66 | .map((groupId) => { 67 | const matchingGroup = groupNamesData.find( 68 | (group) => group.id === groupId 69 | ) as GroupName; 70 | return matchingGroup ? matchingGroup.name : null; 71 | }) 72 | .filter(Boolean) 73 | .join(", "); 74 | groupMemberships[member.id] = formattedMembership || "None"; 75 | } else { 76 | groupMemberships[member.id] = "None"; 77 | } 78 | }); 79 | 80 | setGroupMemberships(groupMemberships); 81 | }, [members, groupMappings, groupNamesData]); 82 | 83 | return ( 84 | 85 | 86 | 87 | Name 88 | Email 89 | Created Time 90 | Group 91 | 92 | 93 | 94 | {members.map((member: any) => ( 95 | 96 | 97 | {member.first_name} {member.last_name} 98 | 99 | {member.email} 100 | 101 | {new Date(member.created_at).toLocaleString("en-US")} 102 | 103 | {groupMemberships[member.id]} 104 | 105 | 106 | 107 | 108 | 113 | 114 | Edit Member 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | onDelete(member.id)} 125 | style={{ cursor: "pointer" }} 126 | /> 127 | 128 | Delete Member 129 | 130 | 131 | 132 | 133 | ))} 134 | 135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /components/new-message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // This is a client component 👈🏽 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Tiptap from "@/components/tiptap"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | DialogTrigger, 13 | } from "@/components/ui/dialog"; 14 | import { 15 | Form, 16 | FormControl, 17 | FormField, 18 | FormItem, 19 | FormLabel, 20 | } from "@/components/ui/form"; 21 | import { cookies } from "next/headers"; 22 | import { zodResolver } from "@hookform/resolvers/zod"; 23 | import { Input } from "@/components/ui/input"; 24 | import { 25 | createClientComponentClient, 26 | createServerActionClient, 27 | createServerComponentClient, 28 | } from "@supabase/auth-helpers-nextjs"; 29 | import React, { useEffect, useState } from "react"; 30 | 31 | import { Controller, useForm } from "react-hook-form"; 32 | 33 | import * as z from "zod"; 34 | import EmailComposer from "@/components/email-composer"; 35 | import { Mail } from "lucide-react"; 36 | 37 | export default function NewMessage({ user }: { user: any }) { 38 | const supabase = createClientComponentClient(); 39 | const [isTiptapOpen, setIsTiptapOpen] = React.useState(false); 40 | 41 | const handleNewMessageClick = () => { 42 | setIsTiptapOpen(true); 43 | }; 44 | 45 | const handleSend = () => { 46 | setIsTiptapOpen(false); 47 | }; 48 | 49 | return ( 50 |
51 | 52 | 53 | 58 | 59 | 60 | 66 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /components/secondary-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Link from "next/link"; 3 | import { User, Mail } from "lucide-react"; 4 | import { NavItem } from "@/types/nav"; 5 | import { cn } from "@/lib/utils"; 6 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; 7 | import { cookies } from "next/headers"; 8 | import { redirect } from "next/navigation"; 9 | 10 | interface SecondaryNavProps { 11 | items?: NavItem[]; 12 | } 13 | 14 | export function SecondaryNav({ items }: SecondaryNavProps) { 15 | return ( 16 |
17 | {items?.length ? ( 18 | 37 | ) : null} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { siteConfig } from "@/config/site"; 4 | import { buttonVariants } from "@/components/ui/button"; 5 | import { Icons } from "@/components/icons"; 6 | import { MainNav } from "@/components/main-nav"; 7 | import { ThemeToggle } from "@/components/theme-toggle"; 8 | import UserNav from "./user-nav"; 9 | import { SecondaryNav } from "./secondary-nav"; 10 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; 11 | import { cookies } from "next/headers"; 12 | import { redirect } from "next/navigation"; 13 | 14 | export async function SiteHeader() { 15 | const supabase = createServerComponentClient({ cookies }); 16 | 17 | const { 18 | data: { user }, 19 | } = await supabase.auth.getUser(); 20 | 21 | return ( 22 |
23 |
24 |
25 | {user && } 26 |
27 | 46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /components/styles.css: -------------------------------------------------------------------------------- 1 | /* Basic editor styles */ 2 | .tiptap { 3 | margin-top: 1rem; 4 | } 5 | 6 | .tiptap > * + * { 7 | margin-top: 0.75em; 8 | } 9 | 10 | .tiptap ul, 11 | .tiptap ol { 12 | padding: 0 1rem; 13 | } 14 | 15 | .tiptap h1, 16 | .tiptap h2, 17 | .tiptap h3, 18 | .tiptap h4, 19 | .tiptap h5, 20 | .tiptap h6 { 21 | line-height: 1.1; 22 | } 23 | 24 | .tiptap code { 25 | background-color: rgba(97, 97, 97, 0.1); 26 | color: #616161; 27 | } 28 | 29 | .tiptap pre { 30 | background: #0D0D0D; 31 | color: #FFF; 32 | font-family: 'JetBrainsMono', monospace; 33 | padding: 0.75rem 1rem; 34 | border-radius: 0.5rem; 35 | } 36 | 37 | .tiptap pre code { 38 | color: inherit; 39 | padding: 0; 40 | background: none; 41 | font-size: 0.8rem; 42 | } 43 | 44 | .tiptap mark { 45 | background-color: #FAF594; 46 | } 47 | 48 | .tiptap img { 49 | max-width: 100%; 50 | height: auto; 51 | } 52 | 53 | .tiptap blockquote { 54 | padding-left: 1rem; 55 | border-left: 2px solid rgba(13, 13, 13, 0.1); 56 | } 57 | 58 | .tiptap hr { 59 | border: none; 60 | border-top: 2px solid rgba(13, 13, 13, 0.1); 61 | margin: 2rem 0; 62 | } 63 | -------------------------------------------------------------------------------- /components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === "production") return null; 3 | 4 | return ( 5 |
6 |
xs
7 |
8 | sm 9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Icons } from "@/components/icons"; 8 | 9 | export function ThemeToggle() { 10 | const { setTheme, theme } = useTheme(); 11 | 12 | return ( 13 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/tiptap.js: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | 3 | import Highlight from "@tiptap/extension-highlight"; 4 | import TextAlign from "@tiptap/extension-text-align"; 5 | import { EditorContent, useEditor } from "@tiptap/react"; 6 | import StarterKit from "@tiptap/starter-kit"; 7 | import React from "react"; 8 | import Image from "@tiptap/extension-image"; 9 | 10 | import { Controller, useForm } from "react-hook-form"; 11 | 12 | const MenuBar = ({ editor }) => { 13 | if (!editor) { 14 | return null; 15 | } 16 | 17 | const buttonBaseStyles = "px-2 py-1 rounded border border-gray-300"; 18 | const activeButtonStyles = "bg-primary text-white"; 19 | const inactiveButtonStyles = "bg-secondary text-gray-700"; 20 | const addImage = () => { 21 | const url = window.prompt("URL"); 22 | 23 | if (url) { 24 | editor.chain().focus().setImage({ src: url }).run(); 25 | } 26 | }; 27 | 28 | return ( 29 | <> 30 | 43 | 44 | 57 | 70 | 83 | 94 | 105 | 116 | 129 | 142 | 155 | 168 | 181 | 187 | 188 | ); 189 | }; 190 | 191 | export default ({ setValue, field }) => { 192 | const editor = useEditor({ 193 | onUpdate: ({ editor }) => { 194 | // Update the form field value when the editor's content changes 195 | field.onChange(editor.getHTML()); 196 | }, 197 | onMounted: () => { 198 | // Set the initial value when the editor is mounted 199 | setValue(fieldName, editor.getHTML()); 200 | }, 201 | extensions: [ 202 | StarterKit, 203 | Image, 204 | TextAlign.configure({ 205 | types: ["heading", "paragraph"], 206 | }), 207 | Highlight, 208 | ], 209 | content: ``, 210 | }); 211 | 212 | return ( 213 |
214 | 215 | 216 |
217 | ); 218 | }; 219 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /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 rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = ({ 14 | className, 15 | ...props 16 | }: DialogPrimitive.DialogPortalProps) => ( 17 | 18 | ) 19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName 20 | 21 | const DialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 33 | )) 34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 35 | 36 | const DialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, children, ...props }, ref) => ( 40 | 41 | 42 | 50 | {children} 51 | 52 | 53 | Close 54 | 55 | 56 | 57 | )) 58 | DialogContent.displayName = DialogPrimitive.Content.displayName 59 | 60 | const DialogHeader = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | DialogHeader.displayName = "DialogHeader" 73 | 74 | const DialogFooter = ({ 75 | className, 76 | ...props 77 | }: React.HTMLAttributes) => ( 78 |
85 | ) 86 | DialogFooter.displayName = "DialogFooter" 87 | 88 | const DialogTitle = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 100 | )) 101 | DialogTitle.displayName = DialogPrimitive.Title.displayName 102 | 103 | const DialogDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | DialogDescription.displayName = DialogPrimitive.Description.displayName 114 | 115 | export { 116 | Dialog, 117 | DialogTrigger, 118 | DialogContent, 119 | DialogHeader, 120 | DialogFooter, 121 | DialogTitle, 122 | DialogDescription, 123 | } 124 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |