├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── onboarding │ │ └── page.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (root) │ ├── activity │ │ └── page.tsx │ ├── communities │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── create-thread │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── profile │ │ ├── [id] │ │ │ └── page.tsx │ │ └── edit │ │ │ └── page.tsx │ ├── search │ │ └── page.tsx │ └── thread │ │ └── [id] │ │ └── page.tsx ├── api │ ├── uploadthing │ │ ├── core.ts │ │ └── route.ts │ └── webhook │ │ └── clerk │ │ └── route.ts ├── favicon.ico └── globals.css ├── components.json ├── components ├── cards │ ├── CommunityCard.tsx │ ├── ThreadCard.tsx │ └── UserCard.tsx ├── forms │ ├── AccountProfile.tsx │ ├── Comment.tsx │ ├── DeleteThread.tsx │ └── PostThread.tsx ├── shared │ ├── Bottombar.tsx │ ├── LeftSidebar.tsx │ ├── Pagination.tsx │ ├── ProfileHeader.tsx │ ├── RightSidebar.tsx │ ├── Searchbar.tsx │ ├── ThreadsTab.tsx │ └── Topbar.tsx └── ui │ ├── button.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── select.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── constants └── index.js ├── lib ├── actions │ ├── community.actions.ts │ ├── thread.actions.ts │ └── user.actions.ts ├── models │ ├── community.model.ts │ ├── thread.model.ts │ └── user.model.ts ├── mongoose.ts ├── uploadthing.ts ├── utils.ts └── validations │ ├── thread.ts │ └── user.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── community.svg │ ├── create.svg │ ├── delete.svg │ ├── edit.svg │ ├── heart-filled.svg │ ├── heart-gray.svg │ ├── heart.svg │ ├── home.svg │ ├── logout.svg │ ├── members.svg │ ├── more.svg │ ├── profile.svg │ ├── reply.svg │ ├── repost.svg │ ├── request.svg │ ├── search-gray.svg │ ├── search.svg │ ├── share.svg │ ├── tag.svg │ └── user.svg ├── logo.svg ├── next.svg └── vercel.svg ├── tailwind.config.js └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "next", 5 | "standard", 6 | "plugin:tailwindcss/recommended", 7 | "prettier" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.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 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # vscode 38 | .vscode 39 | 40 | # env 41 | .env 42 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { ClerkProvider } from "@clerk/nextjs"; 5 | import { dark } from "@clerk/themes"; 6 | 7 | import "../globals.css"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export const metadata: Metadata = { 12 | title: "Auth", 13 | description: "Generated by create next app", 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 27 | 28 | {children} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/(auth)/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { fetchUser } from "@/lib/actions/user.actions"; 5 | import AccountProfile from "@/components/forms/AccountProfile"; 6 | 7 | async function Page() { 8 | const user = await currentUser(); 9 | if (!user) return null; // to avoid typescript warnings 10 | 11 | const userInfo = await fetchUser(user.id); 12 | if (userInfo?.onboarded) redirect("/"); 13 | 14 | const userData = { 15 | id: user.id, 16 | objectId: userInfo?._id, 17 | username: userInfo ? userInfo?.username : user.username, 18 | name: userInfo ? userInfo?.name : user.firstName ?? "", 19 | bio: userInfo ? userInfo?.bio : "", 20 | image: userInfo ? userInfo?.image : user.imageUrl, 21 | }; 22 | 23 | return ( 24 |
25 |

Onboarding

26 |

27 | Complete your profile now, to use Threds. 28 |

