├── .eslintrc.json
├── .gitignore
├── README.md
├── app
├── [id]
│ ├── layout.tsx
│ ├── page.tsx
│ └── replies
│ │ └── page.tsx
├── activity
│ └── page.tsx
├── api
│ └── loadMore
│ │ └── route.ts
├── favicon.ico
├── globals.css
├── layout.tsx
├── onboarding
│ └── page.tsx
├── page.tsx
├── search
│ └── page.tsx
├── sign-in
│ └── [[...sign-in]]
│ │ └── page.tsx
├── sign-up
│ └── [[...sign-up]]
│ │ └── page.tsx
└── t
│ └── [id]
│ ├── layout.tsx
│ └── page.tsx
├── assets
├── loop.svg
├── threads-screenshot.png
├── threads.png
└── threads.svg
├── components.json
├── components
├── activity
│ ├── categories.tsx
│ ├── follow.tsx
│ └── index.tsx
├── onboarding
│ ├── card.tsx
│ ├── index.tsx
│ ├── privacy.tsx
│ └── screens.tsx
├── profile
│ ├── edit.tsx
│ ├── follow.tsx
│ ├── info.tsx
│ ├── selfShare.tsx
│ └── signOut.tsx
├── search
│ ├── bar.tsx
│ ├── follow.tsx
│ └── user.tsx
├── thread
│ ├── backButton.tsx
│ ├── comment
│ │ ├── commentModal.tsx
│ │ ├── createComment.tsx
│ │ └── index.tsx
│ ├── controls
│ │ ├── index.tsx
│ │ ├── like.tsx
│ │ ├── repost.tsx
│ │ └── share.tsx
│ ├── create
│ │ ├── createThread.tsx
│ │ ├── index.tsx
│ │ └── threadModal.tsx
│ ├── homePosts.tsx
│ ├── index.tsx
│ ├── main.tsx
│ ├── moreMenu.tsx
│ ├── nameLink.tsx
│ ├── others.tsx
│ └── timestamp.tsx
└── ui
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── modeToggle.tsx
│ ├── nav.tsx
│ ├── tabs.tsx
│ ├── themeProvider.tsx
│ ├── toast.tsx
│ ├── toaster.tsx
│ └── use-toast.ts
├── lib
├── actions
│ ├── index.ts
│ ├── threadActions.ts
│ └── userActions.ts
├── prisma.ts
└── utils.ts
├── middleware.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── prisma
└── schema.prisma
├── public
└── threads.png
├── tailwind.config.js
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .vercel
38 | .env*.local
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Threads
2 |
3 | An open-source clone of Threads using Next.js server components, Vercel Postgres, shadcn UI, Clerk, and Prisma.
4 |
5 |
6 | https://github.com/ishaan1013/thr/assets/69771365/f1ca7104-0fa0-4825-ab83-06deeec5cc3f
7 |
8 |
9 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fishaan1013%2Fthr&env=CLERK_SECRET_KEY,NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL,NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL,NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,NEXT_PUBLIC_CLERK_SIGN_IN_URL,NEXT_PUBLIC_CLERK_SIGN_UP_URL&envDescription=Clerk%20is%20recommended%20to%20work%20with%20this%20project.%20Vercel%20Postgres%20is%20optional%2C%20and%20is%20what%20was%20used%20in%20the%20original%20project.&project-name=clone&repository-name=clone&demo-title=Clone&demo-description=A%20Next.js%20clone%20of%20Meta%27s%20new%20app&demo-url=https%3A%2F%2Ft.ishaand.co%2F&demo-image=https%3A%2F%2Fgithub.com%2Fishaan1013%2Fthr%2Fblob%2Fmaster%2Fassets%2Fthreads-screenshot.png%3Fraw%3Dtrue)
10 |
11 | ## Running Locally
12 |
13 | ### Cloning the repository the local machine.
14 |
15 | ```bash
16 | git clone https://github.com/ishaan1013/thr
17 | ```
18 |
19 | ### Create a Postgres database on Vercel (optional, can use other provider)
20 |
21 | - Add the environment variables in .env
22 | - (This project uses Prisma as an ORM for the database)
23 |
24 | ### Create a project on Clerk
25 |
26 | - Add the environment variables in .env
27 | - Ensure you have the following variables:
28 | ```
29 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/"
30 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/onboarding"
31 | NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
32 | NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
33 | ```
34 |
35 | ### Installing the dependencies.
36 |
37 | ```bash
38 | npm install
39 | ```
40 |
41 | ### Running the application.
42 |
43 | Then, run the application in the command line and it will be available at `http://localhost:3000`.
44 |
45 | ```bash
46 | npm run dev
47 | ```
48 |
--------------------------------------------------------------------------------
/app/[id]/layout.tsx:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/prisma";
2 | import { currentUser } from "@clerk/nextjs";
3 | import Image from "next/image";
4 | import { redirect } from "next/navigation";
5 |
6 | import Nav from "@/components/ui/nav";
7 | import { Badge } from "@/components/ui/badge";
8 | import { Button } from "@/components/ui/button";
9 | import { Instagram } from "lucide-react";
10 |
11 | import logo from "@/assets/threads.svg";
12 | import { InfoModal } from "@/components/profile/info";
13 | import SelfShare from "@/components/profile/selfShare";
14 | import { nFormatter } from "@/lib/utils";
15 | import SignOut from "@/components/profile/signOut";
16 | import { EditModal } from "@/components/profile/edit";
17 | import FollowButton from "@/components/profile/follow";
18 |
19 | export default async function ProfilePageLayout({
20 | children,
21 | params,
22 | }: {
23 | children: React.ReactNode;
24 | params: { id: string };
25 | }) {
26 | const user = await currentUser();
27 |
28 | if (!user) return null;
29 |
30 | const getSelf = await prisma.user.findUnique({
31 | where: {
32 | id: user.id,
33 | },
34 | });
35 |
36 | if (!getSelf?.onboarded) {
37 | redirect("/onboarding");
38 | }
39 |
40 | const getUser = await prisma.user.findUnique({
41 | where: {
42 | username: params.id,
43 | },
44 | include: {
45 | followedBy: true,
46 | },
47 | });
48 |
49 | if (!getUser) {
50 | return (
51 | <>
52 |
60 |
69 |
70 | Sorry, this page isn't available
71 |
72 |
73 | The link you followed may be broken, or the page may have been
74 | removed.
75 |
76 | >
77 | );
78 | }
79 |
80 | const self = getSelf.username === params.id;
81 |
82 | const isFollowing = self
83 | ? false
84 | : getUser.followedBy.some((follow) => follow.id === getSelf.id);
85 |
86 | return (
87 | <>
88 |
96 |
106 |
107 |
108 |
{getUser?.name}
109 |
110 | {getUser.username}
111 |
112 | threads.net
113 |
114 |
115 | {getUser.bio ? (
116 |
{getUser.bio}
117 | ) : null}
118 |
119 | {nFormatter(getUser.followedBy.length, 1)}{" "}
120 | {getUser.followedBy.length === 1 ? "follower" : "followers"}
121 |
122 |
123 |
124 |
125 |
131 |
132 |
133 |
134 | {self ? (
135 |
136 |
137 |
138 |
139 | ) : (
140 |
141 |
147 |
148 | )}
149 |
150 | {children}
151 | >
152 | );
153 | }
154 |
--------------------------------------------------------------------------------
/app/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/prisma";
2 | import { currentUser } from "@clerk/nextjs";
3 | import Item from "@/components/thread";
4 | import Link from "next/link";
5 |
6 | export default async function ProfilePage({
7 | params,
8 | }: {
9 | params: { id: string };
10 | }) {
11 | const user = await currentUser();
12 |
13 | if (!user) return null;
14 |
15 | const getUser = await prisma.user.findUnique({
16 | where: {
17 | username: params.id,
18 | },
19 | });
20 |
21 | const posts = await prisma.post.findMany({
22 | where: {
23 | authorId: getUser?.id,
24 | parent: null,
25 | },
26 | include: {
27 | author: true,
28 | children: {
29 | include: {
30 | author: true,
31 | },
32 | },
33 | parent: true,
34 | likes: true,
35 | },
36 | });
37 |
38 | return (
39 | <>
40 |
41 |
44 |
48 | Replies
49 |
50 |
51 | {posts.length === 0 ? (
52 |
53 | No threads posted yet.
54 |
55 | ) : (
56 | posts.map((post) => )
57 | )}
58 | >
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/app/[id]/replies/page.tsx:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/prisma";
2 | import { currentUser } from "@clerk/nextjs";
3 | import Item from "@/components/thread";
4 | import Link from "next/link";
5 | import { Button } from "@/components/ui/button";
6 | import { ArrowUp } from "lucide-react";
7 | import Image from "next/image";
8 |
9 | export default async function RepliesPage({
10 | params,
11 | }: {
12 | params: { id: string };
13 | }) {
14 | const user = await currentUser();
15 |
16 | if (!user) return null;
17 |
18 | const getUser = await prisma.user.findUnique({
19 | where: {
20 | username: params.id,
21 | },
22 | });
23 |
24 | const posts = await prisma.post.findMany({
25 | // where parent is not null
26 | where: {
27 | authorId: getUser?.id,
28 | NOT: {
29 | parent: null,
30 | },
31 | },
32 | include: {
33 | author: true,
34 | children: {
35 | include: {
36 | author: true,
37 | },
38 | },
39 | parent: {
40 | include: {
41 | author: true,
42 | children: {
43 | include: {
44 | author: true,
45 | },
46 | },
47 | parent: {
48 | include: {
49 | author: true,
50 | },
51 | },
52 | likes: true,
53 | },
54 | },
55 | likes: true,
56 | },
57 | });
58 |
59 | return (
60 | <>
61 |
62 |
66 | Threads
67 |
68 |
71 |
72 | {posts.length === 0 ? (
73 |
74 | No replies posted yet.
75 |
76 | ) : (
77 | posts.map((post) => (
78 | <>
79 | {post.parent && post.parent.parent ? (
80 |
81 |
97 |
98 | ) : null}
99 | {post.parent ? (
100 |
101 | ) : null}
102 |
103 | >
104 | ))
105 | )}
106 | >
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/app/activity/page.tsx:
--------------------------------------------------------------------------------
1 | import { Categories, Follow } from "@/components/activity";
2 | import { Button } from "@/components/ui/button";
3 | import Nav from "@/components/ui/nav";
4 | import prisma from "@/lib/prisma";
5 | import { currentUser } from "@clerk/nextjs";
6 | import { redirect } from "next/navigation";
7 |
8 | export default async function ActivityPage() {
9 | const user = await currentUser();
10 |
11 | const getUser = await prisma.user.findUnique({
12 | where: {
13 | id: user?.id,
14 | },
15 | });
16 |
17 | if (!getUser?.onboarded) {
18 | redirect("/onboarding");
19 | }
20 |
21 | return (
22 | <>
23 |
31 |
32 |
36 |
45 | {/*
46 | */}
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/app/api/loadMore/route.ts:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/prisma";
2 |
3 | export async function GET(req: Request) {
4 | try {
5 | // get cursor from query
6 | const url = new URL(req.url);
7 | const cursor = url.searchParams.get("cursor");
8 |
9 | if (!cursor) {
10 | return new Response(JSON.stringify({ error: "No cursor provided" }), {
11 | status: 403,
12 | });
13 | }
14 |
15 | const posts = await prisma.post.findMany({
16 | take: 10,
17 | skip: 1,
18 | cursor: {
19 | id: cursor,
20 | },
21 | orderBy: {
22 | createdAt: "desc",
23 | },
24 | include: {
25 | author: true,
26 | children: {
27 | include: {
28 | author: true,
29 | },
30 | },
31 | parent: true,
32 | likes: true,
33 | },
34 | where: {
35 | parent: null,
36 | },
37 | });
38 |
39 | if (posts.length == 0) {
40 | return new Response(
41 | JSON.stringify({
42 | data: [],
43 | }),
44 | { status: 200 }
45 | );
46 | }
47 |
48 | const data = {
49 | data: posts,
50 | };
51 |
52 | return new Response(JSON.stringify(data), { status: 200 });
53 | } catch (error: any) {
54 | return new Response(
55 | JSON.stringify(JSON.stringify({ error: error.message })),
56 | { status: 403 }
57 | );
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ishaan1013/thr/189ccc6d0be9605cb9a0e9ef5eeb60a1b2812463/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | width: 100%;
8 | margin: 0;
9 | padding: 0;
10 |
11 | overflow-x: hidden;
12 | }
13 |
14 | ::-webkit-scrollbar {
15 | width: 12px;
16 | }
17 |
18 | ::-webkit-scrollbar-track {
19 | background: #0a0a0a;
20 | }
21 |
22 | ::-webkit-scrollbar-thumb {
23 | background: #404040cc;
24 | border-radius: 10px;
25 | }
26 |
27 | ::-webkit-scrollbar-thumb:hover {
28 | background: #525252;
29 | }
30 |
31 | .mini-scrollbar::-webkit-scrollbar {
32 | width: 8px;
33 | }
34 |
35 | .mini-scrollbar::-webkit-scrollbar-track {
36 | background: #0a0a0a;
37 | }
38 |
39 | .mini-scrollbar::-webkit-scrollbar-thumb {
40 | background: #262626;
41 | border-radius: 10px;
42 | }
43 |
44 | .mini-scrollbar::-webkit-scrollbar-thumb:hover {
45 | background: #525252;
46 | }
47 |
48 | .mini-horizontal-scroll::-webkit-scrollbar {
49 | height: 6px;
50 | }
51 |
52 | .mini-horizontal-scroll::-webkit-scrollbar-track {
53 | background: #0a0a0a;
54 | }
55 |
56 | .mini-horizontal-scroll::-webkit-scrollbar-thumb {
57 | background: #262626;
58 | border-radius: 10px;
59 | }
60 |
61 | .mini-horizontal-scroll::-webkit-scrollbar-thumb:hover {
62 | background: #525252;
63 | }
64 |
65 | .gradient {
66 | background: radial-gradient(
67 | circle farthest-corner at 35% 90%,
68 | #eba93b,
69 | transparent 50%
70 | ),
71 | radial-gradient(circle farthest-corner at 0 140%, #eba93b, transparent 50%),
72 | radial-gradient(ellipse farthest-corner at 0 -25%, #373ecc, transparent 50%),
73 | radial-gradient(
74 | ellipse farthest-corner at 20% -50%,
75 | #373ecc,
76 | transparent 50%
77 | ),
78 | radial-gradient(ellipse farthest-corner at 100% 0, #7c26bd, transparent 50%),
79 | radial-gradient(
80 | ellipse farthest-corner at 60% -20%,
81 | #7c26bd,
82 | transparent 50%
83 | ),
84 | radial-gradient(ellipse farthest-corner at 100% 100%, #de1d71, transparent),
85 | linear-gradient(
86 | #5244c7,
87 | #c72893 30%,
88 | #de2c4f 50%,
89 | #f06826 70%,
90 | #f2b657 100%
91 | );
92 | background-clip: text;
93 | color: transparent;
94 | -webkit-background-clip: text;
95 | -webkit-text-fill-color: transparent;
96 | }
97 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { Inter } from "next/font/google";
3 |
4 | import { ClerkProvider, auth } from "@clerk/nextjs";
5 | import { dark } from "@clerk/themes";
6 |
7 | import { ThemeProvider } from "@/components/ui/themeProvider";
8 | import { Toaster } from "@/components/ui/toaster";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata = {
13 | title: "Meta's new app",
14 | description: "A clone built with Next.js",
15 | };
16 |
17 | export default function RootLayout({
18 | children,
19 | }: {
20 | children: React.ReactNode;
21 | }) {
22 | const { userId } = auth();
23 |
24 | return (
25 |
30 |
31 |
32 | {" "}
33 |
34 | {userId ? (
35 | <>
36 |
37 |
38 | {children}
39 |
40 |
41 |
42 | >
43 | ) : (
44 |
45 |
46 | {children}
47 |
48 |
49 | )}
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/app/onboarding/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth, currentUser } from "@clerk/nextjs";
2 | import prisma from "@/lib/prisma";
3 |
4 | import { Screens } from "@/components/onboarding";
5 | import { redirect } from "next/navigation";
6 |
7 | export const revalidate = 0;
8 |
9 | export default async function OnboardingLayout() {
10 | const user = await currentUser();
11 |
12 | if (!user) {
13 | redirect("/sign-up");
14 | }
15 |
16 | const getUser = await prisma.user.findUnique({
17 | where: {
18 | id: user.id,
19 | },
20 | });
21 |
22 | if (getUser?.onboarded) {
23 | redirect("/");
24 | }
25 |
26 | const userData = {
27 | id: user.id,
28 | username: getUser ? getUser.username : user.id.slice(5),
29 | name: getUser ? getUser.name : user.firstName ?? "",
30 | bio: getUser ? getUser.bio : "",
31 | image: getUser ? getUser.image : user.imageUrl,
32 | };
33 |
34 | const allUsernames = await prisma.user.findMany({
35 | select: {
36 | username: true,
37 | },
38 | });
39 |
40 | return (
41 |
42 | {user ? (
43 |
44 | ) : null}
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | import logo from "@/assets/threads.svg";
5 | import { Button } from "@/components/ui/button";
6 |
7 | import { currentUser } from "@clerk/nextjs";
8 | import prisma from "@/lib/prisma";
9 | import Nav from "@/components/ui/nav";
10 | import { redirect } from "next/navigation";
11 | import HomePosts from "@/components/thread/homePosts";
12 |
13 | export const revalidate = 0;
14 |
15 | export default async function Page() {
16 | const user = await currentUser();
17 |
18 | if (!user)
19 | return (
20 | <>
21 |
22 |
27 |
28 | Threads
29 |
30 |
31 |
34 |
35 |
36 |
39 |
40 | >
41 | );
42 |
43 | const getUser = await prisma.user.findUnique({
44 | where: {
45 | id: user?.id,
46 | },
47 | });
48 |
49 | if (!getUser?.onboarded) {
50 | redirect("/onboarding");
51 | }
52 |
53 | const posts = await prisma.post.findMany({
54 | take: 20,
55 | orderBy: {
56 | createdAt: "desc",
57 | },
58 | include: {
59 | author: true,
60 | children: {
61 | include: {
62 | author: true,
63 | },
64 | },
65 | parent: true,
66 | likes: true,
67 | },
68 | where: {
69 | parent: null,
70 | },
71 | });
72 |
73 | return (
74 | <>
75 |
83 |
92 |
93 | {/*
94 | {JSON.stringify(posts, null, 2)}
95 |
*/}
96 |
97 | >
98 | //
99 | //
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/app/search/page.tsx:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@clerk/nextjs";
2 | import { redirect } from "next/navigation";
3 | import prisma from "@/lib/prisma";
4 | import Nav from "@/components/ui/nav";
5 | import { Bar } from "@/components/search/bar";
6 | import { SearchUser } from "@/components/search/user";
7 |
8 | export const revalidate = 0;
9 |
10 | export default async function SearchPage({
11 | searchParams,
12 | }: {
13 | searchParams: { [key: string]: string | string[] | undefined };
14 | }) {
15 | const user = await currentUser();
16 |
17 | if (!user) {
18 | redirect("/sign-in");
19 | }
20 |
21 | const getUser = await prisma.user.findUnique({
22 | where: {
23 | id: user?.id,
24 | },
25 | });
26 |
27 | if (!getUser?.onboarded) {
28 | redirect("/onboarding");
29 | }
30 |
31 | // if there's no query, return top followed users
32 | const users = searchParams?.q
33 | ? await prisma.user.findMany({
34 | include: {
35 | followedBy: true,
36 | },
37 | where: {
38 | NOT: {
39 | id: user.id,
40 | },
41 | OR: [
42 | {
43 | username: {
44 | contains: searchParams.q as string,
45 | mode: "insensitive",
46 | },
47 | },
48 | {
49 | name: {
50 | contains: searchParams.q as string,
51 | mode: "insensitive",
52 | },
53 | },
54 | ],
55 | },
56 | orderBy: {
57 | followedBy: {
58 | _count: "desc",
59 | },
60 | },
61 | })
62 | : await prisma.user.findMany({
63 | include: {
64 | followedBy: true,
65 | },
66 | where: {
67 | NOT: {
68 | id: user.id,
69 | },
70 | },
71 | orderBy: {
72 | followedBy: {
73 | _count: "desc",
74 | },
75 | },
76 | });
77 |
78 | return (
79 | <>
80 |
88 |
89 |
93 | {users.length === 0 ? (
94 |
95 | No results
96 |
97 | ) : (
98 | <>
99 | {users.map((user) => {
100 | const isFollowing = user.followedBy.some(
101 | (follow) => follow.id === getUser.id
102 | );
103 | return (
104 |
110 | );
111 | })}
112 | >
113 | )}
114 | >
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/app/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/t/[id]/layout.tsx:
--------------------------------------------------------------------------------
1 | import BackButton from "@/components/thread/backButton";
2 | import { Button } from "@/components/ui/button";
3 | import Nav from "@/components/ui/nav";
4 | import { currentUser } from "@clerk/nextjs";
5 | import { ChevronLeft } from "lucide-react";
6 | import Link from "next/link";
7 | import { redirect } from "next/navigation";
8 | import prisma from "@/lib/prisma";
9 |
10 | export default async function ThreadPageLayout({
11 | children,
12 | }: {
13 | children: React.ReactNode;
14 | }) {
15 | const user = await currentUser();
16 |
17 | if (!user) {
18 | redirect("/sign-in");
19 | }
20 |
21 | const getUser = await prisma.user.findUnique({
22 | where: {
23 | id: user?.id,
24 | },
25 | });
26 |
27 | if (!getUser?.onboarded) {
28 | redirect("/onboarding");
29 | }
30 |
31 | return (
32 | <>
33 |
41 |
42 |
43 |
44 | Thread
45 |
46 |
47 |
48 | {children}
49 | >
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/t/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import Item from "@/components/thread";
2 | import MainItem from "@/components/thread/main";
3 | import { Button } from "@/components/ui/button";
4 | import prisma from "@/lib/prisma";
5 | import { ArrowUp } from "lucide-react";
6 | import Image from "next/image";
7 | import Link from "next/link";
8 |
9 | export const revalidate = 0;
10 |
11 | export default async function ThreadPage({
12 | params,
13 | }: {
14 | params: { id: string };
15 | }) {
16 | const { id } = params;
17 |
18 | const post = await prisma.post.findUnique({
19 | where: {
20 | id,
21 | },
22 | include: {
23 | author: true,
24 | children: {
25 | include: {
26 | author: true,
27 | children: {
28 | include: {
29 | author: true,
30 | },
31 | },
32 | parent: true,
33 | likes: true,
34 | },
35 | },
36 | parent: {
37 | include: {
38 | author: true,
39 | children: {
40 | include: {
41 | author: true,
42 | },
43 | },
44 | parent: {
45 | include: {
46 | author: true,
47 | },
48 | },
49 | likes: true,
50 | },
51 | },
52 | likes: true,
53 | },
54 | });
55 |
56 | if (!post) {
57 | return Post not found.
;
58 | }
59 |
60 | return (
61 | <>
62 | {post.parent && post.parent.parent ? (
63 |
64 |
80 |
81 | ) : null}
82 | {post.parent ? (
83 |
84 | ) : null}
85 |
86 | {post.children.map((child) => (
87 |
88 | ))}
89 | >
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/assets/loop.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/assets/threads-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ishaan1013/thr/189ccc6d0be9605cb9a0e9ef5eeb60a1b2812463/assets/threads-screenshot.png
--------------------------------------------------------------------------------
/assets/threads.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ishaan1013/thr/189ccc6d0be9605cb9a0e9ef5eeb60a1b2812463/assets/threads.png
--------------------------------------------------------------------------------
/assets/threads.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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": "neutral",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/components/activity/categories.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "../ui/button";
2 |
3 | export function Categories() {
4 | return (
5 |
6 |
9 |
10 |
13 |
14 |
15 |
18 |
19 |
20 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/components/activity/follow.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "../ui/button";
2 |
3 | export function Follow() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | username{" "}
11 | {23}m
12 |
13 |
Followed you
14 |
15 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/components/activity/index.tsx:
--------------------------------------------------------------------------------
1 | export * from "./categories";
2 | export * from "./follow";
3 |
--------------------------------------------------------------------------------
/components/onboarding/card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Card, CardContent } from "@/components/ui/card";
5 | import { Input } from "@/components/ui/input";
6 | import { Label } from "@/components/ui/label";
7 | import { onboardData } from "@/lib/actions";
8 | import { useState, useTransition } from "react";
9 | import { useToast } from "../ui/use-toast";
10 | import { AlertCircle } from "lucide-react";
11 |
12 | import Filter from "bad-words";
13 | import { validateUsername } from "@/lib/utils";
14 |
15 | export function OnboardingProfileCard({
16 | userData,
17 | next,
18 | allUsernames,
19 | }: {
20 | userData: {
21 | id: string;
22 | username: string;
23 | name: string;
24 | bio: string;
25 | image: string;
26 | };
27 | next: () => void;
28 | allUsernames: string[];
29 | }) {
30 | const [isPending, startTransition] = useTransition();
31 |
32 | const filter = new Filter();
33 |
34 | const [username, setUsername] = useState(userData.username);
35 | const [name, setName] = useState(userData.name);
36 | const [bio, setBio] = useState(userData.bio);
37 |
38 | const { toast } = useToast();
39 |
40 | return (
41 | <>
42 |
43 |
44 |
121 |
122 |
123 |
148 | >
149 | );
150 | }
151 |
--------------------------------------------------------------------------------
/components/onboarding/index.tsx:
--------------------------------------------------------------------------------
1 | export * from "./screens";
2 | export * from "./card";
3 |
--------------------------------------------------------------------------------
/components/onboarding/privacy.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card";
13 |
14 | export function PrivacySelectCards() {
15 | return (
16 | <>
17 |
18 |
19 | Public profile
20 |
21 | Anyone on or off Threads can see, share and interact with your
22 | content.
23 |
24 |
25 |
26 |
27 |
28 |
29 | Private profile
30 |
31 | Only your approved followers can see and interact with your
32 | content. (coming soon)
33 |
34 |
35 |
36 |
37 |
38 |
41 |
42 | >
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/onboarding/screens.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ChevronRight, ChevronLeft } from "lucide-react";
4 | import { useState } from "react";
5 | import { OnboardingProfileCard } from ".";
6 | import { PrivacySelectCards } from "./privacy";
7 | import { Button } from "../ui/button";
8 |
9 | export function Screens({
10 | userData,
11 | allUsernames,
12 | }: {
13 | userData: {
14 | id: string;
15 | username: string;
16 | name: string;
17 | bio: string;
18 | image: string;
19 | };
20 | allUsernames: {
21 | username: string;
22 | }[];
23 | }) {
24 | const [screen, setScreen] = useState(0);
25 |
26 | const nextScreen = () => setScreen((prev) => prev + 1);
27 |
28 | if (screen === 0) {
29 | return (
30 | <>
31 |
32 | {/* Skip */}
33 |
34 |
35 |
36 |
Profile
37 |
38 | Customize your Threads profile.
39 |
40 |
41 |
42 | user.username)}
44 | userData={userData}
45 | next={nextScreen}
46 | />
47 | >
48 | );
49 | }
50 | return (
51 | <>
52 |
59 |
60 |
61 |
Privacy
62 |
63 | Your privacy on Threads and Instagram can be different.
64 |
65 |
66 |
67 |
68 | >
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/components/profile/edit.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@/components/ui/dialog";
10 | import { Button } from "../ui/button";
11 | import { useEffect, useState, useTransition } from "react";
12 | import { Prisma } from "@prisma/client";
13 | import { Label } from "../ui/label";
14 | import { Input } from "../ui/input";
15 | import { AlertCircle, Loader2 } from "lucide-react";
16 | import { editProfile } from "@/lib/actions";
17 | import { usePathname } from "next/navigation";
18 | import { useToast } from "../ui/use-toast";
19 |
20 | export function EditModal({
21 | data,
22 | }: {
23 | data: Prisma.UserGetPayload<{
24 | include: {
25 | followedBy: true;
26 | };
27 | }>;
28 | }) {
29 | const [open, setOpen] = useState(false);
30 |
31 | const [username, setUsername] = useState(data.username);
32 | const [name, setName] = useState(data.name);
33 | const [bio, setBio] = useState(data.bio);
34 |
35 | const [clicked, setClicked] = useState(false);
36 |
37 | const [isPending, startTransition] = useTransition();
38 | const { toast } = useToast();
39 | const pathname = usePathname();
40 |
41 | useEffect(() => {
42 | if (clicked && !isPending) {
43 | setOpen(false);
44 | setClicked(false);
45 | toast({
46 | title: "Updated user data",
47 | });
48 | }
49 | }, [isPending]);
50 |
51 | return (
52 | <>
53 |
60 |
131 | >
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/components/profile/follow.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTransition } from "react";
4 | import { Button } from "../ui/button";
5 | import { useToast } from "../ui/use-toast";
6 | import { followUser, unfollowUser } from "@/lib/actions";
7 | import { Loader2 } from "lucide-react";
8 | import { usePathname } from "next/navigation";
9 |
10 | export default function FollowButton({
11 | isFollowing,
12 | name,
13 | id,
14 | followingId,
15 | }: {
16 | isFollowing: boolean;
17 | name: string;
18 | id: string;
19 | followingId: string;
20 | }) {
21 | const [isPending, startTransition] = useTransition();
22 | const { toast } = useToast();
23 | const pathname = usePathname();
24 |
25 | return (
26 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/components/profile/info.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | } from "@/components/ui/dialog";
11 | import { Github, HelpCircle } from "lucide-react";
12 | import { Button } from "../ui/button";
13 |
14 | export function InfoModal() {
15 | return (
16 | <>
17 |
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/components/profile/selfShare.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "../ui/button";
4 |
5 | export default function SelfShare({
6 | name,
7 | username,
8 | }: {
9 | name: string;
10 | username: string;
11 | }) {
12 | const shareData = {
13 | title: "Threads",
14 | text: "Link to " + name + "'s post on Threads",
15 | url: "http://localhost:3000/" + username,
16 | };
17 |
18 | return (
19 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/components/profile/signOut.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SignOutButton } from "@clerk/nextjs";
4 | import { LogOut } from "lucide-react";
5 | import { useRouter } from "next/navigation";
6 |
7 | export default function SignOut() {
8 | const router = useRouter();
9 |
10 | return (
11 | {
13 | router.push("/");
14 | }}
15 | >
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/components/search/bar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Search } from "lucide-react";
4 | import { Input } from "../ui/input";
5 | import { useRouter } from "next/navigation";
6 | import { useEffect, useState } from "react";
7 |
8 | export function Bar() {
9 | const router = useRouter();
10 |
11 | const [search, setSearch] = useState("");
12 |
13 | //query after 0.3s of no input
14 | useEffect(() => {
15 | const delayDebounceFn = setTimeout(() => {
16 | if (search) {
17 | console.log("pushing /search?q=" + search);
18 | router.push("/search?q=" + search);
19 | // searchSongs(search, accessToken, setSongResults)
20 | // console.log("searching for: " + search);
21 | } else {
22 | console.log("pushing /search");
23 | router.push("/search");
24 | }
25 | }, 300);
26 |
27 | return () => clearTimeout(delayDebounceFn);
28 | }, [search]);
29 |
30 | return (
31 |
32 |
33 | setSearch(e.target.value)}
37 | className="pl-8"
38 | />
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/components/search/follow.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTransition } from "react";
4 | import { Button } from "../ui/button";
5 | import { useToast } from "../ui/use-toast";
6 | import { followUser, unfollowUser } from "@/lib/actions";
7 | import { Loader2 } from "lucide-react";
8 | import { usePathname } from "next/navigation";
9 |
10 | export default function FollowButton({
11 | isFollowing,
12 | name,
13 | id,
14 | followingId,
15 | }: {
16 | isFollowing: boolean;
17 | name: string;
18 | id: string;
19 | followingId: string;
20 | }) {
21 | const [isPending, startTransition] = useTransition();
22 | const { toast } = useToast();
23 | const pathname = usePathname();
24 |
25 | return (
26 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/components/search/user.tsx:
--------------------------------------------------------------------------------
1 | import { Prisma, User } from "@prisma/client";
2 | import { Button } from "../ui/button";
3 | import Image from "next/image";
4 | import { nFormatter } from "@/lib/utils";
5 | import Link from "next/link";
6 | import FollowButton from "./follow";
7 |
8 | export function SearchUser({
9 | user,
10 | isFollowing,
11 | id,
12 | }: {
13 | user: Prisma.UserGetPayload<{
14 | include: {
15 | followedBy: true;
16 | };
17 | }>;
18 | isFollowing: boolean;
19 | id: string;
20 | }) {
21 | return (
22 |
23 |
24 |
31 |
32 |
33 |
34 |
{user.username}
35 |
{user.name}
36 |
37 | {nFormatter(user.followedBy.length, 1)}{" "}
38 | {user.followedBy.length === 1 ? "follower" : "followers"}
39 |
40 |
41 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/components/thread/backButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { Button } from "../ui/button";
5 | import { ChevronLeft } from "lucide-react";
6 |
7 | export default function BackButton() {
8 | const router = useRouter();
9 |
10 | return (
11 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/components/thread/comment/commentModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Item from "@/components/thread";
4 |
5 | import {
6 | Dialog,
7 | DialogContent,
8 | DialogDescription,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@/components/ui/dialog";
13 | import { MessageCircle } from "lucide-react";
14 | import { Create } from ".";
15 | import { Prisma } from "@prisma/client";
16 | import { useState } from "react";
17 |
18 | export function Modal({
19 | data,
20 | }: {
21 | data: Prisma.PostGetPayload<{
22 | include: {
23 | author: true;
24 | children: {
25 | include: {
26 | author: true;
27 | };
28 | };
29 | parent: true;
30 | likes: true;
31 | };
32 | }>;
33 | }) {
34 | const [open, setOpen] = useState(false);
35 |
36 | return (
37 | <>
38 |
47 |
56 | >
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/components/thread/comment/createComment.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Loader2, Paperclip } from "lucide-react";
4 | import { Button } from "../../ui/button";
5 | import { useEffect, useState, useTransition } from "react";
6 | import { Prisma } from "@prisma/client";
7 | import { useUser } from "@clerk/nextjs";
8 | import Image from "next/image";
9 | import { usePathname } from "next/navigation";
10 | import { replyToThread } from "@/lib/actions";
11 | import { useToast } from "@/components/ui/use-toast";
12 |
13 | export function Create({
14 | itemData,
15 | setOpen,
16 | }: {
17 | itemData: Prisma.PostGetPayload<{
18 | include: {
19 | author: true;
20 | children: {
21 | include: {
22 | author: true;
23 | };
24 | };
25 | parent: true;
26 | likes: true;
27 | };
28 | }>;
29 | setOpen: (open: boolean) => void;
30 | }) {
31 | const [comment, setComment] = useState("");
32 | const [clicked, setClicked] = useState(false);
33 |
34 | const { toast } = useToast();
35 | const { isSignedIn, isLoaded, user } = useUser();
36 | const [isPending, startTransition] = useTransition();
37 | const pathname = usePathname();
38 |
39 | useEffect(() => {
40 | if (clicked && !isPending) {
41 | setComment("");
42 | setOpen(false);
43 | setClicked(false);
44 | toast({
45 | title: "Replied to thread",
46 | });
47 | }
48 | }, [isPending]);
49 |
50 | if (!isLoaded || !isSignedIn) return null;
51 |
52 | return (
53 |
54 |
85 |
102 | {/*
*/}
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/components/thread/comment/index.tsx:
--------------------------------------------------------------------------------
1 | export * from "./createComment";
2 | export * from "./commentModal";
3 |
--------------------------------------------------------------------------------
/components/thread/controls/index.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Share from "./share";
4 | import { Modal } from "../comment";
5 | import Repost from "./repost";
6 | import { Prisma } from "@prisma/client";
7 | import Like from "./like";
8 |
9 | export default function Controls({
10 | data,
11 | numPosts,
12 | }: {
13 | data: Prisma.PostGetPayload<{
14 | include: {
15 | author: true;
16 | children: {
17 | include: {
18 | author: true;
19 | };
20 | };
21 | parent: true;
22 | likes: true;
23 | };
24 | }>;
25 | numPosts?: number;
26 | }) {
27 | const likes = data.likes.map((like) => like.userId);
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/thread/controls/like.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { likeThread, unlikeThread } from "@/lib/actions";
4 | import { useUser } from "@clerk/nextjs";
5 | import { Heart } from "lucide-react";
6 | import { usePathname } from "next/navigation";
7 | import { useEffect, useState, useTransition } from "react";
8 |
9 | export default function Like({
10 | post,
11 | numPosts,
12 | likes,
13 | }: {
14 | post: string;
15 | numPosts?: number;
16 | likes: string[];
17 | }) {
18 | const [liked, setLiked] = useState(false);
19 |
20 | const { isLoaded, isSignedIn, user } = useUser();
21 | const pathname = usePathname();
22 | const [isPending, startTransition] = useTransition();
23 |
24 | useEffect(() => {
25 | if (user) {
26 | if (likes.includes(user.id)) {
27 | setLiked(true);
28 | } else {
29 | setLiked(false);
30 | }
31 | }
32 | }, [user, numPosts]);
33 |
34 | const handleLike = () => {
35 | const wasLiked = liked;
36 | setLiked(!liked);
37 | if (user) {
38 | if (!wasLiked) {
39 | startTransition(() => likeThread(post, user.id, pathname));
40 | } else {
41 | startTransition(() => unlikeThread(post, user.id, pathname));
42 | }
43 | }
44 | };
45 |
46 | return (
47 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/components/thread/controls/repost.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuTrigger,
8 | } from "@/components/ui/dropdown-menu";
9 | import { MessageSquareDashed, Repeat2 } from "lucide-react";
10 | import { useToast } from "@/components/ui/use-toast";
11 |
12 | export default function Repost() {
13 | const { toast } = useToast();
14 |
15 | return (
16 |
17 | {
19 | e.preventDefault();
20 | e.stopPropagation();
21 | }}
22 | >
23 | {" "}
24 |
25 |
26 |
27 | {
29 | // e.preventDefault();
30 | // e.stopPropagation();
31 | // toast({
32 | // title: "Reposted",
33 | // });
34 | // }}
35 | disabled
36 | >
37 | {" "}
38 |
39 | Repost
40 |
41 |
42 | {" "}
43 |
44 | Quote
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/components/thread/controls/share.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { useToast } from "@/components/ui/use-toast";
12 | import { Link, Send, Share } from "lucide-react";
13 |
14 | export default function ShareButton({
15 | post,
16 | name,
17 | }: {
18 | post: string;
19 | name: string;
20 | }) {
21 | const { toast } = useToast();
22 |
23 | const shareData = {
24 | title: "Threads",
25 | text: "Link to " + name + "'s post on Threads",
26 | url: "http://localhost:3000/t/" + post,
27 | };
28 |
29 | return (
30 |
31 |
32 | {" "}
33 |
34 |
35 |
36 | {
38 | e.preventDefault();
39 | e.stopPropagation();
40 | navigator.clipboard.writeText(shareData.url);
41 | toast({
42 | title: "Copied to clipboard",
43 | });
44 | }}
45 | >
46 | {" "}
47 |
48 | Copy Link
49 |
50 | {
52 | e.preventDefault();
53 | e.stopPropagation();
54 | navigator.share(shareData);
55 | }}
56 | >
57 | {" "}
58 |
59 | Share Via...
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/thread/create/createThread.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createThread } from "@/lib/actions";
4 | import { Button } from "../../ui/button";
5 | import { useEffect, useState, useTransition } from "react";
6 | import Image from "next/image";
7 |
8 | import { usePathname } from "next/navigation";
9 | import { Loader2 } from "lucide-react";
10 | import { useToast } from "@/components/ui/use-toast";
11 |
12 | export function Create({
13 | setOpen,
14 | create,
15 | }: {
16 | setOpen: (open: boolean) => void;
17 | create: {
18 | id: string;
19 | name: string;
20 | image: string;
21 | };
22 | }) {
23 | const [thread, setThread] = useState("");
24 | const [clicked, setClicked] = useState(false);
25 |
26 | const { toast } = useToast();
27 |
28 | const [isPending, startTransition] = useTransition();
29 | const pathname = usePathname();
30 |
31 | useEffect(() => {
32 | if (clicked && !isPending) {
33 | setThread("");
34 | setOpen(false);
35 | setClicked(false);
36 | toast({
37 | title: "Thread created",
38 | });
39 | }
40 | }, [isPending]);
41 |
42 | return (
43 |
44 |
75 |
90 | {/*
*/}
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/components/thread/create/index.tsx:
--------------------------------------------------------------------------------
1 | export * from "./createThread";
2 | export * from "./threadModal";
3 |
--------------------------------------------------------------------------------
/components/thread/create/threadModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | DialogTrigger,
9 | } from "@/components/ui/dialog";
10 | import { Edit } from "lucide-react";
11 | import { Create } from ".";
12 | import { useState } from "react";
13 |
14 | export function Modal({
15 | create,
16 | }: {
17 | create: {
18 | id: string;
19 | name: string;
20 | image: string;
21 | };
22 | }) {
23 | const [open, setOpen] = useState(false);
24 |
25 | return (
26 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/components/thread/homePosts.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { Prisma } from "@prisma/client";
5 |
6 | import Item from ".";
7 | import { Button } from "../ui/button";
8 | import { useInView } from "react-intersection-observer";
9 | import { Loader2 } from "lucide-react";
10 |
11 | export default function HomePosts({
12 | posts,
13 | }: {
14 | posts: Prisma.PostGetPayload<{
15 | include: {
16 | author: true;
17 | children: {
18 | include: {
19 | author: true;
20 | };
21 | };
22 | parent: true;
23 | likes: true;
24 | };
25 | }>[];
26 | }) {
27 | const [items, setItems] = useState(posts);
28 | const [noMore, setNoMore] = useState(false);
29 | const [loading, setLoading] = useState(false);
30 |
31 | const { ref, inView } = useInView();
32 |
33 | useEffect(() => {
34 | if (inView && !noMore) {
35 | setLoading(true);
36 | loadMore();
37 | console.log("LOADING MORE");
38 | }
39 | }, [inView, noMore]);
40 |
41 | useEffect(() => {
42 | setItems(posts);
43 | }, [posts]);
44 |
45 | const loadMore = async () => {
46 | const morePosts = await fetch(
47 | `/api/loadMore?cursor=${items[items.length - 1].id}`,
48 | {
49 | method: "GET",
50 | }
51 | ).then((res) => res.json());
52 |
53 | if (morePosts.data.length === 0) {
54 | setNoMore(true);
55 | }
56 |
57 | setItems([...items, ...morePosts.data]);
58 | setLoading(false);
59 | };
60 |
61 | return (
62 | <>
63 | {items.map((item, i) => {
64 | if (i === items.length - 1)
65 | return (
66 |
67 |
68 |
69 | );
70 | return ;
71 | })}
72 |
73 | {items.length === 0 ? (
74 |
75 | There are no threads...
76 | Try making one!
77 |
78 | ) : null}
79 |
80 | {/* {noMore ? null : (
81 |
89 | )} */}
90 | {loading ? (
91 |
92 | ) : null}
93 |
94 | >
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/components/thread/index.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | import Others from "./others";
5 | import MoreMenu from "./moreMenu";
6 | import Controls from "./controls";
7 | import { Post, Prisma } from "@prisma/client";
8 |
9 | // import relativeTime from "dayjs/plugin/relativeTime";
10 | // import dayjs from "dayjs";
11 | // import updateLocale from "dayjs/plugin/updateLocale";
12 |
13 | import loop from "@/assets/loop.svg";
14 | import { timeSince } from "@/lib/utils";
15 | import Timestamp from "./timestamp";
16 | import NameLink from "./nameLink";
17 |
18 | export default function Item({
19 | data,
20 | comment = false,
21 | posts,
22 | noLink = false,
23 | parent = false,
24 | }: {
25 | data: Prisma.PostGetPayload<{
26 | include: {
27 | author: true;
28 | children: {
29 | include: {
30 | author: true;
31 | };
32 | };
33 | parent: true;
34 | likes: true;
35 | };
36 | }>;
37 | comment?: boolean;
38 | posts?: Prisma.PostGetPayload<{
39 | include: {
40 | author: true;
41 | children: {
42 | include: {
43 | author: true;
44 | };
45 | };
46 | parent: true;
47 | likes: true;
48 | };
49 | }>[];
50 | noLink?: boolean;
51 | parent?: boolean;
52 | }) {
53 | const mainClass = parent
54 | ? "px-3 pt-4 space-x-2 flex font-light"
55 | : comment
56 | ? `space-x-2 flex font-light ${noLink ? "pointer-events-none" : ""}`
57 | : `px-3 py-4 space-x-2 flex border-b font-light border-neutral-900 ${
58 | noLink ? "pointer-events-none" : ""
59 | }`;
60 |
61 | // dayjs.extend(relativeTime);
62 | // const ago = dayjs(data.createdAt).fromNow();
63 |
64 | // dayjs.extend(updateLocale);
65 |
66 | // dayjs.updateLocale("en", {
67 | // relativeTime: {
68 | // future: "in %s",
69 | // past: "%s",
70 | // s: "now",
71 | // m: "1m",
72 | // mm: "%dm",
73 | // h: "1h",
74 | // hh: "%dh",
75 | // d: "1d",
76 | // dd: "%dd",
77 | // M: "1m",
78 | // MM: "%dm",
79 | // y: "1y",
80 | // yy: "%dy",
81 | // },
82 | // });
83 |
84 | return (
85 | <>
86 |
87 |
88 |
89 |
96 |
97 |
102 | {parent ? (
103 |
104 |
111 |
112 | ) : null}
113 |
114 | {comment || parent ? null :
}
115 |
116 |
117 |
118 |
119 |
120 | {comment ? null : (
121 |
122 | {/* */}
123 |
128 |
129 | )}
130 |
131 |
138 | {data.text}
139 |
140 | {comment ? null : (
141 | <>
142 |
143 |
144 | {data.children.length > 0 ? (
145 |
146 | {data.children.length}{" "}
147 | {data.children.length === 1 ? "reply" : "replies"}
148 |
149 | ) : null}
150 | {data.children.length > 0 && data.likes.length > 0 ? (
151 |
152 | ) : null}
153 | {data.likes.length > 0 ? (
154 |
155 | {data.likes.length}{" "}
156 | {data.likes.length === 1 ? "like" : "likes"}
157 |
158 | ) : null}
159 |
160 | >
161 | )}
162 |
163 |
164 | >
165 | );
166 | }
167 |
--------------------------------------------------------------------------------
/components/thread/main.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import MoreMenu from "./moreMenu";
4 | import Controls from "./controls";
5 | import { Post, Prisma } from "@prisma/client";
6 | import { timeSince } from "@/lib/utils";
7 | import Timestamp from "./timestamp";
8 | import NameLink from "./nameLink";
9 |
10 | // import relativeTime from "dayjs/plugin/relativeTime";
11 | // import dayjs from "dayjs";
12 | // import updateLocale from "dayjs/plugin/updateLocale";
13 |
14 | export default function MainItem({
15 | data,
16 | comment = false,
17 | posts,
18 | }: {
19 | data: Prisma.PostGetPayload<{
20 | include: {
21 | author: true;
22 | children: {
23 | include: {
24 | author: true;
25 | children: true;
26 | parent: true;
27 | likes: true;
28 | };
29 | };
30 | parent: true;
31 | likes: true;
32 | };
33 | }>;
34 | comment?: boolean;
35 | posts?: Prisma.PostGetPayload<{
36 | include: {
37 | author: true;
38 | children: true;
39 | parent: true;
40 | likes: true;
41 | };
42 | }>[];
43 | }) {
44 | // dayjs.extend(relativeTime);
45 | // const ago = dayjs(data.createdAt).fromNow();
46 |
47 | // dayjs.extend(updateLocale);
48 |
49 | // dayjs.updateLocale("en", {
50 | // relativeTime: {
51 | // future: "in %s",
52 | // past: "%s",
53 | // s: "now",
54 | // m: "1m",
55 | // mm: "%dm",
56 | // h: "1h",
57 | // hh: "%dh",
58 | // d: "1d",
59 | // dd: "%dd",
60 | // M: "1m",
61 | // MM: "%dm",
62 | // y: "1y",
63 | // yy: "%dy",
64 | // },
65 | // });
66 |
67 | return (
68 |
69 |
70 |
82 |
83 | {/* */}
84 |
89 |
90 |
91 |
92 |
{data.text}
93 |
94 |
95 | {data.children.length > 0 ? (
96 |
97 | {data.children.length}{" "}
98 | {data.children.length === 1 ? "reply" : "replies"}
99 |
100 | ) : null}
101 | {data.children.length > 0 && data.likes.length > 0 ? (
102 |
103 | ) : null}
104 | {data.likes.length > 0 ? (
105 |
106 | {data.likes.length} {data.likes.length === 1 ? "like" : "likes"}
107 |
108 | ) : null}
109 |
110 |
111 |
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/components/thread/moreMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { useUser } from "@clerk/nextjs";
12 | import { Flag, Loader2, MoreHorizontal, Trash, UserX2 } from "lucide-react";
13 | import { useToast } from "../ui/use-toast";
14 | import { useEffect, useState, useTransition } from "react";
15 | import { usePathname, useRouter } from "next/navigation";
16 | import { deleteThread } from "@/lib/actions";
17 |
18 | export default function MoreMenu({
19 | author,
20 | name,
21 | mainPage = false,
22 | id,
23 | }: {
24 | author: string;
25 | name: string;
26 | mainPage?: boolean;
27 | id: string;
28 | }) {
29 | const { user } = useUser();
30 | const { toast } = useToast();
31 | const pathname = usePathname();
32 | const router = useRouter();
33 | const [isPending, startTransition] = useTransition();
34 |
35 | const [deleted, setDeleted] = useState(false);
36 | const [open, setOpen] = useState(false);
37 |
38 | const self = user?.id === author;
39 |
40 | useEffect(() => {
41 | if (deleted && !isPending) {
42 | toast({
43 | title: "Thread deleted",
44 | });
45 | setOpen(false);
46 | if (pathname.startsWith("/t")) {
47 | router.push("/");
48 | }
49 | }
50 | }, [deleted, isPending]);
51 |
52 | return (
53 |
54 | {
56 | e.stopPropagation();
57 | e.preventDefault();
58 | setOpen((prev) => !prev);
59 | }}
60 | >
61 | {" "}
62 |
63 |
64 |
65 | {self ? (
66 | {
68 | e.stopPropagation();
69 | e.preventDefault();
70 | startTransition(() => deleteThread(id, pathname));
71 | setDeleted(true);
72 | }}
73 | disabled={deleted}
74 | className="!text-red-500"
75 | >
76 | {" "}
77 | {deleted ? (
78 |
79 | ) : (
80 |
81 | )}
82 | Delete
83 |
84 | ) : (
85 | <>
86 | {
88 | e.stopPropagation();
89 | e.preventDefault();
90 | toast({
91 | title: name + " has been blocked",
92 | });
93 | setOpen(false);
94 | }}
95 | >
96 |
97 | Block
98 |
99 | {
101 | e.stopPropagation();
102 | e.preventDefault();
103 | toast({
104 | title: name + " has been reported",
105 | });
106 | setOpen(false);
107 | }}
108 | className="!text-red-500"
109 | >
110 | {" "}
111 |
112 | Report
113 |
114 | >
115 | )}
116 |
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/components/thread/nameLink.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 |
5 | export default function NameLink({
6 | name,
7 | username,
8 | }: {
9 | name: string;
10 | username: string;
11 | }) {
12 | const router = useRouter();
13 |
14 | return (
15 | {
18 | e.preventDefault();
19 | e.stopPropagation();
20 | router.push(`/${username}`);
21 | }}
22 | >
23 | {name}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/components/thread/others.tsx:
--------------------------------------------------------------------------------
1 | import { Post, Prisma } from "@prisma/client";
2 | import Image from "next/image";
3 |
4 | export default function Others({
5 | others,
6 | }: {
7 | others: Prisma.PostGetPayload<{
8 | include: {
9 | author: true;
10 | };
11 | }>[];
12 | }) {
13 | if (others.length === 0) {
14 | return null;
15 | }
16 | if (others.length === 1) {
17 | return (
18 |
29 | );
30 | }
31 | if (others.length === 2) {
32 | return (
33 |
34 |
35 |
42 |
43 |
44 |
51 |
52 |
53 | );
54 | }
55 | return (
56 |
57 |
58 |
65 |
66 |
67 |
74 |
75 |
76 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/components/thread/timestamp.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { timeSince } from "@/lib/utils";
4 |
5 | export default function Timestamp({ time }: { time: Date }) {
6 | return {timeSince(time)}
;
7 | }
8 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border border-neutral-200 px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-400 focus:ring-offset-2 dark:border-neutral-800 dark:focus:ring-neutral-800",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-neutral-900 text-neutral-50 hover:bg-neutral-900/80 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/80",
13 | secondary:
14 | "border-none bg-neutral-100 text-neutral-700 hover:bg-neutral-100/80 dark:bg-neutral-900 dark:text-neutral-600",
15 | destructive:
16 | "border-transparent bg-red-500 text-neutral-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/80",
17 | outline: "text-neutral-950 dark:text-neutral-50",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/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-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-800",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90",
14 | destructive:
15 | "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
18 | secondary:
19 | "bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
20 | ghost:
21 | "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
22 | link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
23 | },
24 | size: {
25 | default: "h-10 px-4 py-2",
26 | sm: "h-8 rounded-md px-3",
27 | lg: "h-11 rounded-md px-8",
28 | icon: "h-10 w-10",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | );
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
53 | );
54 | }
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = ({
14 | className,
15 | ...props
16 | }: DialogPrimitive.DialogPortalProps) => (
17 |
18 | )
19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName
20 |
21 | const DialogOverlay = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
33 | ))
34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
35 |
36 | const DialogContent = React.forwardRef<
37 | React.ElementRef,
38 | React.ComponentPropsWithoutRef
39 | >(({ className, children, ...props }, ref) => (
40 |
41 |
42 |
50 | {children}
51 |
52 |
53 | Close
54 |
55 |
56 |
57 | ))
58 | DialogContent.displayName = DialogPrimitive.Content.displayName
59 |
60 | const DialogHeader = ({
61 | className,
62 | ...props
63 | }: React.HTMLAttributes) => (
64 |
71 | )
72 | DialogHeader.displayName = "DialogHeader"
73 |
74 | const DialogFooter = ({
75 | className,
76 | ...props
77 | }: React.HTMLAttributes) => (
78 |
85 | )
86 | DialogFooter.displayName = "DialogFooter"
87 |
88 | const DialogTitle = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
100 | ))
101 | DialogTitle.displayName = DialogPrimitive.Title.displayName
102 |
103 | const DialogDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | DialogDescription.displayName = DialogPrimitive.Description.displayName
114 |
115 | export {
116 | Dialog,
117 | DialogTrigger,
118 | DialogContent,
119 | DialogHeader,
120 | DialogFooter,
121 | DialogTitle,
122 | DialogDescription,
123 | }
124 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/components/ui/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/modeToggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Moon, Sun } from "lucide-react";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/ui/nav.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname } from "next/navigation";
4 | import Link from "next/link";
5 |
6 | import { Heart, Home, Search, User2 } from "lucide-react";
7 | import { Modal } from "../thread/create";
8 |
9 | export default function Nav({
10 | username,
11 | create,
12 | }: {
13 | username: string | null;
14 | create: {
15 | id: string;
16 | name: string;
17 | image: string;
18 | };
19 | }) {
20 | const path = usePathname();
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
31 |
32 |
33 |
34 |
39 |
40 |
41 | {/* */}
42 | {username === null ? (
43 |
44 | ) : (
45 |
50 | )}
51 | {/* */}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/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/themeProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children};
9 | }
10 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToastPrimitives from "@radix-ui/react-toast";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { X } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-neutral-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-white dark:bg-neutral-950",
31 | destructive:
32 | "destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-red-50",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | );
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | );
53 | });
54 | Toast.displayName = ToastPrimitives.Root.displayName;
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ));
69 | ToastAction.displayName = ToastPrimitives.Action.displayName;
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ));
87 | ToastClose.displayName = ToastPrimitives.Close.displayName;
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ));
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef;
114 |
115 | type ToastActionElement = React.ReactElement;
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | };
128 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast";
11 | import { useToast } from "@/components/ui/use-toast";
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast();
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | );
31 | })}
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react";
3 |
4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | const actionTypes = {
17 | ADD_TOAST: "ADD_TOAST",
18 | UPDATE_TOAST: "UPDATE_TOAST",
19 | DISMISS_TOAST: "DISMISS_TOAST",
20 | REMOVE_TOAST: "REMOVE_TOAST",
21 | } as const;
22 |
23 | let count = 0;
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_VALUE;
27 | return count.toString();
28 | }
29 |
30 | type ActionType = typeof actionTypes;
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"];
43 | toastId?: ToasterToast["id"];
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"];
47 | toastId?: ToasterToast["id"];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | });
67 | }, TOAST_REMOVE_DELAY);
68 |
69 | toastTimeouts.set(toastId, timeout);
70 | };
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | };
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) =>
84 | t.id === action.toast.id ? { ...t, ...action.toast } : t
85 | ),
86 | };
87 |
88 | case "DISMISS_TOAST": {
89 | const { toastId } = action;
90 |
91 | // ! Side effects ! - This could be extracted into a dismissToast() action,
92 | // but I'll keep it here for simplicity
93 | if (toastId) {
94 | addToRemoveQueue(toastId);
95 | } else {
96 | state.toasts.forEach((toast) => {
97 | addToRemoveQueue(toast.id);
98 | });
99 | }
100 |
101 | return {
102 | ...state,
103 | toasts: state.toasts.map((t) =>
104 | t.id === toastId || toastId === undefined
105 | ? {
106 | ...t,
107 | open: false,
108 | }
109 | : t
110 | ),
111 | };
112 | }
113 | case "REMOVE_TOAST":
114 | if (action.toastId === undefined) {
115 | return {
116 | ...state,
117 | toasts: [],
118 | };
119 | }
120 | return {
121 | ...state,
122 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
123 | };
124 | }
125 | };
126 |
127 | const listeners: Array<(state: State) => void> = [];
128 |
129 | let memoryState: State = { toasts: [] };
130 |
131 | function dispatch(action: Action) {
132 | memoryState = reducer(memoryState, action);
133 | listeners.forEach((listener) => {
134 | listener(memoryState);
135 | });
136 | }
137 |
138 | type Toast = Omit;
139 |
140 | function toast({ ...props }: Toast) {
141 | const id = genId();
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: "UPDATE_TOAST",
146 | toast: { ...props, id },
147 | });
148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
149 |
150 | dispatch({
151 | type: "ADD_TOAST",
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: (open) => {
157 | if (!open) dismiss();
158 | },
159 | },
160 | });
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | };
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState);
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState);
174 | return () => {
175 | const index = listeners.indexOf(setState);
176 | if (index > -1) {
177 | listeners.splice(index, 1);
178 | }
179 | };
180 | }, [state]);
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
186 | };
187 | }
188 |
189 | export { useToast, toast };
190 |
--------------------------------------------------------------------------------
/lib/actions/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./userActions";
2 | export * from "./threadActions";
3 |
--------------------------------------------------------------------------------
/lib/actions/threadActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import prisma from "../prisma";
5 | import { cleanup } from "../utils";
6 |
7 | export async function createThread(
8 | text: string,
9 | authorId: string,
10 | path: string
11 | ) {
12 | await prisma.post.create({
13 | data: {
14 | text: cleanup(text),
15 | author: {
16 | connect: {
17 | id: authorId,
18 | },
19 | },
20 | },
21 | });
22 |
23 | revalidatePath(path);
24 | }
25 |
26 | export async function replyToThread(
27 | text: string,
28 | authorId: string,
29 | threadId: string,
30 | path: string
31 | ) {
32 | await prisma.post.create({
33 | data: {
34 | text: cleanup(text),
35 | author: {
36 | connect: {
37 | id: authorId,
38 | },
39 | },
40 | parent: {
41 | connect: {
42 | id: threadId,
43 | },
44 | },
45 | },
46 | });
47 |
48 | revalidatePath(path);
49 | }
50 |
51 | export async function repostThread(
52 | id: string,
53 | reposterId: string,
54 | path: string
55 | ) {
56 | await prisma.repost.create({
57 | data: {
58 | post: {
59 | connect: {
60 | id,
61 | },
62 | },
63 | reposter: {
64 | connect: {
65 | id: reposterId,
66 | },
67 | },
68 | },
69 | });
70 |
71 | revalidatePath(path);
72 | }
73 |
74 | export async function deleteThread(id: string, path: string) {
75 | // ! navigate back to home if on dedicated page for this thread & its deleted
76 |
77 | await prisma.post.update({
78 | where: {
79 | id,
80 | },
81 | data: {
82 | likes: {
83 | deleteMany: {},
84 | },
85 | children: {
86 | deleteMany: {},
87 | },
88 | },
89 | include: {
90 | likes: true,
91 | },
92 | });
93 |
94 | await prisma.post.delete({
95 | where: {
96 | id,
97 | },
98 | });
99 |
100 | revalidatePath(path);
101 | }
102 |
103 | export async function likeThread(id: string, userId: string, path: string) {
104 | await prisma.likes.create({
105 | data: {
106 | post: {
107 | connect: {
108 | id,
109 | },
110 | },
111 | user: {
112 | connect: {
113 | id: userId,
114 | },
115 | },
116 | },
117 | });
118 |
119 | await prisma.post.update({
120 | where: {
121 | id,
122 | },
123 | data: {
124 | likes: {
125 | connect: {
126 | postId_userId: {
127 | postId: id,
128 | userId,
129 | },
130 | },
131 | },
132 | },
133 | });
134 |
135 | revalidatePath(path);
136 | }
137 |
138 | export async function unlikeThread(id: string, userId: string, path: string) {
139 | await prisma.likes.delete({
140 | where: {
141 | postId_userId: {
142 | postId: id,
143 | userId,
144 | },
145 | },
146 | });
147 |
148 | revalidatePath(path);
149 | }
150 |
--------------------------------------------------------------------------------
/lib/actions/userActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import prisma from "../prisma";
5 | import { cleanup } from "../utils";
6 |
7 | export async function changeUsername(
8 | username: string,
9 | userId: string,
10 | path: string
11 | ) {
12 | await prisma.user.update({
13 | where: {
14 | id: userId,
15 | },
16 | data: {
17 | username: username.toLowerCase(),
18 | },
19 | });
20 |
21 | revalidatePath(path);
22 | }
23 |
24 | export async function editProfile(
25 | name: string,
26 | bio: string,
27 | userId: string,
28 | path: string
29 | ) {
30 | await prisma.user.update({
31 | where: {
32 | id: userId,
33 | },
34 | data: {
35 | name: cleanup(name),
36 | bio: cleanup(bio),
37 | },
38 | });
39 |
40 | revalidatePath(path);
41 | }
42 |
43 | // update all 3 in a function called "onboardData"
44 |
45 | export async function onboardData(
46 | username: string,
47 | name: string,
48 | bio: string,
49 | image: string,
50 | userId: string
51 | ) {
52 | await prisma.user.create({
53 | data: {
54 | id: userId,
55 | username: username.toLowerCase(),
56 | name: cleanup(name),
57 | bio: cleanup(bio),
58 | image,
59 | onboarded: true,
60 | },
61 | });
62 | }
63 |
64 | export async function followUser(
65 | userId: string,
66 | followingId: string,
67 | path: string
68 | ) {
69 | await prisma.user.update({
70 | where: {
71 | id: userId,
72 | },
73 | data: {
74 | following: {
75 | connect: {
76 | id: followingId,
77 | },
78 | },
79 | },
80 | });
81 |
82 | revalidatePath(path);
83 | }
84 |
85 | export async function unfollowUser(
86 | userId: string,
87 | followingId: string,
88 | path: string
89 | ) {
90 | await prisma.user.update({
91 | where: {
92 | id: userId,
93 | },
94 | data: {
95 | following: {
96 | disconnect: {
97 | id: followingId,
98 | },
99 | },
100 | },
101 | });
102 |
103 | revalidatePath(path);
104 | }
105 |
--------------------------------------------------------------------------------
/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | declare global {
4 | // eslint-disable-next-line no-var
5 | var prisma: PrismaClient | undefined;
6 | }
7 |
8 | let prisma: PrismaClient;
9 |
10 | if (process.env.VERCEL_ENV === "production") {
11 | prisma = new PrismaClient();
12 | } else {
13 | if (!global.prisma) {
14 | global.prisma = new PrismaClient();
15 | }
16 | prisma = global.prisma;
17 | }
18 |
19 | export default prisma;
20 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import Filter from "bad-words";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export const timeSince = (date: Date) => {
10 | const d = new Date();
11 | const seconds = Math.floor((d.getTime() - date.getTime()) / 1000);
12 | var interval = seconds / 31536000;
13 |
14 | if (interval > 1) {
15 | return Math.floor(interval) + "y";
16 | }
17 | interval = seconds / 2592000;
18 | if (interval > 1) {
19 | return Math.floor(interval) + "m";
20 | }
21 | interval = seconds / 86400;
22 | if (interval > 1) {
23 | return Math.floor(interval) + "d";
24 | }
25 | interval = seconds / 3600;
26 | if (interval > 1) {
27 | return Math.floor(interval) + "h";
28 | }
29 | interval = seconds / 60;
30 | if (interval > 1) {
31 | return Math.floor(interval) + "m";
32 | }
33 | return Math.floor(seconds) + "s";
34 | };
35 |
36 | export const nFormatter = (num: number, digits: number) => {
37 | const lookup = [
38 | { value: 1, symbol: "" },
39 | { value: 1e3, symbol: "k" },
40 | { value: 1e6, symbol: "M" },
41 | { value: 1e9, symbol: "G" },
42 | { value: 1e12, symbol: "T" },
43 | { value: 1e15, symbol: "P" },
44 | { value: 1e18, symbol: "E" },
45 | ];
46 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
47 | var item = lookup
48 | .slice()
49 | .reverse()
50 | .find(function (item) {
51 | return num >= item.value;
52 | });
53 | return item
54 | ? (num / item.value).toFixed(digits).replace(rx, "$1") + item.symbol
55 | : "0";
56 | };
57 |
58 | export const validateUsername = (text: string) => {
59 | const pattern = /^[a-zA-Z0-9][a-zA-Z0-9._]*[a-zA-Z0-9]$/;
60 | return pattern.test(text);
61 | };
62 |
63 | export const cleanup = (text: string) => {
64 | const filter = new Filter();
65 |
66 | try {
67 | return filter.clean(text);
68 | } catch {
69 | return text;
70 | }
71 | };
72 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | export default authMiddleware({
4 | publicRoutes: ["/"],
5 | });
6 |
7 | export const config = {
8 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
9 | };
10 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | serverActions: true,
5 | },
6 | images: {
7 | remotePatterns: [
8 | {
9 | hostname: "img.clerk.com",
10 | },
11 | ],
12 | },
13 | };
14 |
15 | module.exports = nextConfig;
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "threads",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "prisma generate && next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@clerk/nextjs": "^4.21.14",
13 | "@clerk/themes": "^1.7.5",
14 | "@prisma/client": "^4.16.2",
15 | "@radix-ui/react-dialog": "^1.0.4",
16 | "@radix-ui/react-dropdown-menu": "^2.0.5",
17 | "@radix-ui/react-label": "^2.0.2",
18 | "@radix-ui/react-slot": "^1.0.2",
19 | "@radix-ui/react-tabs": "^1.0.4",
20 | "@radix-ui/react-toast": "^1.1.4",
21 | "@types/node": "20.4.0",
22 | "@types/react": "18.2.14",
23 | "@types/react-dom": "18.2.6",
24 | "autoprefixer": "10.4.14",
25 | "bad-words": "^3.0.4",
26 | "class-variance-authority": "^0.6.1",
27 | "clsx": "^1.2.1",
28 | "eslint": "8.44.0",
29 | "eslint-config-next": "13.4.8",
30 | "lucide-react": "^0.258.0",
31 | "next": "13.4.8",
32 | "next-themes": "^0.2.1",
33 | "postcss": "8.4.25",
34 | "react": "18.2.0",
35 | "react-dom": "18.2.0",
36 | "react-intersection-observer": "^9.5.2",
37 | "tailwind-merge": "^1.13.2",
38 | "tailwindcss": "3.3.2",
39 | "tailwindcss-animate": "^1.0.6",
40 | "typescript": "5.1.6"
41 | },
42 | "devDependencies": {
43 | "@types/bad-words": "^3.0.1",
44 | "prisma": "^4.16.2"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // schema.prisma
2 |
3 | generator client {
4 | provider = "prisma-client-js"
5 | }
6 |
7 | datasource db {
8 | provider = "postgresql"
9 | url = env("POSTGRES_PRISMA_URL") // uses connection pooling
10 | directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
11 | shadowDatabaseUrl = env("POSTGRES_URL_NON_POOLING") // used for migrations
12 | }
13 |
14 | model Post {
15 | id String @id @default(cuid())
16 | text String
17 | author User @relation(fields: [authorId], references: [id])
18 | authorId String
19 | repost Repost?
20 | createdAt DateTime @default(now())
21 | likes Likes[]
22 |
23 | parentId String?
24 | parent Post? @relation("ParentChildren", fields: [parentId], references: [id])
25 | children Post[] @relation("ParentChildren")
26 | }
27 |
28 | model Repost {
29 | id String @id @default(cuid())
30 | post Post @relation(fields: [postId], references: [id])
31 | postId String @unique
32 |
33 | reposter User @relation(fields: [reposterId], references: [id])
34 | reposterId String
35 | }
36 |
37 | model User {
38 | id String @id
39 | username String @unique
40 | name String
41 | image String
42 | bio String
43 | posts Post[]
44 | reposts Repost[]
45 | likes Likes[]
46 | followedBy User[] @relation("UserFollows")
47 | following User[] @relation("UserFollows")
48 | onboarded Boolean @default(false)
49 | }
50 |
51 | model Likes {
52 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
53 | postId String
54 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
55 | userId String
56 | createdAt DateTime @default(now())
57 |
58 | @@id([postId, userId])
59 | }
60 |
--------------------------------------------------------------------------------
/public/threads.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ishaan1013/thr/189ccc6d0be9605cb9a0e9ef5eeb60a1b2812463/public/threads.png
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | },
15 | fontSize: {
16 | xs: ["0.75rem", "1rem"],
17 | sm: ["0.875rem", "1.25rem"],
18 | base: ["0.9375rem", "1.5rem"],
19 | lg: ["1.125rem", "1.75rem"],
20 | xl: ["1.25rem", "1.75rem"],
21 | "2xl": ["1.5rem", "2rem"],
22 | "3xl": ["1.875rem", "2.25rem"],
23 | "4xl": ["2.25rem", "2.5rem"],
24 | "5xl": ["3rem", "1"],
25 | "6xl": ["3.75rem", "1"],
26 | "7xl": ["4.5rem", "1"],
27 | "8xl": ["6rem", "1"],
28 | "9xl": ["8rem", "1"],
29 | },
30 | extend: {
31 | keyframes: {
32 | "accordion-down": {
33 | from: { height: 0 },
34 | to: { height: "var(--radix-accordion-content-height)" },
35 | },
36 | "accordion-up": {
37 | from: { height: "var(--radix-accordion-content-height)" },
38 | to: { height: 0 },
39 | },
40 | },
41 | animation: {
42 | "accordion-down": "accordion-down 0.2s ease-out",
43 | "accordion-up": "accordion-up 0.2s ease-out",
44 | },
45 | },
46 | },
47 | plugins: [require("tailwindcss-animate")],
48 | };
49 |
--------------------------------------------------------------------------------
/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": "node",
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": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/prisma.schema"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------