├── .github
├── FUNDING.yml
└── assets
│ ├── README.md
│ ├── activity.png
│ ├── communities.png
│ ├── community-profile.png
│ ├── create-organization.png
│ ├── create-thread.png
│ ├── edit-profile.png
│ ├── explore.png
│ ├── home.png
│ ├── my-profile-followers-tab.png
│ ├── my-profile.png
│ ├── onboarding.png
│ ├── search.png
│ ├── sign-in.png
│ ├── sign-up.png
│ ├── thread-likes-page.png
│ ├── thread-page.png
│ └── user-profile.png
├── .gitignore
├── LICENSE
├── 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
│ ├── edit-thread
│ │ └── [id]
│ │ │ └── page.tsx
│ ├── explore
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ ├── profile
│ │ ├── [id]
│ │ │ └── page.tsx
│ │ └── edit
│ │ │ └── page.tsx
│ ├── search
│ │ └── page.tsx
│ └── thread
│ │ ├── [id]
│ │ └── page.tsx
│ │ └── reactions
│ │ └── [id]
│ │ └── page.tsx
├── api
│ ├── uploadthing
│ │ ├── core.ts
│ │ └── route.ts
│ └── webhook
│ │ └── clerk
│ │ └── route.ts
├── favicon.ico
└── globals.css
├── components.json
├── components
├── atoms
│ ├── EditThread.tsx
│ ├── FollowUser.tsx
│ └── ReactThread.tsx
├── 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
│ ├── tabs.tsx
│ └── textarea.tsx
├── 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-purple.svg
│ ├── delete.svg
│ ├── edit.svg
│ ├── explore.svg
│ ├── follow.svg
│ ├── followers.svg
│ ├── following.svg
│ ├── heart-filled.svg
│ ├── heart-gray.svg
│ ├── heart.svg
│ ├── home.svg
│ ├── logo.svg
│ ├── logout.svg
│ ├── members.svg
│ ├── more.svg
│ ├── profile.svg
│ ├── reply.svg
│ ├── repost.svg
│ ├── request.svg
│ ├── search-gray.svg
│ ├── search.svg
│ ├── share.svg
│ ├── tag.svg
│ ├── unfollow.svg
│ └── user.svg
├── next.svg
└── vercel.svg
├── tailwind.config.js
├── tailwind.config.ts
└── tsconfig.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: ladunjexa
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.github/assets/README.md:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.github/assets/activity.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/activity.png
--------------------------------------------------------------------------------
/.github/assets/communities.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/communities.png
--------------------------------------------------------------------------------
/.github/assets/community-profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/community-profile.png
--------------------------------------------------------------------------------
/.github/assets/create-organization.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/create-organization.png
--------------------------------------------------------------------------------
/.github/assets/create-thread.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/create-thread.png
--------------------------------------------------------------------------------
/.github/assets/edit-profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/edit-profile.png
--------------------------------------------------------------------------------
/.github/assets/explore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/explore.png
--------------------------------------------------------------------------------
/.github/assets/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/home.png
--------------------------------------------------------------------------------
/.github/assets/my-profile-followers-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/my-profile-followers-tab.png
--------------------------------------------------------------------------------
/.github/assets/my-profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/my-profile.png
--------------------------------------------------------------------------------
/.github/assets/onboarding.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/onboarding.png
--------------------------------------------------------------------------------
/.github/assets/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/search.png
--------------------------------------------------------------------------------
/.github/assets/sign-in.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/sign-in.png
--------------------------------------------------------------------------------
/.github/assets/sign-up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/sign-up.png
--------------------------------------------------------------------------------
/.github/assets/thread-likes-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/thread-likes-page.png
--------------------------------------------------------------------------------
/.github/assets/thread-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/thread-page.png
--------------------------------------------------------------------------------
/.github/assets/user-profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/.github/assets/user-profile.png
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Liron Abutbul
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/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 | export const metadata: Metadata = {
10 | title: "Threads",
11 | description: "A Next.js 13 Meta Threads Application clone",
12 | };
13 |
14 | const inter = Inter({ subsets: ["latin"] });
15 |
16 | export default function RootLayout({
17 | children,
18 | }: {
19 | children: React.ReactNode;
20 | }) {
21 | return (
22 |
27 |
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/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;
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?.username || user?.username,
18 | name: userInfo?.name || user?.firstName || "",
19 | bio: userInfo?.bio || "",
20 | image: userInfo?.image || user?.imageUrl,
21 | };
22 |
23 | return (
24 |
25 | Onboarding
26 |
27 | Complete your profile now to use Threads
28 |
29 |
30 |
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 | import { truncateString, formatDateWithMeasure } from "@/lib/utils";
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 activity = await getActivity(userInfo._id);
17 |
18 | return (
19 | <>
20 |
Activity
21 |
22 |
23 | {activity.length > 0 ? (
24 | <>
25 | {activity.map((activity: any) => (
26 |
33 |
34 |
41 |
48 |
49 |
50 | ))}
51 | >
52 | ) : (
53 | No activity yet
54 | )}
55 |
56 | >
57 | );
58 | }
59 |
60 | const ActivityComponent = ({ author, createdAt, activityType, text }: any) => (
61 |
62 |
63 | {author.name}
64 | {" "}
65 | <>
66 | {activityType === "follow" && "followed you"}
67 | {activityType === "reaction" && "like your thread"}
68 | {text && `replied to your thread: "${truncateString(text, 100)}"`}
69 | >{" "}
70 | ~ {formatDateWithMeasure(createdAt)}
71 |
72 | );
73 |
74 | export default Page;
75 |
--------------------------------------------------------------------------------
/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 |
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 | // Fetch communities
23 | const result = await fetchCommunities({
24 | searchTerm: searchParams.q,
25 | pageNumber: searchParams?.page ? +searchParams.page : 1,
26 | pageSize: 25,
27 | });
28 |
29 | return (
30 | <>
31 | Communities
32 |
33 |
34 |
35 |
36 |
37 |
38 | {result.communities.length === 0 ? (
39 | No communities found
40 | ) : (
41 | <>
42 | {result.communities.map((community) => (
43 |
52 | ))}
53 | >
54 | )}
55 |
56 |
57 |
62 | >
63 | );
64 | }
65 |
66 | export default Page;
67 |
--------------------------------------------------------------------------------
/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)/edit-thread/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import PostThread from "@/components/forms/PostThread";
2 | import { fetchThreadById } from "@/lib/actions/thread.actions";
3 | import { fetchUser } from "@/lib/actions/user.actions";
4 | import { currentUser } from "@clerk/nextjs";
5 | import { redirect } from "next/navigation";
6 | import React from "react";
7 |
8 | const Page = async ({ params }: { params: { id: string } }) => {
9 | if (!params.id) return null;
10 |
11 | const user = await currentUser();
12 | if (!user) return null;
13 |
14 | const userInfo = await fetchUser(user.id);
15 | if (!userInfo?.onboarded) redirect("/onboarding");
16 |
17 | const thread = await fetchThreadById(params.id);
18 |
19 | return (
20 | <>
21 | Edit Thread
22 |
23 |
28 | >
29 | );
30 | };
31 |
32 | export default Page;
33 |
--------------------------------------------------------------------------------
/app/(root)/explore/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 { fetchExplore, getReactionsData } from "@/lib/actions/thread.actions";
8 | import { fetchUser } from "@/lib/actions/user.actions";
9 |
10 | async function Explore({
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 fetchExplore({
22 | userId: user.id,
23 | pageNumber: searchParams?.page ? +searchParams.page : 1,
24 | pageSize: 30,
25 | });
26 |
27 | const reactionsData = await getReactionsData({
28 | userId: userInfo._id,
29 | posts: result.posts,
30 | });
31 |
32 | const { childrenReactions, childrenReactionState } = reactionsData;
33 |
34 | return (
35 | <>
36 | Explore
37 |
38 |
39 | {result.posts.length === 0 ? (
40 | No threads found
41 | ) : (
42 | <>
43 | {result.posts.map((post, idx) => (
44 |
57 | ))}
58 | >
59 | )}
60 |
61 |
62 |
67 | >
68 | );
69 | }
70 |
71 | export default Explore;
72 |
--------------------------------------------------------------------------------
/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 |
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, getReactionsData } 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 | const reactionsData = await getReactionsData({
27 | userId: userInfo._id,
28 | posts: result.posts,
29 | });
30 |
31 | const { childrenReactions, childrenReactionState } = reactionsData;
32 |
33 | return (
34 | <>
35 | Home
36 |
37 |
38 | {result.posts.length === 0 ? (
39 | No threads found
40 | ) : (
41 | <>
42 | {result.posts.map((post, idx) => (
43 |
56 | ))}
57 | >
58 | )}
59 |
60 |
61 |
66 | >
67 | );
68 | }
69 |
70 | export default Home;
71 |
--------------------------------------------------------------------------------
/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 {
12 | fetchUser,
13 | fetchUsersByField,
14 | isUserFollowing,
15 | } from "@/lib/actions/user.actions";
16 | import UserCard from "@/components/cards/UserCard";
17 |
18 | async function Page({ params }: { params: { id: string } }) {
19 | const user = await currentUser();
20 | if (!user) return null;
21 |
22 | const userInfo = await fetchUser(params.id);
23 | if (!userInfo?.onboarded) redirect("/onboarding");
24 |
25 | const followers = await fetchUsersByField(params.id, "followers");
26 | const following = await fetchUsersByField(params.id, "following");
27 |
28 | const isFollowing = await isUserFollowing(user.id, params.id);
29 |
30 | return (
31 |
32 |
41 |
42 |
43 |
44 |
45 | {profileTabs.map((tab) => (
46 |
47 |
54 | {tab.label}
55 | {tab.label === "Threads" && (
56 |
57 | {userInfo.threadsCount}
58 |
59 | )}
60 | {tab.label === "Followers" && (
61 |
62 | {userInfo.followersCount}
63 |
64 | )}
65 | {tab.label === "Following" && (
66 |
67 | {userInfo.followingCount}
68 |
69 | )}
70 |
71 | ))}
72 |
73 |
74 |
75 | {/* @ts-ignore */}{" "}
76 | {userInfo.threadsCount === 0 ? (
77 |
78 |
No threads found
79 |
80 | ) : (
81 |
86 | )}
87 |
88 |
89 |
90 |
91 | {userInfo.followersCount === 0 ? (
92 |
No users found
93 | ) : (
94 | <>
95 | {followers.map((follower: any) => (
96 |
104 | ))}
105 | >
106 | )}
107 |
108 |
109 |
110 |
111 |
112 | {userInfo.followingCount === 0 ? (
113 |
No users found
114 | ) : (
115 | <>
116 | {following.map((following: any) => (
117 |
125 | ))}
126 | >
127 | )}
128 |
129 |
130 |
131 |
132 |
133 | );
134 | }
135 | export default Page;
136 |
--------------------------------------------------------------------------------
/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 |
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 | searchTerm: 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 users found
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 {
9 | fetchThreadById,
10 | getReactionsData,
11 | } from "@/lib/actions/thread.actions";
12 |
13 | async function Page({ params }: { params: { id: string } }) {
14 | if (!params.id) return null;
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 thread = await fetchThreadById(params.id);
23 |
24 | const reactionsData = await getReactionsData({
25 | userId: userInfo._id,
26 | posts: thread.children,
27 | parentId: thread._id,
28 | });
29 |
30 | const {
31 | parentReactions,
32 | parentReactionState,
33 | childrenReactions,
34 | childrenReactionState,
35 | } = reactionsData;
36 |
37 | return (
38 |
39 |
40 |
52 |
53 |
54 |
55 |
60 |
61 |
62 |
63 | {thread.children.map((childItem: any, idx: number) => (
64 |
78 | ))}
79 |
80 |
81 | );
82 | }
83 |
84 | export default Page;
85 |
--------------------------------------------------------------------------------
/app/(root)/thread/reactions/[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 {
9 | fetchThreadById,
10 | getReactedUsersByThread,
11 | isThreadReactedByUser,
12 | } from "@/lib/actions/thread.actions";
13 | import UserCard from "@/components/cards/UserCard";
14 |
15 | export const revalidate = 0;
16 |
17 | async function page({ params }: { params: { id: string } }) {
18 | if (!params.id) return null;
19 |
20 | const user = await currentUser();
21 | if (!user) return null;
22 |
23 | const userInfo = await fetchUser(user.id);
24 | if (!userInfo?.onboarded) redirect("/onboarding");
25 |
26 | const thread = await fetchThreadById(params.id);
27 |
28 | const reactions = await getReactedUsersByThread(thread._id);
29 |
30 | const reactionState = await isThreadReactedByUser({
31 | threadId: thread._id,
32 | userId: userInfo._id,
33 | });
34 |
35 | return (
36 |
37 |
38 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
61 |
People who likes
62 | {thread.reactionsCount === 0 ? (
63 |
No users found
64 | ) : (
65 | <>
66 | {reactions.users.map((reaction: any) => (
67 |
75 | ))}
76 | >
77 | )}
78 |
79 |
80 | );
81 | }
82 |
83 | export default page;
84 |
--------------------------------------------------------------------------------
/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 | };
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ladunjexa/nextjs13-threads/be6339e60fc907bd25426ac38528a4afbeb26532/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 | .follow-card_btn {
54 | @apply rounded-lg bg-primary-500 px-3 py-1.5 text-small-regular !text-light-1 !important;
55 | }
56 |
57 | .searchbar {
58 | @apply flex gap-1 rounded-lg bg-dark-3 px-4 py-2;
59 | }
60 |
61 | .searchbar_input {
62 | @apply border-none bg-dark-3 text-base-regular text-light-4 outline-none !important;
63 | }
64 |
65 | .topbar {
66 | @apply fixed top-0 z-30 flex w-full items-center justify-between bg-dark-2 px-6 py-3;
67 | }
68 |
69 | .bottombar {
70 | @apply fixed bottom-0 z-10 w-full rounded-t-3xl bg-glassmorphism p-4 backdrop-blur-lg xs:px-7 md:hidden;
71 | }
72 |
73 | .bottombar_container {
74 | @apply flex items-center justify-between gap-3 xs:gap-5;
75 | }
76 |
77 | .bottombar_link {
78 | @apply relative flex flex-col items-center gap-2 rounded-lg p-2 sm:flex-1 sm:px-2 sm:py-2.5;
79 | }
80 |
81 | .leftsidebar {
82 | @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;
83 | }
84 |
85 | .leftsidebar_link {
86 | @apply relative flex justify-start gap-4 rounded-lg p-4;
87 | }
88 |
89 | .pagination {
90 | @apply mt-10 flex w-full items-center justify-center gap-5;
91 | }
92 |
93 | .rightsidebar {
94 | @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;
95 | }
96 | }
97 |
98 | @layer utilities {
99 | .css-invert {
100 | @apply invert-[50%] brightness-200;
101 | }
102 |
103 | .custom-scrollbar::-webkit-scrollbar {
104 | width: 3px;
105 | height: 3px;
106 | border-radius: 2px;
107 | }
108 |
109 | .custom-scrollbar::-webkit-scrollbar-track {
110 | background: #09090a;
111 | }
112 |
113 | .custom-scrollbar::-webkit-scrollbar-thumb {
114 | background: #5c5c7b;
115 | border-radius: 50px;
116 | }
117 |
118 | .custom-scrollbar::-webkit-scrollbar-thumb:hover {
119 | background: #7878a3;
120 | }
121 | }
122 |
123 | /* Clerk Responsive fix */
124 | .cl-organizationSwitcherTrigger .cl-userPreview .cl-userPreviewTextContainer {
125 | @apply max-sm:hidden;
126 | }
127 |
128 | .cl-organizationSwitcherTrigger
129 | .cl-organizationPreview
130 | .cl-organizationPreviewTextContainer {
131 | @apply max-sm:hidden;
132 | }
133 |
134 | /* Shadcn Component Styles */
135 |
136 | /* Tab */
137 | .tab {
138 | @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;
139 | }
140 |
141 | .no-focus {
142 | @apply focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 !important;
143 | }
144 |
145 | /* Account Profile */
146 | .account-form_image-label {
147 | @apply flex h-24 w-24 items-center justify-center rounded-full bg-dark-4 !important;
148 | }
149 |
150 | .account-form_image-input {
151 | @apply cursor-pointer border-none bg-transparent outline-none file:text-blue !important;
152 | }
153 |
154 | .account-form_input {
155 | @apply border border-dark-4 bg-dark-3 text-light-1 !important;
156 | }
157 |
158 | /* Comment Form */
159 | .comment-form {
160 | @apply mt-10 flex items-center gap-4 border-y border-y-dark-4 py-5 max-xs:flex-col !important;
161 | }
162 |
163 | .comment-form_btn {
164 | @apply rounded-3xl bg-primary-500 px-8 py-2 !text-small-regular text-light-1 max-xs:w-full !important;
165 | }
166 |
167 | .bg-clerk-auth {
168 | @apply bg-[linear-gradient(_to_right_top,#d16ba5,#c262a6,#b25aa7,#a054a8,#8c4ea9,#775fbe,#596fce,#257dda,#00a0ef,#00c1f7,#00dff6,#5ffbf1_)];
169 | }
170 |
--------------------------------------------------------------------------------
/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/atoms/EditThread.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 | import React from "react";
4 |
5 | interface Props {
6 | threadId: string;
7 | currentUserId: string;
8 | authorId: string;
9 | }
10 |
11 | const EditThread = ({ threadId, currentUserId, authorId }: Props) => {
12 | if (currentUserId !== authorId) return null;
13 |
14 | return (
15 |
16 |
23 |
24 | );
25 | };
26 |
27 | export default EditThread;
28 |
--------------------------------------------------------------------------------
/components/atoms/FollowUser.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import React from "react";
5 | import { Button } from "../ui/button";
6 | import { followUser } from "@/lib/actions/user.actions";
7 | import { usePathname } from "next/navigation";
8 |
9 | interface Props {
10 | userId: string;
11 | currentUserId: string;
12 | isFollowing?: boolean;
13 | }
14 |
15 | const FollowUser = ({ userId, currentUserId, isFollowing = false }: Props) => {
16 | const pathname = usePathname();
17 |
18 | const handleClick = async () => {
19 | await followUser({
20 | followerId: currentUserId,
21 | followedId: userId,
22 | path: pathname,
23 | });
24 | };
25 |
26 | return (
27 |
36 | );
37 | };
38 |
39 | export default FollowUser;
40 |
--------------------------------------------------------------------------------
/components/atoms/ReactThread.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import React from "react";
5 | import { usePathname } from "next/navigation";
6 | import { addReactToThread } from "@/lib/actions/thread.actions";
7 |
8 | interface Props {
9 | threadId: string;
10 | currentUserId: string;
11 | interactState?: boolean;
12 | isComment?: boolean;
13 | parentId?: string | null;
14 | }
15 |
16 | const ReactThread = ({
17 | threadId,
18 | currentUserId,
19 | interactState = false,
20 | isComment = false,
21 | parentId = null,
22 | }: Props) => {
23 | const pathname = usePathname();
24 |
25 | const handleClick = async () => {
26 | await addReactToThread({
27 | threadId,
28 | userId: currentUserId,
29 | path: pathname,
30 | });
31 | };
32 |
33 | return (
34 |
42 | );
43 | };
44 |
45 | export default ReactThread;
46 |
--------------------------------------------------------------------------------
/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 |
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 |
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 | import EditThread from "../atoms/EditThread";
7 | import ReactThread from "../atoms/ReactThread";
8 |
9 | interface Props {
10 | id: string;
11 | currentUserId: string;
12 | parentId: string | null;
13 | content: string;
14 | author: {
15 | name: string;
16 | image: string;
17 | id: string;
18 | };
19 | community: {
20 | id: string;
21 | name: string;
22 | image: string;
23 | } | null;
24 | createdAt: string;
25 | comments: {
26 | author: {
27 | image: string;
28 | };
29 | }[];
30 | reactions: {
31 | image: string;
32 | _id: string;
33 | id: string;
34 | name: string;
35 | username: string;
36 | }[];
37 | isComment?: boolean;
38 | reactState?: boolean;
39 | }
40 |
41 | function ThreadCard({
42 | id,
43 | currentUserId,
44 | parentId,
45 | content,
46 | author,
47 | community,
48 | createdAt,
49 | comments,
50 | reactions,
51 | isComment,
52 | reactState,
53 | }: Props) {
54 | return (
55 |
60 |
61 |
62 |
63 |
64 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | {author.name}
79 |
80 |
81 |
82 |
{content}
83 |
84 |
85 |
86 |
93 |
94 |
101 |
102 |
109 |
116 |
117 |
118 |
119 | {isComment && (
120 | <>
121 | {comments.length > 0 && (
122 |
123 |
124 | {comments.length}{" "}
125 | {comments.length > 1 ? "replies" : "reply"}
126 |
127 |
128 | )}
129 |
130 | {comments.length > 0 && reactions.length > 0 && (
131 |
•
132 | )}
133 |
134 | {reactions.length > 0 && (
135 |
136 |
137 | {reactions.length}{" "}
138 | {reactions.length > 1 ? "likes" : "like"}
139 |
140 |
141 | )}
142 | >
143 | )}
144 |
145 |
146 |
147 |
148 |
149 |
150 |
157 |
162 |
163 |
164 |
165 |
166 | {!isComment && (
167 | <>
168 | {comments.length > 0 && (
169 |
170 | {comments.slice(0, 2).map((comment, index) => (
171 |
181 | ))}
182 |
183 |
184 |
185 | {comments.length}{" "}
186 | {comments.length > 1 ? "replies" : "reply"}
187 |
188 |
189 |
190 | )}
191 |
192 | {/* {comments.length > 0 && reactions.length > 0 && (
193 |
196 | )} */}
197 |
198 | {reactions.length > 0 && (
199 |
200 | {reactions.slice(0, 2).map((reaction, index) => (
201 |
211 | ))}
212 |
213 |
214 |
215 | {reactions.length} {reactions.length > 1 ? "likes" : "like"}
216 |
217 |
218 |
219 | )}
220 | >
221 | )}
222 |
223 |
224 | {!isComment && community && (
225 |
229 |
230 | {formatDateString(createdAt)}
231 | {community && ` - ${community.name} Community`}
232 |
233 |
234 |
241 |
242 | )}
243 |
244 | );
245 | }
246 |
247 | export default ThreadCard;
248 |
--------------------------------------------------------------------------------
/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 | return (
20 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
{name}
33 |
@{username}
34 |
35 |
36 |
37 |
45 |
46 | );
47 | }
48 |
49 | export default UserCard;
50 |
--------------------------------------------------------------------------------
/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 |
216 |
217 | );
218 | };
219 |
220 | export default AccountProfile;
221 |
--------------------------------------------------------------------------------
/components/forms/Comment.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { z } from "zod";
4 | import Image from "next/image";
5 | import { useForm } from "react-hook-form";
6 | import { usePathname } from "next/navigation";
7 | import { zodResolver } from "@hookform/resolvers/zod";
8 |
9 | import {
10 | Form,
11 | FormControl,
12 | FormField,
13 | FormItem,
14 | FormLabel,
15 | } from "@/components/ui/form";
16 |
17 | import { Input } from "../ui/input";
18 | import { Button } from "../ui/button";
19 |
20 | import { CommentValidation } from "@/lib/validations/thread";
21 | import { addCommentToThread } from "@/lib/actions/thread.actions";
22 |
23 | interface Props {
24 | threadId: string;
25 | currentUserImg: string;
26 | currentUserId: string;
27 | }
28 |
29 | function Comment({ threadId, currentUserImg, currentUserId }: Props) {
30 | const pathname = usePathname();
31 |
32 | const form = useForm>({
33 | resolver: zodResolver(CommentValidation),
34 | defaultValues: {
35 | thread: "",
36 | },
37 | });
38 |
39 | const onSubmit = async (values: z.infer) => {
40 | await addCommentToThread({
41 | threadId,
42 | commentText: values.thread,
43 | userId: JSON.parse(currentUserId),
44 | path: pathname,
45 | });
46 |
47 | form.reset();
48 | };
49 |
50 | return (
51 |
83 |
84 | );
85 | }
86 |
87 | export default Comment;
88 |
--------------------------------------------------------------------------------
/components/forms/DeleteThread.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { usePathname, useRouter } from "next/navigation";
5 |
6 | import { deleteThread } from "@/lib/actions/thread.actions";
7 |
8 | interface Props {
9 | threadId: string;
10 | currentUserId: string;
11 | authorId: string;
12 | parentId: string | null;
13 | isComment?: boolean;
14 | }
15 |
16 | function DeleteThread({
17 | threadId,
18 | currentUserId,
19 | authorId,
20 | parentId,
21 | isComment,
22 | }: Props) {
23 | const pathname = usePathname();
24 | const router = useRouter();
25 |
26 | if (currentUserId !== authorId) return null;
27 |
28 | const handleClick = async () => {
29 | await deleteThread(JSON.parse(threadId), pathname);
30 | if (!parentId || !isComment) {
31 | router.push("/");
32 | }
33 | };
34 | return (
35 |
43 | );
44 | }
45 |
46 | export default DeleteThread;
47 |
--------------------------------------------------------------------------------
/components/forms/PostThread.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as z from "zod";
4 | import { useForm } from "react-hook-form";
5 | import { useOrganization } from "@clerk/nextjs";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { usePathname, useRouter } from "next/navigation";
8 |
9 | import {
10 | Form,
11 | FormControl,
12 | FormField,
13 | FormItem,
14 | FormLabel,
15 | FormMessage,
16 | } from "@/components/ui/form";
17 | import { Button } from "@/components/ui/button";
18 | import { Textarea } from "@/components/ui/textarea";
19 |
20 | import { ThreadValidation } from "@/lib/validations/thread";
21 | import { createThread, editThread } from "@/lib/actions/thread.actions";
22 |
23 | interface Props {
24 | userId: string;
25 | threadId?: string;
26 | threadText?: string;
27 | }
28 |
29 | function PostThread({ userId, threadId, threadText }: Props) {
30 | const router = useRouter();
31 | const pathname = usePathname();
32 |
33 | const { organization } = useOrganization();
34 |
35 | const form = useForm>({
36 | resolver: zodResolver(ThreadValidation),
37 | defaultValues: {
38 | thread: threadText || "",
39 | accountId: userId,
40 | },
41 | });
42 |
43 | const onSubmit = async (values: z.infer) => {
44 | if (threadId && threadText) {
45 | await editThread({
46 | threadId,
47 | text: values.thread,
48 | path: pathname,
49 | });
50 | } else {
51 | await createThread({
52 | text: values.thread,
53 | author: userId,
54 | communityId: organization ? organization.id : null,
55 | path: pathname,
56 | });
57 | }
58 |
59 | router.push("/");
60 | };
61 |
62 | return (
63 |
88 |
89 | );
90 | }
91 |
92 | export default PostThread;
93 |
--------------------------------------------------------------------------------
/components/shared/Bottombar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 |
7 | import { sidebarLinks } from "@/constants";
8 |
9 | function Bottombar() {
10 | const pathname = usePathname();
11 |
12 | return (
13 |
14 |
15 | {sidebarLinks.map((link) => {
16 | const isActive =
17 | (pathname.includes(link.route) && link.route.length > 1) ||
18 | pathname === link.route;
19 |
20 | return (
21 |
26 |
33 |
34 |
35 | {link.label.split(/\s+/)[0]}
36 |
37 |
38 | );
39 | })}
40 |
41 |
42 | );
43 | }
44 |
45 | export default Bottombar;
46 |
--------------------------------------------------------------------------------
/components/shared/LeftSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import Link from "next/link";
5 | import { usePathname, useRouter } from "next/navigation";
6 | import { SignOutButton, SignedIn, useAuth } from "@clerk/nextjs";
7 |
8 | import { sidebarLinks } from "@/constants";
9 |
10 | const LeftSidebar = () => {
11 | const router = useRouter();
12 | const pathname = usePathname();
13 |
14 | const { userId } = useAuth();
15 |
16 | return (
17 |
18 |
19 | {sidebarLinks.map((link) => {
20 | const isActive =
21 | (pathname.includes(link.route) && link.route.length > 1) ||
22 | pathname === link.route;
23 |
24 | if (link.route === "/profile") link.route = `${link.route}/${userId}`;
25 |
26 | return (
27 |
32 |
38 |
39 |
{link.label}
40 |
41 | );
42 | })}
43 |
44 |
45 |
46 |
47 | router.push("/sign-in")}>
48 |
49 |
55 |
56 |
Logout
57 |
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default LeftSidebar;
66 |
--------------------------------------------------------------------------------
/components/shared/Pagination.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 |
5 | import { Button } from "../ui/button";
6 |
7 | interface Props {
8 | pageNumber: number;
9 | isNext: boolean;
10 | path: string;
11 | }
12 |
13 | const Pagination = ({ pageNumber, isNext, path }: Props) => {
14 | const router = useRouter();
15 |
16 | const handleNavigation = (type: string) => {
17 | let nextPageNumber = pageNumber;
18 |
19 | if (type === "prev") {
20 | nextPageNumber = Math.max(1, pageNumber - 1);
21 | } else if (type === "next") {
22 | nextPageNumber = pageNumber + 1;
23 | }
24 |
25 | if (nextPageNumber > 1) {
26 | router.push(`/${path}?page=${nextPageNumber}`);
27 | } else {
28 | router.push(`/${path}`);
29 | }
30 |
31 | if (!isNext && pageNumber === 1) return null;
32 | };
33 |
34 | return (
35 |
36 |
43 |
{pageNumber}
44 |
51 |
52 | );
53 | };
54 |
55 | export default Pagination;
56 |
--------------------------------------------------------------------------------
/components/shared/ProfileHeader.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import FollowUser from "../atoms/FollowUser";
4 |
5 | interface Props {
6 | accountId: string;
7 | authUserId: string;
8 | name: string;
9 | username: string;
10 | imgUrl: string;
11 | bio: string;
12 | type?: string;
13 | isFollowing?: boolean;
14 | }
15 |
16 | function ProfileHeader({
17 | accountId,
18 | authUserId,
19 | name,
20 | username,
21 | imgUrl,
22 | bio,
23 | type,
24 | isFollowing,
25 | }: Props) {
26 | return (
27 |
28 |
29 |
30 |
31 |
37 |
38 |
39 |
40 |
41 | {name}
42 |
43 |
@{username}
44 |
45 |
46 | {type !== "Community" && (
47 |
48 | <>
49 | {accountId === authUserId ? (
50 |
51 |
61 |
62 | ) : (
63 |
68 | )}
69 | >
70 |
71 | )}
72 |
73 |
74 |
{bio}
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | export default ProfileHeader;
82 |
--------------------------------------------------------------------------------
/components/shared/RightSidebar.tsx:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@clerk/nextjs";
2 |
3 | import UserCard from "../cards/UserCard";
4 |
5 | import { fetchCommunities } from "@/lib/actions/community.actions";
6 | import { fetchUsers } from "@/lib/actions/user.actions";
7 |
8 | async function RightSidebar() {
9 | const user = await currentUser();
10 | if (!user) return null;
11 |
12 | const similarMinds = await fetchUsers({
13 | userId: user.id,
14 | pageSize: 4,
15 | });
16 |
17 | const suggestedCommunities = await fetchCommunities({ pageSize: 4 });
18 |
19 | return (
20 |
21 |
22 |
23 | Suggested Communities
24 |
25 |
26 |
27 | {suggestedCommunities.communities.length > 0 ? (
28 | <>
29 | {suggestedCommunities.communities.map((community) => (
30 |
38 | ))}
39 | >
40 | ) : (
41 |
42 | No communities yet
43 |
44 | )}
45 |
46 |
47 |
48 |
Similar Minds
49 |
50 | {similarMinds.users.length > 0 ? (
51 | <>
52 | {similarMinds.users.map((person) => (
53 |
61 | ))}
62 | >
63 | ) : (
64 |
No users yet
65 | )}
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | export default RightSidebar;
--------------------------------------------------------------------------------
/components/shared/Searchbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { useRouter } from "next/navigation";
5 | import { useState, useEffect } from "react";
6 |
7 | import { Input } from "../ui/input";
8 |
9 | interface Props {
10 | routeType: string;
11 | }
12 |
13 | function Searchbar({ routeType }: Props) {
14 | const router = useRouter();
15 | const [searchTerm, setSearchTerm] = useState("");
16 |
17 | useEffect(() => {
18 | const delayDebounceFn = setTimeout(() => {
19 | if (searchTerm) router.push(`/${routeType}?q=` + searchTerm);
20 | else {
21 | router.push(`/${routeType}`);
22 | }
23 | }, 300);
24 |
25 | return () => clearTimeout(delayDebounceFn);
26 | }, [searchTerm, routeType]);
27 |
28 | return (
29 |
30 |
37 |
38 | setSearchTerm(e.target.value)}
42 | placeholder={`${
43 | routeType !== "search" ? "Search Communities" : "Search Users"
44 | }`}
45 | className="no-focus searchbar_input"
46 | />
47 |
48 | );
49 | }
50 |
51 | export default Searchbar;
52 |
--------------------------------------------------------------------------------
/components/shared/ThreadsTab.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { fetchCommunityPosts } from "@/lib/actions/community.actions";
4 | import { fetchUser, fetchUserPosts } from "@/lib/actions/user.actions";
5 |
6 | import ThreadCard from "../cards/ThreadCard";
7 | import { getReactionsData } from "@/lib/actions/thread.actions";
8 | import { currentUser } from "@clerk/nextjs";
9 |
10 | interface Result {
11 | name: string;
12 | image: string;
13 | id: string;
14 | threads: {
15 | _id: string;
16 | text: string;
17 | parentId: string | null;
18 | author: {
19 | name: string;
20 | image: string;
21 | id: string;
22 | };
23 | community: {
24 | id: string;
25 | name: string;
26 | image: string;
27 | } | null;
28 | createdAt: string;
29 | children: {
30 | author: {
31 | image: string;
32 | };
33 | }[];
34 | }[];
35 | }
36 |
37 | interface Props {
38 | currentUserId: string;
39 | accountId: string;
40 | accountType: string;
41 | }
42 |
43 | async function ThreadsTab({ currentUserId, accountId, accountType }: Props) {
44 | let result: Result;
45 |
46 | if (accountType === "Community") {
47 | result = await fetchCommunityPosts(accountId);
48 | } else {
49 | result = await fetchUserPosts(accountId);
50 | }
51 |
52 | if (!result) {
53 | redirect("/");
54 | }
55 |
56 | const user = await currentUser();
57 | if (!user) return null;
58 |
59 | const userInfo = await fetchUser(user.id);
60 | if (!userInfo?.onboarded) redirect("/onboarding");
61 |
62 | const reactionsData = await getReactionsData({
63 | userId: userInfo._id,
64 | posts: result.threads,
65 | });
66 |
67 | const { childrenReactions, childrenReactionState } = reactionsData;
68 |
69 | return (
70 |
71 | {result.threads.map((thread, idx) => (
72 |
97 | ))}
98 |
99 | );
100 | }
101 |
102 | export default ThreadsTab;
103 |
--------------------------------------------------------------------------------
/components/shared/Topbar.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import { OrganizationSwitcher, SignOutButton, SignedIn } from "@clerk/nextjs";
4 | import { dark } from "@clerk/themes";
5 |
6 | function Topbar() {
7 | return (
8 |
40 | );
41 | }
42 |
43 | export default Topbar;
44 |
--------------------------------------------------------------------------------
/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-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
13 | destructive:
14 | "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
15 | outline:
16 | "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
17 | secondary:
18 | "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
19 | ghost: "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
20 | link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
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/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 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/constants/index.js:
--------------------------------------------------------------------------------
1 | export const sidebarLinks = [
2 | {
3 | imgURL: "/assets/home.svg",
4 | route: "/",
5 | label: "Home",
6 | },
7 | {
8 | imgURL: "/assets/explore.svg",
9 | route: "/explore",
10 | label: "Explore",
11 | },
12 | {
13 | imgURL: "/assets/search.svg",
14 | route: "/search",
15 | label: "Search",
16 | },
17 | {
18 | imgURL: "/assets/heart.svg",
19 | route: "/activity",
20 | label: "Activity",
21 | },
22 | {
23 | imgURL: "/assets/create.svg",
24 | route: "/create-thread",
25 | label: "Create Thread",
26 | },
27 | {
28 | imgURL: "/assets/community.svg",
29 | route: "/communities",
30 | label: "Communities",
31 | },
32 | {
33 | imgURL: "/assets/user.svg",
34 | route: "/profile",
35 | label: "Profile",
36 | },
37 | ];
38 |
39 | export const profileTabs = [
40 | { value: "threads", label: "Threads", icon: "/assets/reply.svg" },
41 | { value: "followers", label: "Followers", icon: "/assets/members.svg" },
42 | { value: "following", label: "Following", icon: "/assets/members.svg" },
43 | // { value: "replies", label: "Replies", icon: "/assets/members.svg" },
44 | // { value: "tagged", label: "Tagged", icon: "/assets/tag.svg" },
45 | ];
46 |
47 | export const communityTabs = [
48 | { value: "threads", label: "Threads", icon: "/assets/reply.svg" },
49 | { value: "members", label: "Members", icon: "/assets/members.svg" },
50 | { value: "requests", label: "Requests", icon: "/assets/request.svg" },
51 | ];
52 |
--------------------------------------------------------------------------------
/lib/actions/community.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { FilterQuery, SortOrder } from "mongoose";
4 |
5 | import Community from "../models/community.model";
6 | import Thread from "../models/thread.model";
7 | import User from "../models/user.model";
8 |
9 | import { connectToDB } from "../mongoose";
10 |
11 | export async function createCommunity(
12 | id: string,
13 | name: string,
14 | username: string,
15 | image: string,
16 | bio: string,
17 | createdById: string // Change the parameter name to reflect it's an id
18 | ) {
19 | try {
20 | connectToDB();
21 |
22 | // Find the user with the provided unique id
23 | const user = await User.findOne({ id: createdById });
24 |
25 | if (!user) {
26 | throw new Error("User not found"); // Handle the case if the user with the id is not found
27 | }
28 |
29 | const newCommunity = new Community({
30 | id,
31 | name,
32 | username,
33 | image,
34 | bio,
35 | createdBy: user._id, // Use the mongoose ID of the user
36 | });
37 |
38 | const createdCommunity = await newCommunity.save();
39 |
40 | // Update User model
41 | user.communities.push(createdCommunity._id);
42 | await user.save();
43 |
44 | return createdCommunity;
45 | } catch (error) {
46 | // Handle any errors
47 | console.error("Error creating community:", error);
48 | throw error;
49 | }
50 | }
51 |
52 | export async function fetchCommunityDetails(id: string) {
53 | try {
54 | connectToDB();
55 |
56 | const communityDetails = await Community.findOne({ id }).populate([
57 | "createdBy",
58 | {
59 | path: "members",
60 | model: User,
61 | select: "name username image _id id",
62 | },
63 | ]);
64 |
65 | return communityDetails;
66 | } catch (error) {
67 | // Handle any errors
68 | console.error("Error fetching community details:", error);
69 | throw error;
70 | }
71 | }
72 |
73 | export async function fetchCommunityPosts(id: string) {
74 | try {
75 | connectToDB();
76 |
77 | const communityPosts = await Community.findById(id).populate({
78 | path: "threads",
79 | model: Thread,
80 | populate: [
81 | {
82 | path: "author",
83 | model: User,
84 | select: "name image id", // Select the "name" and "_id" fields from the "User" model
85 | },
86 | {
87 | path: "children",
88 | model: Thread,
89 | populate: {
90 | path: "author",
91 | model: User,
92 | select: "image _id", // Select the "name" and "_id" fields from the "User" model
93 | },
94 | },
95 | ],
96 | });
97 |
98 | return communityPosts;
99 | } catch (error) {
100 | // Handle any errors
101 | console.error("Error fetching community posts:", error);
102 | throw error;
103 | }
104 | }
105 |
106 | export async function fetchCommunities({
107 | searchTerm = "",
108 | pageNumber = 1,
109 | pageSize = 20,
110 | sortBy = "desc",
111 | }: {
112 | searchTerm?: string;
113 | pageNumber?: number;
114 | pageSize?: number;
115 | sortBy?: SortOrder;
116 | }) {
117 | try {
118 | connectToDB();
119 |
120 | // Calculate the number of communities to skip based on the page number and page size.
121 | const skipAmount = (pageNumber - 1) * pageSize;
122 |
123 | // Create a case-insensitive regular expression for the provided search string.
124 | const regex = new RegExp(searchTerm, "i");
125 |
126 | // Create an initial query object to filter communities.
127 | const query: FilterQuery = {};
128 |
129 | // If the search string is not empty, add the $or operator to match either username or name fields.
130 | if (searchTerm.trim() !== "") {
131 | query.$or = [
132 | { username: { $regex: regex } },
133 | { name: { $regex: regex } },
134 | ];
135 | }
136 |
137 | // Define the sort options for the fetched communities based on createdAt field and provided sort order.
138 | const sortOptions = { createdAt: sortBy };
139 |
140 | // Create a query to fetch the communities based on the search and sort criteria.
141 | const communitiesQuery = Community.find(query)
142 | .sort(sortOptions)
143 | .skip(skipAmount)
144 | .limit(pageSize)
145 | .populate("members");
146 |
147 | // Count the total number of communities that match the search criteria (without pagination).
148 | const totalCommunitiesCount = await Community.countDocuments(query);
149 |
150 | const communities = await communitiesQuery.exec();
151 |
152 | // Check if there are more communities beyond the current page.
153 | const isNext = totalCommunitiesCount > skipAmount + communities.length;
154 |
155 | return { communities, isNext };
156 | } catch (error) {
157 | console.error("Error fetching communities:", error);
158 | throw error;
159 | }
160 | }
161 |
162 | export async function addMemberToCommunity(
163 | communityId: string,
164 | memberId: string
165 | ) {
166 | try {
167 | connectToDB();
168 |
169 | // Find the community by its unique id
170 | const community = await Community.findOne({ id: communityId });
171 |
172 | if (!community) {
173 | throw new Error("Community not found");
174 | }
175 |
176 | // Find the user by their unique id
177 | const user = await User.findOne({ id: memberId });
178 |
179 | if (!user) {
180 | throw new Error("User not found");
181 | }
182 |
183 | // Check if the user is already a member of the community
184 | if (community.members.includes(user._id)) {
185 | throw new Error("User is already a member of the community");
186 | }
187 |
188 | // Add the user's _id to the members array in the community
189 | community.members.push(user._id);
190 | await community.save();
191 |
192 | // Add the community's _id to the communities array in the user
193 | user.communities.push(community._id);
194 | await user.save();
195 |
196 | return community;
197 | } catch (error) {
198 | // Handle any errors
199 | console.error("Error adding member to community:", error);
200 | throw error;
201 | }
202 | }
203 |
204 | export async function removeUserFromCommunity(
205 | userId: string,
206 | communityId: string
207 | ) {
208 | try {
209 | connectToDB();
210 |
211 | const userIdObject = await User.findOne({ id: userId }, { _id: 1 });
212 | const communityIdObject = await Community.findOne(
213 | { id: communityId },
214 | { _id: 1 }
215 | );
216 |
217 | if (!userIdObject) {
218 | throw new Error("User not found");
219 | }
220 |
221 | if (!communityIdObject) {
222 | throw new Error("Community not found");
223 | }
224 |
225 | // Remove the user's _id from the members array in the community
226 | await Community.updateOne(
227 | { _id: communityIdObject._id },
228 | { $pull: { members: userIdObject._id } }
229 | );
230 |
231 | // Remove the community's _id from the communities array in the user
232 | await User.updateOne(
233 | { _id: userIdObject._id },
234 | { $pull: { communities: communityIdObject._id } }
235 | );
236 |
237 | return { success: true };
238 | } catch (error) {
239 | // Handle any errors
240 | console.error("Error removing user from community:", error);
241 | throw error;
242 | }
243 | }
244 |
245 | export async function updateCommunityInfo(
246 | communityId: string,
247 | name: string,
248 | username: string,
249 | image: string
250 | ) {
251 | try {
252 | connectToDB();
253 |
254 | // Find the community by its _id and update the information
255 | const updatedCommunity = await Community.findOneAndUpdate(
256 | { id: communityId },
257 | { name, username, image }
258 | );
259 |
260 | if (!updatedCommunity) {
261 | throw new Error("Community not found");
262 | }
263 |
264 | return updatedCommunity;
265 | } catch (error) {
266 | // Handle any errors
267 | console.error("Error updating community information:", error);
268 | throw error;
269 | }
270 | }
271 |
272 | export async function deleteCommunity(communityId: string) {
273 | try {
274 | connectToDB();
275 |
276 | // Find the community by its ID and delete it
277 | const deletedCommunity = await Community.findOneAndDelete({
278 | id: communityId,
279 | });
280 |
281 | if (!deletedCommunity) {
282 | throw new Error("Community not found");
283 | }
284 |
285 | // Delete all threads associated with the community
286 | await Thread.deleteMany({ community: communityId });
287 |
288 | // Find all users who are part of the community
289 | const communityUsers = await User.find({ communities: communityId });
290 |
291 | // Remove the community from the 'communities' array for each user
292 | const updateUserPromises = communityUsers.map((user) => {
293 | user.communities.pull(communityId);
294 | return user.save();
295 | });
296 |
297 | await Promise.all(updateUserPromises);
298 |
299 | return deletedCommunity;
300 | } catch (error) {
301 | console.error("Error deleting community: ", error);
302 | throw error;
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/lib/actions/user.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { FilterQuery, SortOrder } from "mongoose";
4 | import { revalidatePath } from "next/cache";
5 |
6 | import Community from "../models/community.model";
7 | import Thread from "../models/thread.model";
8 | import User from "../models/user.model";
9 |
10 | import { connectToDB } from "../mongoose";
11 |
12 | export async function followUser({
13 | followerId,
14 | followedId,
15 | path,
16 | }: {
17 | followerId: string;
18 | followedId: string;
19 | path: string;
20 | }) {
21 | try {
22 | connectToDB();
23 |
24 | const follower = await User.findOne({ id: followerId });
25 |
26 | if (!follower) {
27 | throw new Error("Follower not found");
28 | }
29 |
30 | const followed = await User.findOne({ id: followedId });
31 |
32 | if (!followed) {
33 | throw new Error("Followed not found");
34 | }
35 |
36 | const isAlreadyFollowed = await isUserFollowing(followerId, followedId);
37 |
38 | if (isAlreadyFollowed) {
39 | follower.following.pull({
40 | user: followed._id,
41 | });
42 | } else {
43 | follower.following.push({
44 | user: followed._id,
45 | });
46 | }
47 |
48 | await follower.save();
49 |
50 | if (isAlreadyFollowed) {
51 | followed.followers.pull({
52 | user: follower._id,
53 | });
54 | } else {
55 | followed.followers.push({
56 | user: follower._id,
57 | });
58 | }
59 |
60 | await followed.save();
61 |
62 | revalidatePath(path);
63 | } catch (error: any) {
64 | throw new Error(`Failed to follow user: ${error.message}`);
65 | }
66 | }
67 |
68 | export async function isUserFollowing(followerId: string, followedId: string) {
69 | try {
70 | connectToDB();
71 |
72 | const followed = await User.findOne({ id: followedId });
73 |
74 | const isFollowing = await User.findOne({
75 | id: followerId,
76 | following: { $elemMatch: { user: followed._id } },
77 | });
78 |
79 | return !!isFollowing;
80 | } catch (error: any) {
81 | throw new Error(`Failed to check if user is followed: ${error.message}`);
82 | }
83 | }
84 |
85 | export async function fetchUser(userId: string) {
86 | try {
87 | connectToDB();
88 |
89 | return await User.findOne({ id: userId }).populate({
90 | path: "communities",
91 | model: Community,
92 | });
93 | } catch (error: any) {
94 | throw new Error(`Failed to fetch user: ${error.message}`);
95 | }
96 | }
97 |
98 | interface Params {
99 | userId: string;
100 | username: string;
101 | name: string;
102 | bio: string;
103 | image: string;
104 | path: string;
105 | }
106 |
107 | export async function updateUser({
108 | userId,
109 | bio,
110 | name,
111 | path,
112 | username,
113 | image,
114 | }: Params): Promise {
115 | try {
116 | connectToDB();
117 |
118 | await User.findOneAndUpdate(
119 | { id: userId },
120 | {
121 | username: username.toLowerCase(),
122 | name,
123 | bio,
124 | image,
125 | onboarded: true,
126 | },
127 | { upsert: true }
128 | );
129 |
130 | if (path === "/profile/edit") {
131 | revalidatePath(path);
132 | }
133 | } catch (error: any) {
134 | throw new Error(`Failed to create/update user: ${error.message}`);
135 | }
136 | }
137 |
138 | export async function fetchUserPosts(userId: string) {
139 | try {
140 | connectToDB();
141 |
142 | // Find all threads authored by the user with the given userId
143 | const threads = await User.findOne({ id: userId }).populate({
144 | path: "threads",
145 | model: Thread,
146 | populate: [
147 | {
148 | path: "community",
149 | model: Community,
150 | select: "name id image _id", // Select the "name" and "_id" fields from the "Community" model
151 | },
152 | {
153 | path: "children",
154 | model: Thread,
155 | populate: {
156 | path: "author",
157 | model: User,
158 | select: "name image id", // Select the "name" and "_id" fields from the "User" model
159 | },
160 | },
161 | ],
162 | });
163 | return threads;
164 | } catch (error) {
165 | console.error("Error fetching user threads:", error);
166 | throw error;
167 | }
168 | }
169 |
170 | export async function getUserFollowersIds(userId: string, key: string) {
171 | try {
172 | connectToDB();
173 |
174 | const user = await User.findOne({ id: userId });
175 |
176 | const followersIds = user[key].map((folower: any) => folower.user);
177 |
178 | return followersIds;
179 | } catch (error: any) {
180 | throw new Error(`Failed to fetch user followers: ${error.message}`);
181 | }
182 | }
183 |
184 | export async function fetchUsersByField(userId: string, field: string) {
185 | try {
186 | connectToDB();
187 |
188 | const user = await User.findOne({ id: userId });
189 |
190 | const usersIds = user[field].map((user: any) => user.user);
191 |
192 | const users = await User.find({ _id: { $in: usersIds } });
193 |
194 | return users;
195 | } catch (error: any) {
196 | throw new Error(`Failed to fetch users: ${error.message}`);
197 | }
198 | }
199 |
200 | // Almost similar to Thead (search + pagination) and Community (search + pagination)
201 | export async function fetchUsers({
202 | userId,
203 | userIds,
204 | searchTerm = "",
205 | pageNumber = 1,
206 | pageSize = 20,
207 | sortBy = "desc",
208 | }: {
209 | userId: string | null;
210 | userIds?: string[];
211 | searchTerm?: string;
212 | pageNumber?: number;
213 | pageSize?: number;
214 | sortBy?: SortOrder;
215 | }) {
216 | try {
217 | connectToDB();
218 |
219 | // Calculate the number of users to skip based on the page number and page size.
220 | const skipAmount = (pageNumber - 1) * pageSize;
221 |
222 | // Create a case-insensitive regular expression for the provided search string.
223 | const regex = new RegExp(searchTerm, "i");
224 |
225 | // Create an initial query object to filter users.
226 | const query: FilterQuery = {
227 | id: { $ne: userId }, // Exclude the current user from the results.
228 | };
229 |
230 | if (userIds) {
231 | query._id = { $in: userIds };
232 | }
233 |
234 | // If the search string is not empty, add the $or operator to match either username or name fields.
235 | if (searchTerm.trim() !== "") {
236 | query.$or = [
237 | { username: { $regex: regex } },
238 | { name: { $regex: regex } },
239 | ];
240 | }
241 |
242 | // Define the sort options for the fetched users based on createdAt field and provided sort order.
243 | const sortOptions = { createdAt: sortBy };
244 |
245 | const usersQuery = User.find(query)
246 | .sort(sortOptions)
247 | .skip(skipAmount)
248 | .limit(pageSize);
249 |
250 | // Count the total number of users that match the search criteria (without pagination).
251 | const totalUsersCount = await User.countDocuments(query);
252 |
253 | const users = await usersQuery.exec();
254 |
255 | // Check if there are more users beyond the current page.
256 | const isNext = totalUsersCount > skipAmount + users.length;
257 |
258 | return { users, isNext };
259 | } catch (error) {
260 | console.error("Error fetching users:", error);
261 | throw error;
262 | }
263 | }
264 |
265 | export async function getActivity(userId: string) {
266 | try {
267 | connectToDB();
268 |
269 | const [userThreads, user] = await Promise.all([
270 | Thread.find({ author: userId }),
271 | User.findOne({ _id: userId }),
272 | ]);
273 |
274 | const childThreadIds = userThreads.flatMap(
275 | (userThread) => userThread.children
276 | );
277 | const reactions = userThreads.flatMap((userThread) => userThread.reactions);
278 |
279 | const [reactionsUsers, followersUsers] = await Promise.all([
280 | User.find({ _id: { $in: reactions.map((reaction) => reaction.user) } }),
281 | User.find({
282 | _id: {
283 | $in: user.followers.map((follower: { user: any }) => follower.user),
284 | },
285 | }),
286 | ]);
287 |
288 | const reactionsData = reactions.map((reaction, index) => {
289 | const reactingUser = reactionsUsers.find(
290 | (user) => user._id.toString() === reaction.user.toString()
291 | );
292 |
293 | if (reactingUser._id.equals(userId)) return null;
294 | return {
295 | author: {
296 | name: reactingUser.name,
297 | username: reactingUser.username,
298 | image: reactingUser.image,
299 | _id: reactingUser._id,
300 | id: reactingUser.id,
301 | },
302 | createdAt: reaction.createdAt,
303 | parentId: userThreads[0]._id.toString(),
304 | activityType: "reaction",
305 | };
306 | });
307 |
308 | const followersData = user.followers.map(
309 | (follower: { user: { toString: () => any }; createdAt: any }) => {
310 | const followingUser = followersUsers.find(
311 | (user) => user._id.toString() === follower.user.toString()
312 | );
313 |
314 | if (followingUser._id.equals(userId)) return null;
315 | return {
316 | author: {
317 | name: followingUser.name,
318 | username: followingUser.username,
319 | image: followingUser.image,
320 | _id: followingUser._id,
321 | id: followingUser.id,
322 | },
323 | createdAt: follower.createdAt,
324 | activityType: "follow",
325 | };
326 | }
327 | );
328 |
329 | const [replies, reactionsAndFollowers] = await Promise.all([
330 | Thread.find({
331 | _id: { $in: childThreadIds },
332 | author: { $ne: userId },
333 | }).populate({
334 | path: "author",
335 | model: User,
336 | select: "name username image _id",
337 | }),
338 | reactionsData.concat(followersData),
339 | ]);
340 |
341 | const activity = [...replies, ...reactionsAndFollowers]
342 | .filter((i) => i !== null)
343 | .sort((a, b) => b?.createdAt - a?.createdAt);
344 |
345 | return activity;
346 | } catch (error) {
347 | console.error("Error fetching activity: ", error);
348 | throw error;
349 | }
350 | }
351 |
--------------------------------------------------------------------------------
/lib/models/community.model.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const followerSchema = new mongoose.Schema({
4 | user: {
5 | type: mongoose.Schema.Types.ObjectId,
6 | ref: "User",
7 | },
8 | createdAt: {
9 | type: Date,
10 | default: Date.now,
11 | },
12 | });
13 |
14 | const communitySchema = new mongoose.Schema({
15 | id: {
16 | type: String,
17 | required: true,
18 | },
19 | username: {
20 | type: String,
21 | unique: true,
22 | required: true,
23 | },
24 | name: {
25 | type: String,
26 | required: true,
27 | },
28 | image: String,
29 | bio: String,
30 | createdBy: {
31 | type: mongoose.Schema.Types.ObjectId,
32 | ref: "User",
33 | },
34 | threads: [
35 | {
36 | type: mongoose.Schema.Types.ObjectId,
37 | ref: "Thread",
38 | },
39 | ],
40 | members: [
41 | {
42 | type: mongoose.Schema.Types.ObjectId,
43 | ref: "User",
44 | },
45 | ],
46 | followers: [followerSchema],
47 | });
48 |
49 | communitySchema.virtual("membersCount").get(function () {
50 | return this.members.length;
51 | });
52 |
53 | communitySchema.virtual("threadsCount").get(function () {
54 | return this.threads.length;
55 | });
56 |
57 | communitySchema.virtual("followersCount").get(function () {
58 | return this.followers.length;
59 | });
60 |
61 | const Community =
62 | mongoose.models.Community || mongoose.model("Community", communitySchema);
63 |
64 | export default Community;
65 |
--------------------------------------------------------------------------------
/lib/models/thread.model.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const reactionSchema = new mongoose.Schema({
4 | user: {
5 | type: mongoose.Schema.Types.ObjectId,
6 | ref: "User",
7 | },
8 | createdAt: {
9 | type: Date,
10 | default: Date.now,
11 | },
12 | });
13 |
14 | const threadSchema = new mongoose.Schema({
15 | text: {
16 | type: String,
17 | required: true,
18 | },
19 | author: {
20 | type: mongoose.Schema.Types.ObjectId,
21 | ref: "User",
22 | required: true,
23 | },
24 | community: {
25 | type: mongoose.Schema.Types.ObjectId,
26 | ref: "Community",
27 | },
28 | createdAt: {
29 | type: Date,
30 | default: Date.now,
31 | },
32 | parentId: {
33 | type: String,
34 | },
35 | reactions: [reactionSchema],
36 | children: [
37 | {
38 | type: mongoose.Schema.Types.ObjectId,
39 | ref: "Thread",
40 | },
41 | ],
42 | });
43 |
44 | threadSchema.virtual("reactionsCount").get(function () {
45 | return this.reactions.length;
46 | });
47 |
48 | threadSchema.virtual("repliesCount").get(function () {
49 | return this.children.length;
50 | });
51 |
52 | const Thread = mongoose.models.Thread || mongoose.model("Thread", threadSchema);
53 |
54 | export default Thread;
55 |
--------------------------------------------------------------------------------
/lib/models/user.model.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const followerSchema = new mongoose.Schema({
4 | user: {
5 | type: mongoose.Schema.Types.ObjectId,
6 | ref: "User",
7 | },
8 | createdAt: {
9 | type: Date,
10 | default: Date.now,
11 | },
12 | });
13 |
14 | const reactionSchema = new mongoose.Schema({
15 | thread: {
16 | type: mongoose.Schema.Types.ObjectId,
17 | ref: "Thread",
18 | },
19 | createdAt: {
20 | type: Date,
21 | default: Date.now,
22 | },
23 | });
24 |
25 | const userSchema = new mongoose.Schema({
26 | id: {
27 | type: String,
28 | required: true,
29 | },
30 | username: {
31 | type: String,
32 | unique: true,
33 | required: true,
34 | },
35 | name: {
36 | type: String,
37 | required: true,
38 | },
39 | image: String,
40 | bio: String,
41 | followers: [followerSchema],
42 | following: [followerSchema],
43 | threads: [
44 | {
45 | type: mongoose.Schema.Types.ObjectId,
46 | ref: "Thread",
47 | },
48 | ],
49 | reactions: [reactionSchema],
50 | onboarded: {
51 | type: Boolean,
52 | default: false,
53 | },
54 | communities: [
55 | {
56 | type: mongoose.Schema.Types.ObjectId,
57 | ref: "Community",
58 | },
59 | ],
60 | });
61 |
62 | userSchema.virtual("threadsCount").get(function () {
63 | return this.threads.length;
64 | });
65 |
66 | userSchema.virtual("followersCount").get(function () {
67 | return this.followers.length;
68 | });
69 |
70 | userSchema.virtual("followingCount").get(function () {
71 | return this.following.length;
72 | });
73 |
74 | userSchema.virtual("communitiesCount").get(function () {
75 | return this.communities.length;
76 | });
77 |
78 | userSchema.virtual("reactionsCount").get(function () {
79 | return this.reactions.length;
80 | });
81 |
82 | const User = mongoose.models.User || mongoose.model("User", userSchema);
83 |
84 | export default User;
85 |
--------------------------------------------------------------------------------
/lib/mongoose.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | let isConnected = false; // Variable to track the connection status
4 |
5 | export const connectToDB = async () => {
6 | // Set strict query mode for Mongoose to prevent unknown field queries.
7 | mongoose.set("strictQuery", true);
8 |
9 | if (!process.env.MONGODB_URL) return console.log("Missing MongoDB URL");
10 |
11 | // If the connection is already established, return without creating a new connection.
12 | if (isConnected) {
13 | console.log("Already connected to MongoDB");
14 | return;
15 | }
16 |
17 | try {
18 | await mongoose.connect(process.env.MONGODB_URL);
19 |
20 | isConnected = true; // Set the connection status to true
21 | console.log("Connected to MongoDB");
22 | } catch (error) {
23 | console.log("Error connecting to MongoDB");
24 | console.log(error);
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/lib/uploadthing.ts:
--------------------------------------------------------------------------------
1 | // Resource: https://docs.uploadthing.com/api-reference/react#generatereacthelpers
2 | // Copy paste (be careful with imports)
3 |
4 | import { generateReactHelpers } from "@uploadthing/react/hooks";
5 |
6 | import type { OurFileRouter } from "@/app/api/uploadthing/core";
7 |
8 | export const { useUploadThing, uploadFiles } =
9 | generateReactHelpers();
10 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | // generated by shadcn
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | // created by chatgpt
10 | export function isBase64Image(imageData: string) {
11 | const base64Regex = /^data:image\/(png|jpe?g|gif|webp);base64,/;
12 | return base64Regex.test(imageData);
13 | }
14 |
15 | // created by chatgpt
16 | export function formatDateString(dateString: string) {
17 | const options: Intl.DateTimeFormatOptions = {
18 | year: "numeric",
19 | month: "short",
20 | day: "numeric",
21 | };
22 |
23 | const date = new Date(dateString);
24 | const formattedDate = date.toLocaleDateString(undefined, options);
25 |
26 | const time = date.toLocaleTimeString([], {
27 | hour: "numeric",
28 | minute: "2-digit",
29 | });
30 |
31 | return `${time} - ${formattedDate}`;
32 | }
33 |
34 | // created by chatgpt
35 | export function formatThreadCount(count: number): string {
36 | if (count === 0) {
37 | return "No Threads";
38 | } else {
39 | const threadCount = count.toString().padStart(2, "0");
40 | const threadWord = count === 1 ? "Thread" : "Threads";
41 | return `${threadCount} ${threadWord}`;
42 | }
43 | }
44 |
45 | export function formatDateWithMeasure(dateString: string): string {
46 | const currentDate = new Date();
47 | const inputDate = new Date(dateString);
48 |
49 | const elapsedMilliseconds = currentDate.getTime() - inputDate.getTime();
50 | const elapsedSeconds = Math.floor(elapsedMilliseconds / 1000);
51 | const elapsedMinutes = Math.floor(elapsedSeconds / 60);
52 | const elapsedHours = Math.floor(elapsedMinutes / 60);
53 | const elapsedDays = Math.floor(elapsedHours / 24);
54 | const elapsedWeeks = Math.floor(elapsedDays / 7);
55 | const elapsedMonths = Math.floor(elapsedDays / 30); // Approximation
56 | const elapsedYears = Math.floor(elapsedDays / 365); // Approximation
57 |
58 | if (elapsedSeconds < 60) {
59 | return `${elapsedSeconds} seconds ago`;
60 | } else if (elapsedMinutes < 60) {
61 | return `${elapsedMinutes} minutes ago`;
62 | } else if (elapsedHours < 24) {
63 | return `${elapsedHours} hours ago`;
64 | } else if (elapsedDays < 7) {
65 | return `${elapsedDays} days ago`;
66 | } else if (elapsedWeeks < 4) {
67 | return `${elapsedWeeks} weeks ago`;
68 | } else if (elapsedMonths < 12) {
69 | return `${elapsedMonths} months ago`;
70 | } else {
71 | return `${elapsedYears} years ago`;
72 | }
73 | }
74 |
75 | // export function formatDateWithMeasure(dateString: string): string {
76 | // const currentDate = new Date();
77 | // const inputDate = new Date(dateString);
78 | // const elapsedMilliseconds = currentDate.getTime() - inputDate.getTime();
79 |
80 | // const timeUnits = [
81 | // { unit: 'second', duration: 1000 },
82 | // { unit: 'minute', duration: 60 * 1000 },
83 | // { unit: 'hour', duration: 60 * 60 * 1000 },
84 | // { unit: 'day', duration: 24 * 60 * 60 * 1000 },
85 | // { unit: 'week', duration: 7 * 24 * 60 * 60 * 1000 },
86 | // { unit: 'month', duration: 30 * 24 * 60 * 60 * 1000 },
87 | // { unit: 'year', duration: 365 * 24 * 60 * 60 * 1000 },
88 | // ];
89 |
90 | // for (const unit of timeUnits) {
91 | // const elapsedUnit = elapsedMilliseconds / unit.duration;
92 | // if (elapsedUnit < 1) {
93 | // const roundedElapsed = Math.floor(elapsedUnit);
94 | // return `${roundedElapsed} ${unit.unit}${roundedElapsed !== 1 ? 's' : ''} ago`;
95 | // }
96 | // }
97 |
98 | // return 'a long time ago';
99 | // }
100 |
101 | export function truncateString(str: string, k: number): string {
102 | if (k >= str.length) {
103 | return str;
104 | } else {
105 | let truncated = str.substring(0, k);
106 |
107 | // If the substring ends within a word, find the last space character
108 | const lastSpaceIndex = truncated.lastIndexOf(" ");
109 | if (lastSpaceIndex !== -1) {
110 | truncated = truncated.substring(0, lastSpaceIndex);
111 | }
112 |
113 | return truncated + "...";
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/lib/validations/thread.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const ThreadValidation = z.object({
4 | thread: z.string().nonempty().min(3, { message: "Minimum 3 characters." }),
5 | accountId: z.string(),
6 | });
7 |
8 | export const CommentValidation = z.object({
9 | thread: z.string().nonempty().min(3, { message: "Minimum 3 characters." }),
10 | });
11 |
--------------------------------------------------------------------------------
/lib/validations/user.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const UserValidation = z.object({
4 | profile_photo: z.string().url().nonempty(),
5 | name: z
6 | .string()
7 | .min(3, { message: "Minimum 3 characters." })
8 | .max(30, { message: "Maximum 30 caracters." }),
9 | username: z
10 | .string()
11 | .min(3, { message: "Minimum 3 characters." })
12 | .max(30, { message: "Maximum 30 caracters." }),
13 | bio: z
14 | .string()
15 | .min(3, { message: "Minimum 3 characters." })
16 | .max(1000, { message: "Maximum 1000 caracters." }),
17 | });
18 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | // This example protects all routes including api/trpc routes
4 | // Please edit this to allow other routes to be public as needed.
5 | // See https://clerk.com/docs/nextjs/middleware for more information about configuring your middleware
6 | export default authMiddleware({
7 | // publicRoutes: [".", "/api/webhook/clerk"],
8 | publicRoutes: [".", "/api/webhook/clerk"],
9 | ignoredRoutes: ["/api/webhook/clerk"],
10 | });
11 |
12 | export const config = {
13 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
14 | };
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | typescript: {
4 | ignoreBuildErrors: true,
5 | },
6 | experimental: {
7 | serverActions: true,
8 | serverComponentsExternalPackages: ["mongoose"],
9 | },
10 | images: {
11 | remotePatterns: [
12 | {
13 | protocol: "https",
14 | hostname: "img.clerk.com",
15 | },
16 | {
17 | protocol: "https",
18 | hostname: "images.clerk.dev",
19 | },
20 | {
21 | protocol: "https",
22 | hostname: "uploadthing.com",
23 | },
24 | {
25 | protocol: "https",
26 | hostname: "placehold.co",
27 | },
28 | ],
29 | },
30 | };
31 |
32 | module.exports = nextConfig;
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs13-threads",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@clerk/nextjs": "^4.29.7",
13 | "@clerk/themes": "^1.7.9",
14 | "@hookform/resolvers": "^3.3.4",
15 | "@radix-ui/react-label": "^2.0.2",
16 | "@radix-ui/react-slot": "^1.0.2",
17 | "@radix-ui/react-tabs": "^1.0.4",
18 | "@types/node": "20.11.19",
19 | "@types/react": "18.2.57",
20 | "@types/react-dom": "18.2.19",
21 | "@uploadthing/react": "^5.7.0",
22 | "autoprefixer": "10.4.17",
23 | "class-variance-authority": "^0.7.0",
24 | "clsx": "^2.1.0",
25 | "lucide-react": "^0.340.0",
26 | "mongoose": "^7.6.8",
27 | "next": "13.5.6",
28 | "postcss": "8.4.35",
29 | "react": "18.2.0",
30 | "react-dom": "18.2.0",
31 | "react-hook-form": "^7.50.1",
32 | "svix": "^1.20.0",
33 | "tailwind-merge": "^1.14.0",
34 | "tailwindcss": "3.4.1",
35 | "tailwindcss-animate": "^1.0.7",
36 | "typescript": "5.3.3",
37 | "uploadthing": "^5.7.4",
38 | "zod": "^3.22.2"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/community.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/assets/create.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/delete-purple.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/edit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/explore.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/assets/follow.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/assets/followers.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/assets/following.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/assets/heart-filled.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/heart-gray.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/heart.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/home.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/assets/logout.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/members.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/more.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/reply.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/repost.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/request.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/search-gray.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/search.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/share.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/tag.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/unfollow.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/assets/user.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | darkMode: ["class"],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | fontSize: {
19 | "heading1-bold": [
20 | "36px",
21 | {
22 | lineHeight: "140%",
23 | fontWeight: "700",
24 | },
25 | ],
26 | "heading1-semibold": [
27 | "36px",
28 | {
29 | lineHeight: "140%",
30 | fontWeight: "600",
31 | },
32 | ],
33 | "heading2-bold": [
34 | "30px",
35 | {
36 | lineHeight: "140%",
37 | fontWeight: "700",
38 | },
39 | ],
40 | "heading2-semibold": [
41 | "30px",
42 | {
43 | lineHeight: "140%",
44 | fontWeight: "600",
45 | },
46 | ],
47 | "heading3-bold": [
48 | "24px",
49 | {
50 | lineHeight: "140%",
51 | fontWeight: "700",
52 | },
53 | ],
54 | "heading4-medium": [
55 | "20px",
56 | {
57 | lineHeight: "140%",
58 | fontWeight: "500",
59 | },
60 | ],
61 | "body-bold": [
62 | "18px",
63 | {
64 | lineHeight: "140%",
65 | fontWeight: "700",
66 | },
67 | ],
68 | "body-semibold": [
69 | "18px",
70 | {
71 | lineHeight: "140%",
72 | fontWeight: "600",
73 | },
74 | ],
75 | "body-medium": [
76 | "18px",
77 | {
78 | lineHeight: "140%",
79 | fontWeight: "500",
80 | },
81 | ],
82 | "body-normal": [
83 | "18px",
84 | {
85 | lineHeight: "140%",
86 | fontWeight: "400",
87 | },
88 | ],
89 | "body1-bold": [
90 | "18px",
91 | {
92 | lineHeight: "140%",
93 | fontWeight: "700",
94 | },
95 | ],
96 | "base-regular": [
97 | "16px",
98 | {
99 | lineHeight: "140%",
100 | fontWeight: "400",
101 | },
102 | ],
103 | "base-medium": [
104 | "16px",
105 | {
106 | lineHeight: "140%",
107 | fontWeight: "500",
108 | },
109 | ],
110 | "base-semibold": [
111 | "16px",
112 | {
113 | lineHeight: "140%",
114 | fontWeight: "600",
115 | },
116 | ],
117 | "base1-semibold": [
118 | "16px",
119 | {
120 | lineHeight: "140%",
121 | fontWeight: "600",
122 | },
123 | ],
124 | "small-regular": [
125 | "14px",
126 | {
127 | lineHeight: "140%",
128 | fontWeight: "400",
129 | },
130 | ],
131 | "small-medium": [
132 | "14px",
133 | {
134 | lineHeight: "140%",
135 | fontWeight: "500",
136 | },
137 | ],
138 | "small-semibold": [
139 | "14px",
140 | {
141 | lineHeight: "140%",
142 | fontWeight: "600",
143 | },
144 | ],
145 | "subtle-medium": [
146 | "12px",
147 | {
148 | lineHeight: "16px",
149 | fontWeight: "500",
150 | },
151 | ],
152 | "subtle-semibold": [
153 | "12px",
154 | {
155 | lineHeight: "16px",
156 | fontWeight: "600",
157 | },
158 | ],
159 | "tiny-medium": [
160 | "10px",
161 | {
162 | lineHeight: "140%",
163 | fontWeight: "500",
164 | },
165 | ],
166 | "x-small-semibold": [
167 | "7px",
168 | {
169 | lineHeight: "9.318px",
170 | fontWeight: "600",
171 | },
172 | ],
173 | },
174 | extend: {
175 | colors: {
176 | "primary-500": "#877EFF",
177 | "secondary-500": "#FFB620",
178 | blue: "#0095F6",
179 | "logout-btn": "#FF5A5A",
180 | "navbar-menu": "rgba(16, 16, 18, 0.6)",
181 | "dark-1": "#000000",
182 | "dark-2": "#121417",
183 | "dark-3": "#101012",
184 | "dark-4": "#1F1F22",
185 | "light-1": "#FFFFFF",
186 | "light-2": "#EFEFEF",
187 | "light-3": "#7878A3",
188 | "light-4": "#5C5C7B",
189 | "gray-1": "#697C89",
190 | glassmorphism: "rgba(16, 16, 18, 0.60)",
191 | },
192 | boxShadow: {
193 | "count-badge": "0px 0px 6px 2px rgba(219, 188, 159, 0.30)",
194 | "groups-sidebar": "-30px 0px 60px 0px rgba(28, 28, 31, 0.50)",
195 | },
196 | screens: {
197 | xs: "400px",
198 | },
199 | keyframes: {
200 | "accordion-down": {
201 | from: { height: 0 },
202 | to: { height: "var(--radix-accordion-content-height)" },
203 | },
204 | "accordion-up": {
205 | from: { height: "var(--radix-accordion-content-height)" },
206 | to: { height: 0 },
207 | },
208 | },
209 | animation: {
210 | "accordion-down": "accordion-down 0.2s ease-out",
211 | "accordion-up": "accordion-up 0.2s ease-out",
212 | },
213 | },
214 | },
215 | plugins: [require("tailwindcss-animate")],
216 | };
217 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | darkMode: ["class"],
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | fontSize: {
20 | "heading1-bold": [
21 | "36px",
22 | {
23 | lineHeight: "140%",
24 | fontWeight: "700",
25 | },
26 | ],
27 | "heading1-semibold": [
28 | "36px",
29 | {
30 | lineHeight: "140%",
31 | fontWeight: "600",
32 | },
33 | ],
34 | "heading2-bold": [
35 | "30px",
36 | {
37 | lineHeight: "140%",
38 | fontWeight: "700",
39 | },
40 | ],
41 | "heading2-semibold": [
42 | "30px",
43 | {
44 | lineHeight: "140%",
45 | fontWeight: "600",
46 | },
47 | ],
48 | "heading3-bold": [
49 | "24px",
50 | {
51 | lineHeight: "140%",
52 | fontWeight: "700",
53 | },
54 | ],
55 | "heading4-medium": [
56 | "20px",
57 | {
58 | lineHeight: "140%",
59 | fontWeight: "500",
60 | },
61 | ],
62 | "body-bold": [
63 | "18px",
64 | {
65 | lineHeight: "140%",
66 | fontWeight: "700",
67 | },
68 | ],
69 | "body-semibold": [
70 | "18px",
71 | {
72 | lineHeight: "140%",
73 | fontWeight: "600",
74 | },
75 | ],
76 | "body-medium": [
77 | "18px",
78 | {
79 | lineHeight: "140%",
80 | fontWeight: "500",
81 | },
82 | ],
83 | "body-normal": [
84 | "18px",
85 | {
86 | lineHeight: "140%",
87 | fontWeight: "400",
88 | },
89 | ],
90 | "body1-bold": [
91 | "18px",
92 | {
93 | lineHeight: "140%",
94 | fontWeight: "700",
95 | },
96 | ],
97 | "base-regular": [
98 | "16px",
99 | {
100 | lineHeight: "140%",
101 | fontWeight: "400",
102 | },
103 | ],
104 | "base-medium": [
105 | "16px",
106 | {
107 | lineHeight: "140%",
108 | fontWeight: "500",
109 | },
110 | ],
111 | "base-semibold": [
112 | "16px",
113 | {
114 | lineHeight: "140%",
115 | fontWeight: "600",
116 | },
117 | ],
118 | "base1-semibold": [
119 | "16px",
120 | {
121 | lineHeight: "140%",
122 | fontWeight: "600",
123 | },
124 | ],
125 | "small-regular": [
126 | "14px",
127 | {
128 | lineHeight: "140%",
129 | fontWeight: "400",
130 | },
131 | ],
132 | "small-medium": [
133 | "14px",
134 | {
135 | lineHeight: "140%",
136 | fontWeight: "500",
137 | },
138 | ],
139 | "small-semibold": [
140 | "14px",
141 | {
142 | lineHeight: "140%",
143 | fontWeight: "600",
144 | },
145 | ],
146 | "subtle-medium": [
147 | "12px",
148 | {
149 | lineHeight: "16px",
150 | fontWeight: "500",
151 | },
152 | ],
153 | "subtle-semibold": [
154 | "12px",
155 | {
156 | lineHeight: "16px",
157 | fontWeight: "600",
158 | },
159 | ],
160 | "tiny-medium": [
161 | "10px",
162 | {
163 | lineHeight: "140%",
164 | fontWeight: "500",
165 | },
166 | ],
167 | "x-small-semibold": [
168 | "7px",
169 | {
170 | lineHeight: "9.318px",
171 | fontWeight: "600",
172 | },
173 | ],
174 | },
175 | extend: {
176 | colors: {
177 | "primary-500": "#877EFF",
178 | "secondary-500": "#FFB620",
179 | blue: "#0095F6",
180 | "logout-btn": "#FF5A5A",
181 | "navbar-menu": "rgba(16, 16, 18, 0.6)",
182 | "dark-1": "#000000",
183 | "dark-2": "#121417",
184 | "dark-3": "#101012",
185 | "dark-4": "#1F1F22",
186 | "light-1": "#FFFFFF",
187 | "light-2": "#EFEFEF",
188 | "light-3": "#7878A3",
189 | "light-4": "#5C5C7B",
190 | "gray-1": "#697C89",
191 | glassmorphism: "rgba(16, 16, 18, 0.60)",
192 | },
193 | boxShadow: {
194 | "count-badge": "0px 0px 6px 2px rgba(219, 188, 159, 0.30)",
195 | "groups-sidebar": "-30px 0px 60px 0px rgba(28, 28, 31, 0.50)",
196 | },
197 | screens: {
198 | xs: "400px",
199 | },
200 | keyframes: {
201 | "accordion-down": {
202 | from: { height: 0 },
203 | to: { height: "var(--radix-accordion-content-height)" },
204 | },
205 | "accordion-up": {
206 | from: { height: "var(--radix-accordion-content-height)" },
207 | to: { height: 0 },
208 | },
209 | },
210 | animation: {
211 | "accordion-down": "accordion-down 0.2s ease-out",
212 | "accordion-up": "accordion-up 0.2s ease-out",
213 | },
214 | },
215 | },
216 | plugins: [require("tailwindcss-animate")],
217 | };
218 | export default config;
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx",
30 | ".next/types/**/*.ts",
31 | "constants/index.js"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------