29 | 30 |
31 | 32 |
33 |
34 | ); 35 | } 36 | 37 | export default Page; 38 | -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(root)/activity/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import { currentUser } from "@clerk/nextjs"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { fetchUser, getActivity } from "@/lib/actions/user.actions"; 7 | 8 | async function Page() { 9 | const user = await currentUser(); 10 | if (!user) return null; 11 | 12 | const userInfo = await fetchUser(user.id); 13 | if (!userInfo?.onboarded) redirect("/onboarding"); 14 | 15 | const activity = await getActivity(userInfo._id); 16 | 17 | return ( 18 | <> 19 |

Activity

20 | 21 |
22 | {activity.length > 0 ? ( 23 | <> 24 | {activity.map((activity) => ( 25 | 26 |
27 | user_logo 34 |

35 | 36 | {activity.author.name} 37 | {" "} 38 | replied to your thread 39 |

40 |
41 | 42 | ))} 43 | 44 | ) : ( 45 |

No activity yet

46 | )} 47 |
48 | 49 | ); 50 | } 51 | 52 | export default Page; 53 | -------------------------------------------------------------------------------- /app/(root)/communities/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | 4 | import { communityTabs } from "@/constants"; 5 | 6 | import UserCard from "@/components/cards/UserCard"; 7 | import ThreadsTab from "@/components/shared/ThreadsTab"; 8 | import ProfileHeader from "@/components/shared/ProfileHeader"; 9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 10 | 11 | import { fetchCommunityDetails } from "@/lib/actions/community.actions"; 12 | 13 | async function Page({ params }: { params: { id: string } }) { 14 | const user = await currentUser(); 15 | if (!user) return null; 16 | 17 | const communityDetails = await fetchCommunityDetails(params.id); 18 | 19 | return ( 20 |
21 | 30 | 31 |
32 | 33 | 34 | {communityTabs.map((tab) => ( 35 | 36 | {tab.label} 43 |

{tab.label}

44 | 45 | {tab.label === "Threads" && ( 46 |

47 | {communityDetails.threads.length} 48 |

49 | )} 50 |
51 | ))} 52 |
53 | 54 | 55 | {/* @ts-ignore */} 56 | 61 | 62 | 63 | 64 |
65 | {communityDetails.members.map((member: any) => ( 66 | 74 | ))} 75 |
76 |
77 | 78 | 79 | {/* @ts-ignore */} 80 | 85 | 86 |
87 |
88 |
89 | ); 90 | } 91 | 92 | export default Page; 93 | -------------------------------------------------------------------------------- /app/(root)/communities/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Searchbar from "@/components/shared/Searchbar"; 5 | import Pagination from "@/components/shared/Pagination"; 6 | import CommunityCard from "@/components/cards/CommunityCard"; 7 | 8 | import { fetchUser } from "@/lib/actions/user.actions"; 9 | import { fetchCommunities } from "@/lib/actions/community.actions"; 10 | 11 | async function Page({ 12 | searchParams, 13 | }: { 14 | searchParams: { [key: string]: string | undefined }; 15 | }) { 16 | const user = await currentUser(); 17 | if (!user) return null; 18 | 19 | const userInfo = await fetchUser(user.id); 20 | if (!userInfo?.onboarded) redirect("/onboarding"); 21 | 22 | const result = await fetchCommunities({ 23 | searchString: searchParams.q, 24 | pageNumber: searchParams?.page ? +searchParams.page : 1, 25 | pageSize: 25, 26 | }); 27 | 28 | return ( 29 | <> 30 |

Communities

31 | 32 |
33 | 34 |
35 | 36 |
37 | {result.communities.length === 0 ? ( 38 |

No Result

39 | ) : ( 40 | <> 41 | {result.communities.map((community) => ( 42 | 51 | ))} 52 | 53 | )} 54 |
55 | 56 | 61 | 62 | ); 63 | } 64 | 65 | export default Page; 66 | -------------------------------------------------------------------------------- /app/(root)/create-thread/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import PostThread from "@/components/forms/PostThread"; 5 | import { fetchUser } from "@/lib/actions/user.actions"; 6 | 7 | async function Page() { 8 | const user = await currentUser(); 9 | if (!user) return null; 10 | 11 | // fetch organization list created by user 12 | const userInfo = await fetchUser(user.id); 13 | if (!userInfo?.onboarded) redirect("/onboarding"); 14 | 15 | return ( 16 | <> 17 |

Create Thread

18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default Page; 25 | -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { ClerkProvider } from "@clerk/nextjs"; 5 | import { dark } from "@clerk/themes"; 6 | 7 | import "../globals.css"; 8 | import LeftSidebar from "@/components/shared/LeftSidebar"; 9 | import Bottombar from "@/components/shared/Bottombar"; 10 | import RightSidebar from "@/components/shared/RightSidebar"; 11 | import Topbar from "@/components/shared/Topbar"; 12 | 13 | const inter = Inter({ subsets: ["latin"] }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Threads", 17 | description: "A Next.js 13 Meta Threads application", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: { 23 | children: React.ReactNode; 24 | }) { 25 | return ( 26 | 31 | 32 | 33 | 34 | 35 |
36 | 37 |
38 |
{children}
39 |
40 | {/* @ts-ignore */} 41 | 42 |
43 | 44 | 45 | 46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import ThreadCard from "@/components/cards/ThreadCard"; 5 | import Pagination from "@/components/shared/Pagination"; 6 | 7 | import { fetchPosts } from "@/lib/actions/thread.actions"; 8 | import { fetchUser } from "@/lib/actions/user.actions"; 9 | 10 | async function Home({ 11 | searchParams, 12 | }: { 13 | searchParams: { [key: string]: string | undefined }; 14 | }) { 15 | const user = await currentUser(); 16 | if (!user) return null; 17 | 18 | const userInfo = await fetchUser(user.id); 19 | if (!userInfo?.onboarded) redirect("/onboarding"); 20 | 21 | const result = await fetchPosts( 22 | searchParams.page ? +searchParams.page : 1, 23 | 30 24 | ); 25 | 26 | return ( 27 | <> 28 |

Home

29 | 30 |
31 | {result.posts.length === 0 ? ( 32 |

No threads found

33 | ) : ( 34 | <> 35 | {result.posts.map((post) => ( 36 | 47 | ))} 48 | 49 | )} 50 |
51 | 52 | 57 | 58 | ); 59 | } 60 | 61 | export default Home; 62 | -------------------------------------------------------------------------------- /app/(root)/profile/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | import { redirect } from "next/navigation"; 4 | 5 | import { profileTabs } from "@/constants"; 6 | 7 | import ThreadsTab from "@/components/shared/ThreadsTab"; 8 | import ProfileHeader from "@/components/shared/ProfileHeader"; 9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 10 | 11 | import { fetchUser } from "@/lib/actions/user.actions"; 12 | 13 | async function Page({ params }: { params: { id: string } }) { 14 | const user = await currentUser(); 15 | if (!user) return null; 16 | 17 | const userInfo = await fetchUser(params.id); 18 | if (!userInfo?.onboarded) redirect("/onboarding"); 19 | 20 | return ( 21 |
22 | 30 | 31 |
32 | 33 | 34 | {profileTabs.map((tab) => ( 35 | 36 | {tab.label} 43 |

{tab.label}

44 | 45 | {tab.label === "Threads" && ( 46 |

47 | {userInfo.threads.length} 48 |

49 | )} 50 |
51 | ))} 52 |
53 | {profileTabs.map((tab) => ( 54 | 59 | {/* @ts-ignore */} 60 | 65 | 66 | ))} 67 |
68 |
69 |
70 | ); 71 | } 72 | export default Page; 73 | -------------------------------------------------------------------------------- /app/(root)/profile/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { fetchUser } from "@/lib/actions/user.actions"; 5 | import AccountProfile from "@/components/forms/AccountProfile"; 6 | 7 | // Copy paste most of the code as it is from the /onboarding 8 | 9 | async function Page() { 10 | const user = await currentUser(); 11 | if (!user) return null; 12 | 13 | const userInfo = await fetchUser(user.id); 14 | if (!userInfo?.onboarded) redirect("/onboarding"); 15 | 16 | const userData = { 17 | id: user.id, 18 | objectId: userInfo?._id, 19 | username: userInfo ? userInfo?.username : user.username, 20 | name: userInfo ? userInfo?.name : user.firstName ?? "", 21 | bio: userInfo ? userInfo?.bio : "", 22 | image: userInfo ? userInfo?.image : user.imageUrl, 23 | }; 24 | 25 | return ( 26 | <> 27 |

Edit Profile

28 |

Make any changes

29 | 30 |
31 | 32 |
33 | 34 | ); 35 | } 36 | 37 | export default Page; 38 | -------------------------------------------------------------------------------- /app/(root)/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | 4 | import UserCard from "@/components/cards/UserCard"; 5 | import Searchbar from "@/components/shared/Searchbar"; 6 | import Pagination from "@/components/shared/Pagination"; 7 | 8 | import { fetchUser, fetchUsers } from "@/lib/actions/user.actions"; 9 | 10 | async function Page({ 11 | searchParams, 12 | }: { 13 | searchParams: { [key: string]: string | undefined }; 14 | }) { 15 | const user = await currentUser(); 16 | if (!user) return null; 17 | 18 | const userInfo = await fetchUser(user.id); 19 | if (!userInfo?.onboarded) redirect("/onboarding"); 20 | 21 | const result = await fetchUsers({ 22 | userId: user.id, 23 | searchString: searchParams.q, 24 | pageNumber: searchParams?.page ? +searchParams.page : 1, 25 | pageSize: 25, 26 | }); 27 | 28 | return ( 29 |
30 |

Search

31 | 32 | 33 | 34 |
35 | {result.users.length === 0 ? ( 36 |

No Result

37 | ) : ( 38 | <> 39 | {result.users.map((person) => ( 40 | 48 | ))} 49 | 50 | )} 51 |
52 | 53 | 58 |
59 | ); 60 | } 61 | 62 | export default Page; 63 | -------------------------------------------------------------------------------- /app/(root)/thread/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { currentUser } from "@clerk/nextjs"; 3 | 4 | import Comment from "@/components/forms/Comment"; 5 | import ThreadCard from "@/components/cards/ThreadCard"; 6 | 7 | import { fetchUser } from "@/lib/actions/user.actions"; 8 | import { fetchThreadById } from "@/lib/actions/thread.actions"; 9 | 10 | export const revalidate = 0; 11 | 12 | async function page({ params }: { params: { id: string } }) { 13 | if (!params.id) return null; 14 | 15 | const user = await currentUser(); 16 | if (!user) return null; 17 | 18 | const userInfo = await fetchUser(user.id); 19 | if (!userInfo?.onboarded) redirect("/onboarding"); 20 | 21 | const thread = await fetchThreadById(params.id); 22 | 23 | return ( 24 |
25 |
26 | 36 |
37 | 38 |
39 | 44 |
45 | 46 |
47 | {thread.children.map((childItem: any) => ( 48 | 60 | ))} 61 |
62 |
63 | ); 64 | } 65 | 66 | export default page; 67 | -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | // Resource: https://docs.uploadthing.com/nextjs/appdir#creating-your-first-fileroute 2 | // Above resource shows how to setup uploadthing. Copy paste most of it as it is. 3 | // We're changing a few things in the middleware and configs of the file upload i.e., "media", "maxFileCount" 4 | 5 | import { currentUser } from "@clerk/nextjs"; 6 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 7 | 8 | const f = createUploadthing(); 9 | 10 | const getUser = async () => await currentUser(); 11 | 12 | export const ourFileRouter = { 13 | // Define as many FileRoutes as you like, each with a unique routeSlug 14 | media: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } }) 15 | // Set permissions and file types for this FileRoute 16 | .middleware(async (req) => { 17 | // This code runs on your server before upload 18 | const user = await getUser(); 19 | 20 | // If you throw, the user will not be able to upload 21 | if (!user) throw new Error("Unauthorized"); 22 | 23 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 24 | return { userId: user.id }; 25 | }) 26 | .onUploadComplete(async ({ metadata, file }) => { 27 | // This code RUNS ON YOUR SERVER after upload 28 | console.log("Upload complete for userId:", metadata.userId); 29 | 30 | console.log("file url", file.url); 31 | }), 32 | } satisfies FileRouter; 33 | 34 | export type OurFileRouter = typeof ourFileRouter; 35 | -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | // Resource: https://docs.uploadthing.com/nextjs/appdir#create-a-nextjs-api-route-using-the-filerouter 2 | // Copy paste (be careful with imports) 3 | 4 | import { createNextRouteHandler } from "uploadthing/next"; 5 | 6 | import { ourFileRouter } from "./core"; 7 | 8 | // Export routes for Next App Router 9 | export const { GET, POST } = createNextRouteHandler({ 10 | router: ourFileRouter, 11 | }); 12 | -------------------------------------------------------------------------------- /app/api/webhook/clerk/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // Resource: https://clerk.com/docs/users/sync-data-to-your-backend 3 | // Above article shows why we need webhooks i.e., to sync data to our backend 4 | 5 | // Resource: https://docs.svix.com/receiving/verifying-payloads/why 6 | // It's a good practice to verify webhooks. Above article shows why we should do it 7 | import { Webhook, WebhookRequiredHeaders } from "svix"; 8 | import { headers } from "next/headers"; 9 | 10 | import { IncomingHttpHeaders } from "http"; 11 | 12 | import { NextResponse } from "next/server"; 13 | import { 14 | addMemberToCommunity, 15 | createCommunity, 16 | deleteCommunity, 17 | removeUserFromCommunity, 18 | updateCommunityInfo, 19 | } from "@/lib/actions/community.actions"; 20 | 21 | // Resource: https://clerk.com/docs/integration/webhooks#supported-events 22 | // Above document lists the supported events 23 | type EventType = 24 | | "organization.created" 25 | | "organizationInvitation.created" 26 | | "organizationMembership.created" 27 | | "organizationMembership.deleted" 28 | | "organization.updated" 29 | | "organization.deleted"; 30 | 31 | type Event = { 32 | data: Record[]>; 33 | object: "event"; 34 | type: EventType; 35 | }; 36 | 37 | export const POST = async (request: Request) => { 38 | const payload = await request.json(); 39 | const header = headers(); 40 | 41 | const heads = { 42 | "svix-id": header.get("svix-id"), 43 | "svix-timestamp": header.get("svix-timestamp"), 44 | "svix-signature": header.get("svix-signature"), 45 | }; 46 | 47 | // Activitate Webhook in the Clerk Dashboard. 48 | // After adding the endpoint, you'll see the secret on the right side. 49 | const wh = new Webhook(process.env.NEXT_CLERK_WEBHOOK_SECRET || ""); 50 | 51 | let evnt: Event | null = null; 52 | 53 | try { 54 | evnt = wh.verify( 55 | JSON.stringify(payload), 56 | heads as IncomingHttpHeaders & WebhookRequiredHeaders 57 | ) as Event; 58 | } catch (err) { 59 | return NextResponse.json({ message: err }, { status: 400 }); 60 | } 61 | 62 | const eventType: EventType = evnt?.type!; 63 | 64 | // Listen organization creation event 65 | if (eventType === "organization.created") { 66 | // Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/CreateOrganization 67 | // Show what evnt?.data sends from above resource 68 | const { id, name, slug, logo_url, image_url, created_by } = 69 | evnt?.data ?? {}; 70 | 71 | try { 72 | // @ts-ignore 73 | await createCommunity( 74 | // @ts-ignore 75 | id, 76 | name, 77 | slug, 78 | logo_url || image_url, 79 | "org bio", 80 | created_by 81 | ); 82 | 83 | return NextResponse.json({ message: "User created" }, { status: 201 }); 84 | } catch (err) { 85 | console.log(err); 86 | return NextResponse.json( 87 | { message: "Internal Server Error" }, 88 | { status: 500 } 89 | ); 90 | } 91 | } 92 | 93 | // Listen organization invitation creation event. 94 | // Just to show. You can avoid this or tell people that we can create a new mongoose action and 95 | // add pending invites in the database. 96 | if (eventType === "organizationInvitation.created") { 97 | try { 98 | // Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Invitations#operation/CreateOrganizationInvitation 99 | console.log("Invitation created", evnt?.data); 100 | 101 | return NextResponse.json( 102 | { message: "Invitation created" }, 103 | { status: 201 } 104 | ); 105 | } catch (err) { 106 | console.log(err); 107 | 108 | return NextResponse.json( 109 | { message: "Internal Server Error" }, 110 | { status: 500 } 111 | ); 112 | } 113 | } 114 | 115 | // Listen organization membership (member invite & accepted) creation 116 | if (eventType === "organizationMembership.created") { 117 | try { 118 | // Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Memberships#operation/CreateOrganizationMembership 119 | // Show what evnt?.data sends from above resource 120 | const { organization, public_user_data } = evnt?.data; 121 | console.log("created", evnt?.data); 122 | 123 | // @ts-ignore 124 | await addMemberToCommunity(organization.id, public_user_data.user_id); 125 | 126 | return NextResponse.json( 127 | { message: "Invitation accepted" }, 128 | { status: 201 } 129 | ); 130 | } catch (err) { 131 | console.log(err); 132 | 133 | return NextResponse.json( 134 | { message: "Internal Server Error" }, 135 | { status: 500 } 136 | ); 137 | } 138 | } 139 | 140 | // Listen member deletion event 141 | if (eventType === "organizationMembership.deleted") { 142 | try { 143 | // Resource: https://clerk.com/docs/reference/backend-api/tag/Organization-Memberships#operation/DeleteOrganizationMembership 144 | // Show what evnt?.data sends from above resource 145 | const { organization, public_user_data } = evnt?.data; 146 | console.log("removed", evnt?.data); 147 | 148 | // @ts-ignore 149 | await removeUserFromCommunity(public_user_data.user_id, organization.id); 150 | 151 | return NextResponse.json({ message: "Member removed" }, { status: 201 }); 152 | } catch (err) { 153 | console.log(err); 154 | 155 | return NextResponse.json( 156 | { message: "Internal Server Error" }, 157 | { status: 500 } 158 | ); 159 | } 160 | } 161 | 162 | // Listen organization updation event 163 | if (eventType === "organization.updated") { 164 | try { 165 | // Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/UpdateOrganization 166 | // Show what evnt?.data sends from above resource 167 | const { id, logo_url, name, slug } = evnt?.data; 168 | console.log("updated", evnt?.data); 169 | 170 | // @ts-ignore 171 | await updateCommunityInfo(id, name, slug, logo_url); 172 | 173 | return NextResponse.json({ message: "Member removed" }, { status: 201 }); 174 | } catch (err) { 175 | console.log(err); 176 | 177 | return NextResponse.json( 178 | { message: "Internal Server Error" }, 179 | { status: 500 } 180 | ); 181 | } 182 | } 183 | 184 | // Listen organization deletion event 185 | if (eventType === "organization.deleted") { 186 | try { 187 | // Resource: https://clerk.com/docs/reference/backend-api/tag/Organizations#operation/DeleteOrganization 188 | // Show what evnt?.data sends from above resource 189 | const { id } = evnt?.data; 190 | console.log("deleted", evnt?.data); 191 | 192 | // @ts-ignore 193 | await deleteCommunity(id); 194 | 195 | return NextResponse.json( 196 | { message: "Organization deleted" }, 197 | { status: 201 } 198 | ); 199 | } catch (err) { 200 | console.log(err); 201 | 202 | return NextResponse.json( 203 | { message: "Internal Server Error" }, 204 | { status: 500 } 205 | ); 206 | } 207 | } 208 | }; 209 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/threads/72da0d632377518985cb5ae279d84d0733520d37/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | /* main */ 7 | .main-container { 8 | @apply flex min-h-screen flex-1 flex-col items-center bg-dark-1 px-6 pb-10 pt-28 max-md:pb-32 sm:px-10; 9 | } 10 | 11 | /* Head Text */ 12 | .head-text { 13 | @apply text-heading2-bold text-light-1; 14 | } 15 | 16 | /* Activity */ 17 | .activity-card { 18 | @apply flex items-center gap-2 rounded-md bg-dark-2 px-7 py-4; 19 | } 20 | 21 | /* No Result */ 22 | .no-result { 23 | @apply text-center !text-base-regular text-light-3; 24 | } 25 | 26 | /* Community Card */ 27 | .community-card { 28 | @apply w-full rounded-lg bg-dark-3 px-4 py-5 sm:w-96; 29 | } 30 | 31 | .community-card_btn { 32 | @apply rounded-lg bg-primary-500 px-5 py-1.5 text-small-regular !text-light-1 !important; 33 | } 34 | 35 | /* thread card */ 36 | .thread-card_bar { 37 | @apply relative mt-2 w-0.5 grow rounded-full bg-neutral-800; 38 | } 39 | 40 | /* User card */ 41 | .user-card { 42 | @apply flex flex-col justify-between gap-4 max-xs:rounded-xl max-xs:bg-dark-3 max-xs:p-4 xs:flex-row xs:items-center; 43 | } 44 | 45 | .user-card_avatar { 46 | @apply flex flex-1 items-start justify-start gap-3 xs:items-center; 47 | } 48 | 49 | .user-card_btn { 50 | @apply h-auto min-w-[74px] rounded-lg bg-primary-500 text-[12px] text-light-1 !important; 51 | } 52 | 53 | .searchbar { 54 | @apply flex gap-1 rounded-lg bg-dark-3 px-4 py-2; 55 | } 56 | 57 | .searchbar_input { 58 | @apply border-none bg-dark-3 text-base-regular text-light-4 outline-none !important; 59 | } 60 | 61 | .topbar { 62 | @apply fixed top-0 z-30 flex w-full items-center justify-between bg-dark-2 px-6 py-3; 63 | } 64 | 65 | .bottombar { 66 | @apply fixed bottom-0 z-10 w-full rounded-t-3xl bg-glassmorphism p-4 backdrop-blur-lg xs:px-7 md:hidden; 67 | } 68 | 69 | .bottombar_container { 70 | @apply flex items-center justify-between gap-3 xs:gap-5; 71 | } 72 | 73 | .bottombar_link { 74 | @apply relative flex flex-col items-center gap-2 rounded-lg p-2 sm:flex-1 sm:px-2 sm:py-2.5; 75 | } 76 | 77 | .leftsidebar { 78 | @apply sticky left-0 top-0 z-20 flex h-screen w-fit flex-col justify-between overflow-auto border-r border-r-dark-4 bg-dark-2 pb-5 pt-28 max-md:hidden; 79 | } 80 | 81 | .leftsidebar_link { 82 | @apply relative flex justify-start gap-4 rounded-lg p-4; 83 | } 84 | 85 | .pagination { 86 | @apply mt-10 flex w-full items-center justify-center gap-5; 87 | } 88 | 89 | .rightsidebar { 90 | @apply sticky right-0 top-0 z-20 flex h-screen w-fit flex-col justify-between gap-12 overflow-auto border-l border-l-dark-4 bg-dark-2 px-10 pb-6 pt-28 max-xl:hidden; 91 | } 92 | } 93 | 94 | @layer utilities { 95 | .css-invert { 96 | @apply invert-[50%] brightness-200; 97 | } 98 | 99 | .custom-scrollbar::-webkit-scrollbar { 100 | width: 3px; 101 | height: 3px; 102 | border-radius: 2px; 103 | } 104 | 105 | .custom-scrollbar::-webkit-scrollbar-track { 106 | background: #09090a; 107 | } 108 | 109 | .custom-scrollbar::-webkit-scrollbar-thumb { 110 | background: #5c5c7b; 111 | border-radius: 50px; 112 | } 113 | 114 | .custom-scrollbar::-webkit-scrollbar-thumb:hover { 115 | background: #7878a3; 116 | } 117 | } 118 | 119 | /* Clerk Responsive fix */ 120 | .cl-organizationSwitcherTrigger .cl-userPreview .cl-userPreviewTextContainer { 121 | @apply max-sm:hidden; 122 | } 123 | 124 | .cl-organizationSwitcherTrigger 125 | .cl-organizationPreview 126 | .cl-organizationPreviewTextContainer { 127 | @apply max-sm:hidden; 128 | } 129 | 130 | /* Shadcn Component Styles */ 131 | 132 | /* Tab */ 133 | .tab { 134 | @apply flex min-h-[50px] flex-1 items-center gap-3 bg-dark-2 text-light-2 data-[state=active]:bg-[#0e0e12] data-[state=active]:text-light-2 !important; 135 | } 136 | 137 | .no-focus { 138 | @apply focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 !important; 139 | } 140 | 141 | /* Account Profile */ 142 | .account-form_image-label { 143 | @apply flex h-24 w-24 items-center justify-center rounded-full bg-dark-4 !important; 144 | } 145 | 146 | .account-form_image-input { 147 | @apply cursor-pointer border-none bg-transparent outline-none file:text-blue !important; 148 | } 149 | 150 | .account-form_input { 151 | @apply border border-dark-4 bg-dark-3 text-light-1 !important; 152 | } 153 | 154 | /* Comment Form */ 155 | .comment-form { 156 | @apply mt-10 flex items-center gap-4 border-y border-y-dark-4 py-5 max-xs:flex-col !important; 157 | } 158 | 159 | .comment-form_btn { 160 | @apply rounded-3xl bg-primary-500 px-8 py-2 !text-small-regular text-light-1 max-xs:w-full !important; 161 | } 162 | -------------------------------------------------------------------------------- /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": false 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/cards/CommunityCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { Button } from "../ui/button"; 5 | 6 | interface Props { 7 | id: string; 8 | name: string; 9 | username: string; 10 | imgUrl: string; 11 | bio: string; 12 | members: { 13 | image: string; 14 | }[]; 15 | } 16 | 17 | function CommunityCard({ id, name, username, imgUrl, bio, members }: Props) { 18 | return ( 19 |
20 |
21 | 22 | community_logo 28 | 29 | 30 |
31 | 32 |

{name}

33 | 34 |

@{username}

35 |
36 |
37 | 38 |

{bio}

39 | 40 |
41 | 42 | 45 | 46 | 47 | {members.length > 0 && ( 48 |
49 | {members.map((member, index) => ( 50 | {`user_${index}`} 60 | ))} 61 | {members.length > 3 && ( 62 |

63 | {members.length}+ Users 64 |

65 | )} 66 |
67 | )} 68 |
69 |
70 | ); 71 | } 72 | 73 | export default CommunityCard; 74 | -------------------------------------------------------------------------------- /components/cards/ThreadCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { formatDateString } from "@/lib/utils"; 5 | import DeleteThread from "../forms/DeleteThread"; 6 | 7 | interface Props { 8 | id: string; 9 | currentUserId: string; 10 | parentId: string | null; 11 | content: string; 12 | author: { 13 | name: string; 14 | image: string; 15 | id: string; 16 | }; 17 | community: { 18 | id: string; 19 | name: string; 20 | image: string; 21 | } | null; 22 | createdAt: string; 23 | comments: { 24 | author: { 25 | image: string; 26 | }; 27 | }[]; 28 | isComment?: boolean; 29 | } 30 | 31 | function ThreadCard({ 32 | id, 33 | currentUserId, 34 | parentId, 35 | content, 36 | author, 37 | community, 38 | createdAt, 39 | comments, 40 | isComment, 41 | }: Props) { 42 | return ( 43 |
48 |
49 |
50 |
51 | 52 | user_community_image 58 | 59 | 60 |
61 |
62 | 63 |
64 | 65 |

66 | {author.name} 67 |

68 | 69 | 70 |

{content}

71 | 72 |
73 |
74 | heart 81 | 82 | heart 89 | 90 | heart 97 | heart 104 |
105 | 106 | {isComment && comments.length > 0 && ( 107 | 108 |

109 | {comments.length} repl{comments.length > 1 ? "ies" : "y"} 110 |

111 | 112 | )} 113 |
114 |
115 |
116 | 117 | 124 |
125 | 126 | {!isComment && comments.length > 0 && ( 127 |
128 | {comments.slice(0, 2).map((comment, index) => ( 129 | {`user_${index}`} 137 | ))} 138 | 139 | 140 |

141 | {comments.length} repl{comments.length > 1 ? "ies" : "y"} 142 |

143 | 144 |
145 | )} 146 | 147 | {!isComment && community && ( 148 | 152 |

153 | {formatDateString(createdAt)} 154 | {community && ` - ${community.name} Community`} 155 |

156 | 157 | {community.name} 164 | 165 | )} 166 |
167 | ); 168 | } 169 | 170 | export default ThreadCard; 171 | -------------------------------------------------------------------------------- /components/cards/UserCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import { Button } from "../ui/button"; 7 | 8 | interface Props { 9 | id: string; 10 | name: string; 11 | username: string; 12 | imgUrl: string; 13 | personType: string; 14 | } 15 | 16 | function UserCard({ id, name, username, imgUrl, personType }: Props) { 17 | const router = useRouter(); 18 | 19 | const isCommunity = personType === "Community"; 20 | 21 | return ( 22 |
23 |
24 |
25 | user_logo 31 |
32 | 33 |
34 |

{name}

35 |

@{username}

36 |
37 |
38 | 39 | 51 |
52 | ); 53 | } 54 | 55 | export default UserCard; 56 | -------------------------------------------------------------------------------- /components/forms/AccountProfile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import Image from "next/image"; 5 | import { useForm } from "react-hook-form"; 6 | import { usePathname, useRouter } from "next/navigation"; 7 | import { ChangeEvent, useState } from "react"; 8 | import { zodResolver } from "@hookform/resolvers/zod"; 9 | 10 | import { 11 | Form, 12 | FormControl, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage, 17 | } from "@/components/ui/form"; 18 | import { Input } from "@/components/ui/input"; 19 | import { Button } from "@/components/ui/button"; 20 | import { Textarea } from "@/components/ui/textarea"; 21 | 22 | import { useUploadThing } from "@/lib/uploadthing"; 23 | import { isBase64Image } from "@/lib/utils"; 24 | 25 | import { UserValidation } from "@/lib/validations/user"; 26 | import { updateUser } from "@/lib/actions/user.actions"; 27 | 28 | interface Props { 29 | user: { 30 | id: string; 31 | objectId: string; 32 | username: string; 33 | name: string; 34 | bio: string; 35 | image: string; 36 | }; 37 | btnTitle: string; 38 | } 39 | 40 | const AccountProfile = ({ user, btnTitle }: Props) => { 41 | const router = useRouter(); 42 | const pathname = usePathname(); 43 | const { startUpload } = useUploadThing("media"); 44 | 45 | const [files, setFiles] = useState([]); 46 | 47 | const form = useForm>({ 48 | resolver: zodResolver(UserValidation), 49 | defaultValues: { 50 | profile_photo: user?.image ? user.image : "", 51 | name: user?.name ? user.name : "", 52 | username: user?.username ? user.username : "", 53 | bio: user?.bio ? user.bio : "", 54 | }, 55 | }); 56 | 57 | const onSubmit = async (values: z.infer) => { 58 | const blob = values.profile_photo; 59 | 60 | const hasImageChanged = isBase64Image(blob); 61 | if (hasImageChanged) { 62 | const imgRes = await startUpload(files); 63 | 64 | if (imgRes && imgRes[0].fileUrl) { 65 | values.profile_photo = imgRes[0].fileUrl; 66 | } 67 | } 68 | 69 | await updateUser({ 70 | name: values.name, 71 | path: pathname, 72 | username: values.username, 73 | userId: user.id, 74 | bio: values.bio, 75 | image: values.profile_photo, 76 | }); 77 | 78 | if (pathname === "/profile/edit") { 79 | router.back(); 80 | } else { 81 | router.push("/"); 82 | } 83 | }; 84 | 85 | const handleImage = ( 86 | e: ChangeEvent, 87 | fieldChange: (value: string) => void 88 | ) => { 89 | e.preventDefault(); 90 | 91 | const fileReader = new FileReader(); 92 | 93 | if (e.target.files && e.target.files.length > 0) { 94 | const file = e.target.files[0]; 95 | setFiles(Array.from(e.target.files)); 96 | 97 | if (!file.type.includes("image")) return; 98 | 99 | fileReader.onload = async (event) => { 100 | const imageDataUrl = event.target?.result?.toString() || ""; 101 | fieldChange(imageDataUrl); 102 | }; 103 | 104 | fileReader.readAsDataURL(file); 105 | } 106 | }; 107 | 108 | return ( 109 |
110 | 114 | ( 118 | 119 | 120 | {field.value ? ( 121 | profile_icon 129 | ) : ( 130 | profile_icon 137 | )} 138 | 139 | 140 | handleImage(e, field.onChange)} 146 | /> 147 | 148 | 149 | )} 150 | /> 151 | 152 | ( 156 | 157 | 158 | Name 159 | 160 | 161 | 166 | 167 | 168 | 169 | )} 170 | /> 171 | 172 | ( 176 | 177 | 178 | Username 179 | 180 | 181 | 186 | 187 | 188 | 189 | )} 190 | /> 191 | 192 | ( 196 | 197 | 198 | Bio 199 | 200 | 201 |