├── .env.example ├── .gitignore ├── README.md ├── components.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── migrations │ ├── 20230708190621_initial │ │ └── migration.sql │ ├── 20230711161931_update │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── icon-192x192.png ├── icon-256x256.png ├── icon-384x384.png ├── icon-512x512.png ├── logo │ ├── threads-dark.svg │ └── threads.svg ├── manifest.json └── vercel.svg ├── src ├── app │ ├── [username] │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── replies │ │ │ └── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.tsx │ │ ├── loadmore │ │ │ └── route.ts │ │ ├── thread │ │ │ ├── create │ │ │ │ └── route.ts │ │ │ └── reply │ │ │ │ └── route.ts │ │ └── uploadthing │ │ │ ├── core.ts │ │ │ └── route.ts │ ├── create │ │ ├── comment │ │ │ └── [id] │ │ │ │ └── page.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── notifications │ │ ├── layout.tsx │ │ ├── likes │ │ │ └── page.tsx │ │ ├── mentions │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── replies │ │ │ └── page.tsx │ ├── onboarding │ │ └── page.tsx │ ├── page.tsx │ ├── search │ │ └── page.tsx │ ├── settings │ │ ├── layout.tsx │ │ └── page.tsx │ ├── sign-in │ │ └── page.tsx │ ├── styles │ │ └── editor.css │ └── thread │ │ ├── [id] │ │ └── page.tsx │ │ └── layout.tsx ├── assets │ ├── google.svg │ ├── loop-light.svg │ ├── loop.svg │ ├── verified.png │ └── verified.svg ├── components │ ├── AuthenticationForm.tsx │ ├── SubmitThread.tsx │ ├── ThreadCreate.tsx │ ├── common │ │ ├── Navbar.tsx │ │ ├── ProfilePreview.tsx │ │ └── Providers.tsx │ ├── logo │ │ └── Logo.tsx │ ├── miscellaneous │ │ ├── CloseModal.tsx │ │ └── SorryPageNotFound.tsx │ ├── notifications │ │ ├── AllNotifications.tsx │ │ ├── Notification.tsx │ │ └── NotificationsNav.tsx │ ├── onboarding │ │ ├── OnboardingProfileUpdate.tsx │ │ ├── Privacy.tsx │ │ └── Screens.tsx │ ├── profile │ │ ├── EditProfile.tsx │ │ ├── Profile.tsx │ │ └── SignOut.tsx │ ├── search │ │ ├── Bar.tsx │ │ ├── FollowButton.tsx │ │ └── SearchUser.tsx │ ├── settings │ │ └── ThemeChange.tsx │ ├── thread │ │ ├── AuthorNameLink.tsx │ │ ├── BackButton.tsx │ │ ├── HomeThreads.tsx │ │ ├── MainThread.tsx │ │ ├── MoreMenu.tsx │ │ ├── Others.tsx │ │ ├── Thread.tsx │ │ ├── ThreadComponent.tsx │ │ ├── comment │ │ │ └── CreateComponent.tsx │ │ ├── controls │ │ │ ├── Comment.tsx │ │ │ ├── Like.tsx │ │ │ ├── Repost.tsx │ │ │ ├── Share.tsx │ │ │ └── index.tsx │ │ └── create │ │ │ └── Create.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── scroll-area.tsx │ │ ├── separator.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ └── toaster.tsx ├── lib │ ├── actions │ │ ├── index.ts │ │ ├── threadActions.ts │ │ └── userActions.ts │ ├── auth.ts │ ├── db.ts │ ├── uploadImage.ts │ ├── uploadthing.ts │ ├── use-toast.ts │ ├── username.ts │ ├── utils.ts │ └── validators │ │ └── threadSubmit.ts ├── middleware.ts ├── types │ ├── editor.d.ts │ └── next-auth.d.ts └── urls │ └── navigationUrls.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | GOOGLE_CLIENT_ID= 2 | GOOGLE_CLIENT_SECRET= 3 | 4 | 5 | DATABASE_URL= 6 | 7 | NEXTAUTH_SECRET= 8 | 9 | 10 | # From Cloudinary 11 | 12 | NEXT_PUBLIC_CLOUDINARY_PRESET= 13 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= 14 | 15 | NEXT_PUBLIC_PRODUCTION_URL= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | 39 | # Auto Generated PWA files 40 | **/public/sw.js 41 | **/public/workbox-*.js 42 | **/public/worker-*.js 43 | **/public/sw.js.map 44 | **/public/workbox-*.js.map 45 | **/public/worker-*.js.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Threads Clone 2 | 3 | ### A fullstack Threads clone built with Next JS 13, Prisma, TypeScript, and Tailwind CSS. 4 | 5 | ## Features 6 | - [x] Setup 7 | - [x] User authentication 8 | - [x] Create threads 9 | - [x] Onboard new users 10 | - [x] display thread details 11 | - [x] Displaying threads in feed & optimizations 12 | - [x] like, repost, comment, share 13 | - [x] Replying to threads 14 | - [x] Search bar 15 | - [x] Deployment 16 | 17 | ## Installation 18 | 19 | 20 | 21 | ```bash 22 | npm install 23 | 24 | ``` 25 | 26 | ## Run Locally 27 | 28 | Clone the project 29 | 30 | ```bash 31 | git clone https://github.com/pawan67/threads-clone.git 32 | ``` 33 | 34 | Go to the project directory 35 | 36 | ```bash 37 | cd threads-clone 38 | ``` 39 | 40 | Install dependencies 41 | 42 | ```bash 43 | npm install 44 | ``` 45 | 46 | Start the server 47 | 48 | ```bash 49 | npm run dev 50 | ``` 51 | 52 | 53 | ## Demo Link 54 | 55 | [https://threads-clone.vercel.app/](https://threads-clone.vercel.app/) 56 | 57 | 58 | 59 | ## Tech Stack 60 | 61 | **Client:** Next Js 13, TailwindCSS, ShadcnUI, Typescript 62 | 63 | **Server:** Prisma, Next Js routes 64 | 65 | 66 | ## Screenshots 67 | 68 | ![App Screenshot](https://i.imgur.com/AqhmIx3.png) 69 | ![App Screenshot](https://i.imgur.com/ovw6Pz0.png) 70 | ![App Screenshot](https://i.imgur.com/yEfINI3.png) 71 | ![App Screenshot](https://i.imgur.com/eywnFJo.png) 72 | ![App Screenshot](https://i.imgur.com/RXAKoJp.png) 73 | ![App Screenshot](https://i.imgur.com/PwMO3Ex.png) 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "src/components", 14 | "utils": "src/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const withPWA = require("next-pwa")({ 4 | dest: "public", 5 | register: true, 6 | skipWaiting: true, 7 | }); 8 | 9 | const nextConfig = { 10 | experimental: { 11 | serverActions: true, 12 | }, 13 | typescript: { 14 | ignoreBuildErrors: true, 15 | }, 16 | images: { 17 | domains: ["lh3.googleusercontent.com", "res.cloudinary.com"], 18 | }, 19 | 20 | }; 21 | 22 | if (process.env.NODE_ENV === "production") { 23 | module.exports = withPWA(nextConfig); 24 | } else { 25 | module.exports = nextConfig; 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threads-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@editorjs/checklist": "^1.5.0", 14 | "@editorjs/code": "^2.8.0", 15 | "@editorjs/editorjs": "^2.27.2", 16 | "@editorjs/embed": "^2.5.3", 17 | "@editorjs/header": "^2.7.0", 18 | "@editorjs/image": "^2.8.1", 19 | "@editorjs/inline-code": "^1.4.0", 20 | "@editorjs/link": "^2.5.0", 21 | "@editorjs/list": "^1.8.0", 22 | "@editorjs/table": "^2.2.2", 23 | "@next-auth/prisma-adapter": "^1.0.7", 24 | "@prisma/client": "^4.16.2", 25 | "@radix-ui/react-alert-dialog": "^1.0.4", 26 | "@radix-ui/react-avatar": "^1.0.3", 27 | "@radix-ui/react-dialog": "^1.0.4", 28 | "@radix-ui/react-dropdown-menu": "^2.0.5", 29 | "@radix-ui/react-icons": "^1.3.0", 30 | "@radix-ui/react-label": "^2.0.2", 31 | "@radix-ui/react-scroll-area": "^1.0.4", 32 | "@radix-ui/react-separator": "^1.0.3", 33 | "@radix-ui/react-slot": "^1.0.2", 34 | "@radix-ui/react-tabs": "^1.0.4", 35 | "@radix-ui/react-toast": "^1.1.4", 36 | "@tanstack/react-query": "^4.29.19", 37 | "@types/node": "20.4.0", 38 | "@types/react": "18.2.14", 39 | "@types/react-dom": "18.2.6", 40 | "@uploadthing/react": "^5.1.0", 41 | "antd": "^5.7.0", 42 | "autoprefixer": "10.4.14", 43 | "axios": "^1.4.0", 44 | "bad-words": "^3.0.4", 45 | "class-variance-authority": "^0.6.1", 46 | "clsx": "^1.2.1", 47 | "date-fns": "^2.30.0", 48 | "downloadjs": "^1.4.7", 49 | "html2canvas": "^1.4.1", 50 | "lucide-react": "^0.259.0", 51 | "next": "13.4.10", 52 | "next-auth": "^4.22.1", 53 | "next-pwa": "^5.6.0", 54 | "next-themes": "^0.2.1", 55 | "nextjs-toploader": "^1.4.2", 56 | "postcss": "8.4.25", 57 | "react": "18.2.0", 58 | "react-dom": "18.2.0", 59 | "react-dropzone": "^14.2.3", 60 | "react-hook-form": "^7.45.1", 61 | "react-iconly": "^2.2.10", 62 | "react-icons": "^4.10.1", 63 | "react-intersection-observer": "^9.5.2", 64 | "react-slick": "^0.29.0", 65 | "react-textarea-autosize": "^8.5.2", 66 | "server-only": "^0.0.1", 67 | "slick-carousel": "^1.8.1", 68 | "swiper": "^10.0.4", 69 | "tailwind-merge": "^1.13.2", 70 | "tailwindcss": "3.3.2", 71 | "tailwindcss-animate": "^1.0.6", 72 | "typescript": "5.1.6", 73 | "uploadthing": "^5.1.0" 74 | }, 75 | "devDependencies": { 76 | "@types/bad-words": "^3.0.1", 77 | "@types/downloadjs": "^1.4.3", 78 | "@types/editorjs__header": "^2.6.0", 79 | "@types/react-slick": "^0.23.10", 80 | "eslint": "8.44.0", 81 | "eslint-config-next": "13.4.10", 82 | "prisma": "^5.0.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230708190621_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Account" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "provider" TEXT NOT NULL, 7 | "providerAccountId" TEXT NOT NULL, 8 | "refresh_token" TEXT, 9 | "access_token" TEXT, 10 | "expires_at" INTEGER, 11 | "token_type" TEXT, 12 | "scope" TEXT, 13 | "id_token" TEXT, 14 | "session_state" TEXT, 15 | 16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Session" ( 21 | "id" TEXT NOT NULL, 22 | "sessionToken" TEXT NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | "expires" TIMESTAMP(3) NOT NULL, 25 | 26 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "User" ( 31 | "id" TEXT NOT NULL, 32 | "name" TEXT, 33 | "email" TEXT, 34 | "emailVerified" TIMESTAMP(3), 35 | "username" TEXT, 36 | "image" TEXT, 37 | 38 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 39 | ); 40 | 41 | -- CreateIndex 42 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 43 | 44 | -- CreateIndex 45 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 52 | 53 | -- AddForeignKey 54 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 55 | 56 | -- AddForeignKey 57 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 58 | -------------------------------------------------------------------------------- /prisma/migrations/20230711161931_update/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | ADD COLUMN "isEdited" BOOLEAN NOT NULL DEFAULT false, 7 | ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER', 8 | ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 9 | 10 | -- CreateTable 11 | CREATE TABLE "Thread" ( 12 | "id" TEXT NOT NULL, 13 | "title" TEXT NOT NULL, 14 | "content" JSONB, 15 | "authorId" TEXT NOT NULL, 16 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 | 19 | CONSTRAINT "Thread_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "Reply" ( 24 | "id" TEXT NOT NULL, 25 | "content" JSONB, 26 | "authorId" TEXT NOT NULL, 27 | "threadId" TEXT NOT NULL, 28 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | "updatedAt" TIMESTAMP(3) NOT NULL, 30 | 31 | CONSTRAINT "Reply_pkey" PRIMARY KEY ("id") 32 | ); 33 | 34 | -- AddForeignKey 35 | ALTER TABLE "Thread" ADD CONSTRAINT "Thread_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 36 | 37 | -- AddForeignKey 38 | ALTER TABLE "Reply" ADD CONSTRAINT "Reply_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 39 | 40 | -- AddForeignKey 41 | ALTER TABLE "Reply" ADD CONSTRAINT "Reply_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "Thread"("id") ON DELETE CASCADE ON UPDATE CASCADE; 42 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | binaryTargets = ["native", "debian-openssl-3.0.x"] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_URL") 9 | directUrl = env("DIRECT_URL") 10 | } 11 | 12 | model Account { 13 | id String @id @default(cuid()) 14 | userId String 15 | type String 16 | provider String 17 | providerAccountId String 18 | refresh_token String? @db.Text 19 | access_token String? @db.Text 20 | expires_at Int? 21 | token_type String? 22 | scope String? 23 | id_token String? @db.Text 24 | session_state String? 25 | 26 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 27 | 28 | @@unique([provider, providerAccountId]) 29 | } 30 | 31 | model Session { 32 | id String @id @default(cuid()) 33 | sessionToken String @unique 34 | userId String 35 | expires DateTime 36 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 37 | } 38 | 39 | model User { 40 | id String @id @default(cuid()) 41 | name String 42 | email String @unique 43 | emailVerified DateTime? 44 | bio String @default("") 45 | username String @unique @default(cuid()) 46 | isEdited Boolean @default(false) 47 | onboarded Boolean @default(false) 48 | isPrivate Boolean @default(false) 49 | image String 50 | accounts Account[] 51 | sessions Session[] 52 | threads Thread[] 53 | 54 | followedBy User[] @relation("UserFollows") 55 | following User[] @relation("UserFollows") 56 | 57 | role Role @default(USER) 58 | createdAt DateTime @default(now()) 59 | updatedAt DateTime @default(now()) @updatedAt 60 | likes Likes[] 61 | repost Repost[] 62 | // Define the relation to sent notifications 63 | sentNotifications Notification[] @relation("SentNotifications") 64 | // Define the relation to received notifications 65 | receivedNotifications Notification[] @relation("ReceivedNotifications") 66 | } 67 | 68 | model Thread { 69 | id String @id @default(cuid()) 70 | content Json 71 | authorId String 72 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade) 73 | createdAt DateTime @default(now()) 74 | parentId String? 75 | parent Thread? @relation("ParentChildren", fields: [parentId], references: [id]) 76 | children Thread[] @relation("ParentChildren") 77 | updatedAt DateTime @default(now()) @updatedAt 78 | likes Likes[] 79 | repost Repost? 80 | Notifications Notification[] 81 | } 82 | 83 | model Likes { 84 | thread Thread @relation(fields: [threadId], references: [id]) 85 | threadId String 86 | user User @relation(fields: [userId], references: [id]) 87 | userId String 88 | createdAt DateTime @default(now()) 89 | 90 | @@id([threadId, userId]) 91 | } 92 | 93 | model Repost { 94 | id String @id @default(cuid()) 95 | threadId String @unique 96 | thread Thread @relation(fields: [threadId], references: [id]) 97 | reposter User @relation(fields: [reposterId], references: [id]) 98 | reposterId String 99 | } 100 | 101 | model Notification { 102 | id String @id @default(cuid()) 103 | senderId String 104 | receiverId String 105 | sender User @relation("SentNotifications", fields: [senderId], references: [id]) 106 | receiver User @relation("ReceivedNotifications", fields: [receiverId], references: [id]) 107 | read Boolean @default(false) 108 | type NotificationType 109 | createdAt DateTime @default(now()) 110 | updatedAt DateTime @default(now()) @updatedAt 111 | userId String? 112 | thread Thread? @relation(fields: [threadId], references: [id]) 113 | threadId String? 114 | } 115 | 116 | enum NotificationType { 117 | LIKE 118 | REPOST 119 | FOLLOW 120 | NEWPOST 121 | REPLY 122 | } 123 | 124 | enum Role { 125 | USER 126 | ADMIN 127 | } 128 | -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/public/icon-256x256.png -------------------------------------------------------------------------------- /public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/public/icon-384x384.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/public/icon-512x512.png -------------------------------------------------------------------------------- /public/logo/threads-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/logo/threads.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#101010", 3 | "background_color": "#101010", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "Threads", 8 | "short_name": "Threads", 9 | "description": "Meta's Thread clone", 10 | "icons": [ 11 | { 12 | "src": "/icon-192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/icon-256x256.png", 18 | "sizes": "256x256", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/icon-384x384.png", 23 | "sizes": "384x384", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/icon-512x512.png", 28 | "sizes": "512x512", 29 | "type": "image/png" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/[username]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "@/components/logo/Logo"; 2 | import Profile from "@/components/profile/Profile"; 3 | import SignOut from "@/components/profile/SignOut"; 4 | import { Badge } from "@/components/ui/badge"; 5 | import { buttonVariants } from "@/components/ui/button"; 6 | import { getAuthSession } from "@/lib/auth"; 7 | import { db } from "@/lib/db"; 8 | import { Instagram, Settings } from "lucide-react"; 9 | import { redirect } from "next/navigation"; 10 | import { FC } from "react"; 11 | import { followUser, unfollowUser } from "@/lib/actions"; 12 | import SorryPageNotFound from "@/components/miscellaneous/SorryPageNotFound"; 13 | import { Metadata } from "next"; 14 | import { metaTagsGenerator } from "@/lib/utils"; 15 | import Link from "next/link"; 16 | 17 | export async function generateMetadata({ 18 | params: { username }, 19 | }: { 20 | params: { username: string }; 21 | }): Promise { 22 | const user = await db.user.findUnique({ 23 | where: { 24 | username: username, 25 | }, 26 | }); 27 | 28 | return metaTagsGenerator({ 29 | title: `${user?.name} (@${user?.username}) on Threads`, 30 | description: user?.bio, 31 | img: user?.image, 32 | url: `/${username}`, 33 | }); 34 | } 35 | 36 | interface layoutProps { 37 | params: { 38 | username: string; 39 | }; 40 | children: React.ReactNode; 41 | } 42 | 43 | const layout: FC = async ({ params, children }) => { 44 | const { username } = params; 45 | 46 | const user = await db.user.findUnique({ 47 | where: { 48 | username: username, 49 | }, 50 | include: { 51 | followedBy: true, 52 | }, 53 | }); 54 | 55 | const session = await getAuthSession(); 56 | 57 | if (!session) return redirect("/"); 58 | 59 | const getSelf = await db.user.findUnique({ 60 | where: { 61 | id: session?.user?.id, 62 | }, 63 | }); 64 | 65 | if (getSelf?.onboarded === false) return redirect("/onboarding"); 66 | 67 | if (!user) { 68 | return ; 69 | } 70 | 71 | const self = getSelf?.username === username; 72 | 73 | const isFollowing = self 74 | ? false 75 | : user.followedBy.some((follow) => follow.id == getSelf?.id); 76 | 77 | const allUsernames = await db.user.findMany({ 78 | select: { 79 | username: true, 80 | }, 81 | }); 82 | return ( 83 | <> 84 |
85 |
86 |
87 | 91 | 92 | 93 |
94 | 101 | 102 | 103 | 104 |
105 | user.username)} 107 | followUser={followUser} 108 | unfollowUser={unfollowUser} 109 | user={user} 110 | getSelf={getSelf} 111 | isFollowing={isFollowing} 112 | self={self} 113 | /> 114 |
115 | {children} 116 | 117 | ); 118 | }; 119 | 120 | export default layout; 121 | -------------------------------------------------------------------------------- /src/app/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import Link from "next/link"; 3 | import { getAuthSession } from "@/lib/auth"; 4 | import { db } from "@/lib/db"; 5 | import ThreadComponent from "@/components/thread/ThreadComponent"; 6 | interface pageProps { 7 | params: { 8 | username: string; 9 | }; 10 | } 11 | 12 | const page: FC = async ({ params }) => { 13 | const session = await getAuthSession(); 14 | if (!session) return null; 15 | 16 | const user = await db.user.findUnique({ 17 | where: { 18 | username: params.username, 19 | }, 20 | }); 21 | 22 | const threads = await db.thread.findMany({ 23 | where: { 24 | authorId: user?.id, 25 | parent: null, 26 | }, 27 | orderBy: { 28 | createdAt: "desc", 29 | }, 30 | include: { 31 | author: true, 32 | children: { 33 | include: { 34 | author: true, 35 | }, 36 | }, 37 | parent: true, 38 | likes: true, 39 | }, 40 | }); 41 | 42 | return ( 43 | <> 44 |
45 | 48 | 52 | Replies 53 | 54 |
55 | {threads.length === 0 ? ( 56 |
57 | No threads posted yet. 58 |
59 | ) : ( 60 | threads.map((thread) => ) 61 | )} 62 | 63 | ); 64 | }; 65 | 66 | export default page; 67 | -------------------------------------------------------------------------------- /src/app/[username]/replies/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Button } from "@/components/ui/button"; 3 | import { ArrowUp } from "lucide-react"; 4 | import Image from "next/image"; 5 | import { db } from "@/lib/db"; 6 | import { getAuthSession } from "@/lib/auth"; 7 | import ThreadComponent from "@/components/thread/ThreadComponent"; 8 | 9 | export default async function RepliesPage({ 10 | params, 11 | }: { 12 | params: { username: string }; 13 | }) { 14 | const session = await getAuthSession(); 15 | 16 | const user = session?.user; 17 | if (!user) return null; 18 | 19 | const getUser = await db.user.findUnique({ 20 | where: { 21 | username: params.username, 22 | }, 23 | }); 24 | 25 | const posts = await db.thread.findMany({ 26 | // where parent is not null 27 | where: { 28 | authorId: getUser?.id, 29 | NOT: { 30 | parent: null, 31 | }, 32 | }, 33 | include: { 34 | author: true, 35 | children: { 36 | include: { 37 | author: true, 38 | }, 39 | }, 40 | parent: { 41 | include: { 42 | author: true, 43 | children: { 44 | include: { 45 | author: true, 46 | }, 47 | }, 48 | parent: { 49 | include: { 50 | author: true, 51 | }, 52 | }, 53 | likes: true, 54 | }, 55 | }, 56 | likes: true, 57 | }, 58 | }); 59 | 60 | return ( 61 | <> 62 |
63 | 67 | Threads 68 | 69 | 72 |
73 | {posts.length === 0 ? ( 74 |
75 | No replies posted yet. 76 |
77 | ) : ( 78 | posts.map((post) => ( 79 | <> 80 | {post.parent && post.parent.parent ? ( 81 | 82 | 98 | 99 | ) : null} 100 | {post.parent ? ( 101 | 102 | ) : null} 103 | 104 | 105 | )) 106 | )} 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.tsx: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/lib/auth"; 2 | import NextAuth from "next-auth/next"; 3 | 4 | const handler = NextAuth(authOptions); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /src/app/api/loadmore/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export async function GET(req: Request) { 4 | try { 5 | const url = new URL(req.url); 6 | const cursor = url.searchParams.get("cursor"); 7 | 8 | if (!cursor) { 9 | return new Response(JSON.stringify({ error: "No cursor provided" }), { 10 | status: 403, 11 | }); 12 | } 13 | 14 | const threads = await db.thread.findMany({ 15 | take: 10, 16 | skip: 1, 17 | cursor: { 18 | id: cursor, 19 | }, 20 | orderBy: { 21 | createdAt: "desc", 22 | }, 23 | include: { 24 | author: true, 25 | children: { 26 | include: { 27 | author: true, 28 | }, 29 | }, 30 | parent: true, 31 | likes: true, 32 | }, 33 | where: { 34 | parent: null, 35 | }, 36 | }); 37 | 38 | if (threads.length == 0) { 39 | return new Response( 40 | JSON.stringify({ 41 | data: [], 42 | }), 43 | { status: 200 } 44 | ); 45 | } 46 | 47 | const data = { 48 | data: threads, 49 | }; 50 | 51 | return new Response(JSON.stringify(data), { status: 200 }); 52 | } catch (error: any) { 53 | return new Response( 54 | JSON.stringify(JSON.stringify({ error: error.message })), 55 | { status: 403 } 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/api/thread/create/route.ts: -------------------------------------------------------------------------------- 1 | import { getAuthSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { z } from "zod"; 4 | import { ThreadValidator } from "@/lib/validators/threadSubmit"; 5 | 6 | export async function POST(req: Request) { 7 | try { 8 | const body = await req.json(); 9 | const { content } = ThreadValidator.parse(body); 10 | const session = await getAuthSession(); 11 | if (!session?.user) { 12 | return new Response("Unauthorized", { status: 401 }); 13 | } 14 | 15 | const thread = await db.thread.create({ 16 | data: { 17 | content, 18 | authorId: session.user.id, 19 | }, 20 | }); 21 | 22 | return new Response(JSON.stringify(thread), { 23 | headers: { "content-type": "application/json" }, 24 | status: 201, 25 | }); 26 | } catch (error) { 27 | if (error instanceof z.ZodError) { 28 | return new Response("Invalid Thread Request", { status: 422 }); 29 | } 30 | console.log(error); 31 | return new Response("Could not post", { status: 500 }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/api/thread/reply/route.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/src/app/api/thread/reply/route.ts -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 2 | 3 | const f = createUploadthing(); 4 | 5 | const auth = (req: Request) => ({ id: "fakeId" }); // Fake auth function 6 | 7 | // FileRouter for your app, can contain multiple FileRoutes 8 | export const ourFileRouter = { 9 | // Define as many FileRoutes as you like, each with a unique routeSlug 10 | imageUploader: f({ image: { maxFileSize: "4MB" } }) 11 | // Set permissions and file types for this FileRoute 12 | .middleware(async ({ req }) => { 13 | // This code runs on your server before upload 14 | const user = await auth(req); 15 | 16 | // If you throw, the user will not be able to upload 17 | if (!user) throw new Error("Unauthorized"); 18 | 19 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 20 | return { userId: user.id }; 21 | }) 22 | .onUploadComplete(async ({ metadata, file }) => { 23 | // This code RUNS ON YOUR SERVER after upload 24 | console.log("Upload complete for userId:", metadata.userId); 25 | 26 | console.log("file url", file.url); 27 | }), 28 | } satisfies FileRouter; 29 | 30 | export type OurFileRouter = typeof ourFileRouter; -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createNextRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createNextRouteHandler({ 7 | router: ourFileRouter, 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/create/comment/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import SorryPageNotFound from "@/components/miscellaneous/SorryPageNotFound"; 2 | import BackButton from "@/components/thread/BackButton"; 3 | import { CreateComment } from "@/components/thread/comment/CreateComponent"; 4 | import { db } from "@/lib/db"; 5 | import { FC } from "react"; 6 | 7 | interface pageProps { 8 | params: { 9 | id: string; 10 | }; 11 | } 12 | 13 | const ThreadComment: FC = async ({ params }) => { 14 | const { id } = params; 15 | 16 | const thread = await db.thread.findUnique({ 17 | where: { 18 | id: id, 19 | }, 20 | include: { 21 | parent: true, 22 | children: { 23 | include: { 24 | author: true, 25 | }, 26 | }, 27 | likes: true, 28 | author: true, 29 | }, 30 | }); 31 | 32 | if (!thread) return ; 33 | 34 | return ( 35 |
36 | 37 | 38 |
39 | ); 40 | }; 41 | 42 | export default ThreadComment; 43 | -------------------------------------------------------------------------------- /src/app/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { X } from "lucide-react"; 2 | import { FC } from "react"; 3 | import { Separator } from "@/components/ui/separator"; 4 | import { getAuthSession } from "@/lib/auth"; 5 | import ThreadCreate from "@/components/ThreadCreate"; 6 | import { Button } from "@/components/ui/button"; 7 | import SubmitThread from "@/components/SubmitThread"; 8 | import { db } from "@/lib/db"; 9 | import { redirect } from "next/navigation"; 10 | interface pageProps {} 11 | 12 | const page: FC = async ({}) => { 13 | const session = await getAuthSession(); 14 | const user = await db.user.findUnique({ 15 | where: { 16 | id: session?.user?.id, 17 | }, 18 | }); 19 | 20 | if (user?.onboarded === false) return redirect("/onboarding"); 21 | 22 | if (!session?.user) return redirect("/sign-in"); 23 | 24 | return ( 25 |
26 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | export default page; 33 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 20 14.3% 4.1%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 20 14.3% 4.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 20 14.3% 4.1%; 13 | --primary: 24 9.8% 10%; 14 | --primary-foreground: 60 9.1% 97.8%; 15 | --secondary: 60 4.8% 95.9%; 16 | --secondary-foreground: 24 9.8% 10%; 17 | --muted: 60 4.8% 95.9%; 18 | --muted-foreground: 25 5.3% 44.7%; 19 | --accent: 60 4.8% 95.9%; 20 | --accent-foreground: 24 9.8% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 60 9.1% 97.8%; 23 | --border: 20 5.9% 90%; 24 | --input: 20 5.9% 90%; 25 | --ring: 20 14.3% 4.1%; 26 | --radius: 0.75rem; 27 | } 28 | 29 | .dark { 30 | --background: 20 14.3% 4.1%; 31 | --foreground: 60 9.1% 97.8%; 32 | --card: 20 14.3% 4.1%; 33 | --card-foreground: 60 9.1% 97.8%; 34 | --popover: 20 14.3% 4.1%; 35 | --popover-foreground: 60 9.1% 97.8%; 36 | --primary: 60 9.1% 97.8%; 37 | --primary-foreground: 24 9.8% 10%; 38 | --secondary: 12 6.5% 15.1%; 39 | --secondary-foreground: 60 9.1% 97.8%; 40 | --muted: 12 6.5% 15.1%; 41 | --muted-foreground: 24 5.4% 63.9%; 42 | --accent: 12 6.5% 15.1%; 43 | --accent-foreground: 60 9.1% 97.8%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 60 9.1% 97.8%; 46 | --border: 12 6.5% 15.1%; 47 | --input: 12 6.5% 15.1%; 48 | --ring: 24 5.7% 82.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | 61 | .gradient { 62 | background: radial-gradient( 63 | circle farthest-corner at 35% 90%, 64 | #eba93b, 65 | transparent 50% 66 | ), 67 | radial-gradient(circle farthest-corner at 0 140%, #eba93b, transparent 50%), 68 | radial-gradient(ellipse farthest-corner at 0 -25%, #373ecc, transparent 50%), 69 | radial-gradient( 70 | ellipse farthest-corner at 20% -50%, 71 | #373ecc, 72 | transparent 50% 73 | ), 74 | radial-gradient(ellipse farthest-corner at 100% 0, #7c26bd, transparent 50%), 75 | radial-gradient( 76 | ellipse farthest-corner at 60% -20%, 77 | #7c26bd, 78 | transparent 50% 79 | ), 80 | radial-gradient(ellipse farthest-corner at 100% 100%, #de1d71, transparent), 81 | linear-gradient( 82 | #5244c7, 83 | #c72893 30%, 84 | #de2c4f 50%, 85 | #f06826 70%, 86 | #f2b657 100% 87 | ); 88 | 89 | background-size: 200% 200%; 90 | background-clip: text; 91 | color: transparent; 92 | -webkit-background-clip: text; 93 | -webkit-text-fill-color: transparent; 94 | animation: gradient 15s ease infinite; 95 | } 96 | 97 | @keyframes gradient { 98 | 0% { 99 | background-position: 0% 50%; 100 | } 101 | 50% { 102 | background-position: 100% 50%; 103 | } 104 | 100% { 105 | background-position: 0% 50%; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn, metaTagsGenerator } from "@/lib/utils"; 2 | import "./globals.css"; 3 | import "./styles/editor.css"; 4 | import type { Metadata } from "next"; 5 | import { Inter } from "next/font/google"; 6 | import Navbar from "@/components/common/Navbar"; 7 | import { Toaster } from "@/components/ui/toaster"; 8 | import { getAuthSession } from "@/lib/auth"; 9 | import Providers from "@/components/common/Providers"; 10 | import "slick-carousel/slick/slick.css"; 11 | import "slick-carousel/slick/slick-theme.css"; 12 | import { db } from "@/lib/db"; 13 | const inter = Inter({ subsets: ["latin"] }); 14 | 15 | export const metadata = metaTagsGenerator({}); 16 | 17 | export default async function RootLayout({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }) { 22 | const session = await getAuthSession(); 23 | const username = session?.user?.username; 24 | 25 | const isUser = !!session?.user; 26 | 27 | const notifcations = await db.notification.findMany({ 28 | where: { 29 | receiverId: session?.user?.id, 30 | read: false, 31 | }, 32 | }); 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | {isUser && ( 46 | 50 | )} 51 | 52 |
53 | {children} 54 |
55 |
56 |
57 |
58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/notifications/layout.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsNav from "@/components/notifications/NotificationsNav"; 2 | import { buttonVariants } from "@/components/ui/button"; 3 | 4 | import React from "react"; 5 | 6 | function NotificationsLayout({ children }: { children: React.ReactNode }) { 7 | 8 | return ( 9 | <> 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | 16 | export default NotificationsLayout; 17 | -------------------------------------------------------------------------------- /src/app/notifications/likes/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAuthSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { FC } from "react"; 4 | import { redirect } from "next/navigation"; 5 | import Notification from "@/components/notifications/Notification"; 6 | interface pageProps {} 7 | 8 | const page: FC = async ({}) => { 9 | const session = await getAuthSession(); 10 | const user = await db.user.findUnique({ 11 | where: { 12 | id: session?.user?.id, 13 | }, 14 | include: { 15 | following: true, 16 | }, 17 | }); 18 | 19 | const notifications = await db.notification.findMany({ 20 | where: { 21 | receiverId: user?.id, 22 | type: "LIKE", 23 | }, 24 | orderBy: { 25 | createdAt: "desc", 26 | }, 27 | include: { 28 | sender: true, 29 | 30 | thread: { 31 | include: { 32 | author: true, 33 | }, 34 | }, 35 | }, 36 | }); 37 | 38 | if (user?.onboarded === false) return redirect("/onboarding"); 39 | if (!user) return redirect("/"); 40 | 41 | return ( 42 |
43 | {notifications.length > 0 44 | ? notifications.map((notification) => ( 45 | 46 | 47 | )) 48 | : "No notifications"} 49 |
50 | ); 51 | }; 52 | 53 | export default page; 54 | -------------------------------------------------------------------------------- /src/app/notifications/mentions/page.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface pageProps {} 4 | 5 | const page: FC = ({}) => { 6 | return ( 7 |
8 |

Under development

9 |
10 | ); 11 | }; 12 | 13 | export default page; 14 | -------------------------------------------------------------------------------- /src/app/notifications/page.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "@/components/logo/Logo"; 2 | import AllNotifications from "@/components/notifications/AllNotifications"; 3 | import Notification from "@/components/notifications/Notification"; 4 | import { getAuthSession } from "@/lib/auth"; 5 | import { db } from "@/lib/db"; 6 | import { redirect } from "next/navigation"; 7 | import { FC } from "react"; 8 | 9 | interface pageProps {} 10 | 11 | const page: FC = async ({}) => { 12 | const session = await getAuthSession(); 13 | const user = await db.user.findUnique({ 14 | where: { 15 | id: session?.user?.id, 16 | }, 17 | include: { 18 | following: true, 19 | }, 20 | }); 21 | 22 | const notifications = await db.notification.findMany({ 23 | where: { 24 | receiverId: user?.id, 25 | }, 26 | orderBy: { 27 | createdAt: "desc", 28 | }, 29 | include: { 30 | sender: true, 31 | 32 | thread: { 33 | include: { 34 | author: true, 35 | }, 36 | }, 37 | }, 38 | }); 39 | 40 | if (user?.onboarded === false) return redirect("/onboarding"); 41 | if (!user) return redirect("/"); 42 | 43 | return ( 44 |
45 | {notifications.length > 0 46 | ? notifications.map((notification) => ( 47 | 48 | 49 | )) 50 | : "No notifications"} 51 |
52 | ); 53 | }; 54 | 55 | export default page; 56 | -------------------------------------------------------------------------------- /src/app/notifications/replies/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAuthSession } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { FC } from "react"; 4 | import { redirect } from "next/navigation"; 5 | import Notification from "@/components/notifications/Notification"; 6 | interface pageProps {} 7 | 8 | const page: FC = async ({}) => { 9 | const session = await getAuthSession(); 10 | const user = await db.user.findUnique({ 11 | where: { 12 | id: session?.user?.id, 13 | }, 14 | include: { 15 | following: true, 16 | }, 17 | }); 18 | 19 | const notifications = await db.notification.findMany({ 20 | where: { 21 | receiverId: user?.id, 22 | type: "REPLY", 23 | }, 24 | orderBy: { 25 | createdAt: "desc", 26 | }, 27 | include: { 28 | sender: true, 29 | 30 | thread: { 31 | include: { 32 | author: true, 33 | }, 34 | }, 35 | }, 36 | }); 37 | 38 | if (user?.onboarded === false) return redirect("/onboarding"); 39 | if (!user) return redirect("/"); 40 | 41 | return ( 42 |
43 | {notifications.length > 0 44 | ? notifications.map((notification) => ( 45 | 46 | )) 47 | : "No notifications"} 48 |
49 | ); 50 | }; 51 | 52 | export default page; 53 | -------------------------------------------------------------------------------- /src/app/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | import Screens from "@/components/onboarding/Screens"; 2 | import { getAuthSession } from "@/lib/auth"; 3 | import { db } from "@/lib/db"; 4 | import { redirect } from "next/navigation"; 5 | import { FC } from "react"; 6 | 7 | const OnboardingPage = async ({}) => { 8 | const session = await getAuthSession(); 9 | 10 | const getUser = await db.user.findUnique({ 11 | where: { 12 | id: session?.user?.id, 13 | }, 14 | }); 15 | 16 | if (!getUser) redirect("/"); 17 | 18 | const userData = { 19 | id: getUser.id, 20 | onboarded: true, 21 | bio: getUser.bio, 22 | username: getUser?.username, 23 | name: getUser.name, 24 | image: getUser.image, 25 | email: getUser.email, 26 | }; 27 | 28 | const allUsernames = await db.user.findMany({ 29 | select: { 30 | username: true, 31 | }, 32 | }); 33 | 34 | return ( 35 |
36 | {session && } 37 |
38 | ); 39 | }; 40 | 41 | export default OnboardingPage; 42 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "@/components/logo/Logo"; 2 | import HomeThreads from "@/components/thread/HomeThreads"; 3 | import { getAuthSession } from "@/lib/auth"; 4 | import { db } from "@/lib/db"; 5 | import { redirect } from "next/navigation"; 6 | import React from "react"; 7 | 8 | async function HomePage() { 9 | const session = await getAuthSession(); 10 | 11 | if (!session) return redirect("/sign-in"); 12 | 13 | const user = await db.user.findUnique({ 14 | where: { 15 | id: session?.user?.id, 16 | }, 17 | }); 18 | 19 | if (user?.onboarded === false) return redirect("/onboarding"); 20 | 21 | const threads = await db.thread.findMany({ 22 | take: 50, 23 | orderBy: { 24 | createdAt: "desc", 25 | }, 26 | include: { 27 | author: true, 28 | children: { 29 | include: { 30 | author: true, 31 | }, 32 | }, 33 | parent: true, 34 | likes: true, 35 | }, 36 | where: { 37 | parent: null, 38 | }, 39 | }); 40 | 41 | return ( 42 | <> 43 |
44 | 45 |
46 | 47 | 48 | ); 49 | } 50 | 51 | export default HomePage; 52 | -------------------------------------------------------------------------------- /src/app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { Bar } from "@/components/search/Bar"; 2 | import { SearchUser } from "@/components/search/SearchUser"; 3 | import { getAuthSession } from "@/lib/auth"; 4 | import { db } from "@/lib/db"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export const revalidate = 0; 8 | 9 | export default async function SearchPage({ 10 | searchParams, 11 | }: { 12 | searchParams: { [key: string]: string | string[] | undefined }; 13 | }) { 14 | const session = await getAuthSession(); 15 | const user = session?.user; 16 | 17 | if (!user) { 18 | redirect("/sign-in"); 19 | } 20 | 21 | const getUser = await db.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 db.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 db.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 |
81 |
Search
82 | 83 | 84 |
85 | {users.length === 0 ? ( 86 |
87 | No results 88 |
89 | ) : ( 90 | <> 91 | {users.map((user) => { 92 | const isFollowing = user.followedBy.some( 93 | (follow) => follow.id === getUser.id 94 | ); 95 | return ( 96 | 102 | ); 103 | })} 104 | 105 | )} 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/app/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface layoutProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | const layout: FC = ({ children }) => { 8 | return ( 9 |
10 |

Settings

11 |
{children}
12 |
13 | ); 14 | }; 15 | 16 | export default layout; 17 | -------------------------------------------------------------------------------- /src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import ThemeChange from "@/components/settings/ThemeChange"; 2 | import React from "react"; 3 | 4 | function SettingsPage() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default SettingsPage; 13 | -------------------------------------------------------------------------------- /src/app/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import AuthenticationForm from "@/components/AuthenticationForm"; 2 | import React from "react"; 3 | 4 | function page() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | 14 | export default page; 15 | -------------------------------------------------------------------------------- /src/app/styles/editor.css: -------------------------------------------------------------------------------- 1 | .dark .ce-block--selected .ce-block__content, 2 | .dark .ce-inline-toolbar, 3 | .dark .codex-editor--narrow .ce-toolbox, 4 | .dark .ce-conversion-toolbar, 5 | .dark .ce-settings, 6 | .dark .ce-settings__button, 7 | .dark .ce-toolbar__settings-btn, 8 | .dark .cdx-button, 9 | .dark .ce-popover, 10 | .dark .ce-toolbar__plus:hover { 11 | background: theme("colors.popover.DEFAULT"); 12 | color: inherit; 13 | border-color: theme("colors.border"); 14 | } 15 | 16 | .dark .ce-inline-tool, 17 | .dark .ce-conversion-toolbar__label, 18 | .dark .ce-toolbox__button, 19 | .dark .cdx-settings-button, 20 | .dark .ce-toolbar__plus { 21 | color: inherit; 22 | } 23 | 24 | .dark .ce-popover__item-icon, 25 | .dark .ce-conversion-tool__icon { 26 | background-color: theme("colors.muted.DEFAULT"); 27 | box-shadow: none; 28 | } 29 | 30 | .dark .cdx-search-field { 31 | border-color: theme("colors.border"); 32 | background: theme("colors.input"); 33 | color: inherit; 34 | } 35 | 36 | .dark ::selection { 37 | background: theme("colors.accent.DEFAULT"); 38 | } 39 | 40 | .dark .cdx-settings-button:hover, 41 | .dark .ce-settings__button:hover, 42 | .dark .ce-toolbox__button--active, 43 | .dark .ce-toolbox__button:hover, 44 | .dark .cdx-button:hover, 45 | .dark .ce-inline-toolbar__dropdown:hover, 46 | .dark .ce-inline-tool:hover, 47 | .dark .ce-popover__item:hover, 48 | .dark .ce-conversion-tool:hover, 49 | .dark .ce-toolbar__settings-btn:hover { 50 | background-color: theme("colors.accent.DEFAULT"); 51 | color: theme("colors.accent.foreground"); 52 | } 53 | 54 | .dark .cdx-notify--error { 55 | background: theme("colors.destructive.DEFAULT") !important; 56 | } 57 | 58 | .dark .cdx-notify__cross::after, 59 | .dark .cdx-notify__cross::before { 60 | background: white; 61 | } 62 | 63 | .codex-editor__redactor { 64 | padding-bottom: 0 !important; 65 | } 66 | 67 | .ce-toolbar__content { 68 | min-width: 100%; 69 | } 70 | -------------------------------------------------------------------------------- /src/app/thread/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import MainThread from "@/components/thread/MainThread"; 2 | import ThreadComponent from "@/components/thread/ThreadComponent"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | import { db } from "@/lib/db"; 6 | 7 | import { ArrowUp } from "lucide-react"; 8 | import Image from "next/image"; 9 | import Link from "next/link"; 10 | 11 | export const revalidate = 0; 12 | 13 | import { Metadata } from "next"; 14 | import { metaTagsGenerator } from "@/lib/utils"; 15 | import { getAuthSession } from "@/lib/auth"; 16 | 17 | export async function generateMetadata({ 18 | params: { id }, 19 | }: { 20 | params: { id: string }; 21 | }): Promise { 22 | const thread = await db.thread.findUnique({ 23 | where: { 24 | id, 25 | }, 26 | include: { 27 | author: true, 28 | }, 29 | }); 30 | 31 | return metaTagsGenerator({ 32 | title: `Checkout this thread by ${thread?.author.name}`, 33 | url: `/thread/${id}`, 34 | }); 35 | } 36 | 37 | export default async function ThreadDetailedPage({ 38 | params, 39 | }: { 40 | params: { id: string }; 41 | }) { 42 | const { id } = params; 43 | const session = await getAuthSession(); 44 | 45 | const thread = await db.thread.findUnique({ 46 | where: { 47 | id, 48 | }, 49 | include: { 50 | author: true, 51 | children: { 52 | include: { 53 | author: true, 54 | children: { 55 | include: { 56 | author: true, 57 | }, 58 | }, 59 | parent: true, 60 | likes: true, 61 | }, 62 | }, 63 | parent: { 64 | include: { 65 | author: true, 66 | children: { 67 | include: { 68 | author: true, 69 | }, 70 | }, 71 | parent: { 72 | include: { 73 | author: true, 74 | }, 75 | }, 76 | likes: true, 77 | }, 78 | }, 79 | likes: true, 80 | }, 81 | }); 82 | 83 | const user = await db.user.findUnique({ 84 | where: { 85 | id: session?.user?.id, 86 | }, 87 | }); 88 | 89 | if (!thread) { 90 | return ( 91 |
thread not found.
92 | ); 93 | } 94 | 95 | return ( 96 | <> 97 | {thread.parent && thread.parent.parent ? ( 98 | 99 | 115 | 116 | ) : null} 117 | {thread.parent ? ( 118 | 124 | ) : null} 125 | 126 | {thread.children.map((child) => ( 127 | 128 | ))} 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/app/thread/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | 3 | import { redirect } from "next/navigation"; 4 | import { getAuthSession } from "@/lib/auth"; 5 | import BackButton from "@/components/thread/BackButton"; 6 | import { db } from "@/lib/db"; 7 | 8 | 9 | 10 | export default async function ThreadPageLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | const session = await getAuthSession(); 16 | 17 | const user = session?.user; 18 | 19 | if (!user) { 20 | redirect("/sign-in"); 21 | } 22 | 23 | const getUser = await db.user.findUnique({ 24 | where: { 25 | id: user?.id, 26 | }, 27 | }); 28 | 29 | if (!getUser?.onboarded) { 30 | redirect("/onboarding"); 31 | } 32 | 33 | return ( 34 | <> 35 |
36 | 37 |
38 | Thread 39 |
40 |
41 | 42 | {children} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/assets/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/loop-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/loop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/verified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawan67/threads-clone/39ba2a0564f5e1b56ea9361f52c211af735cb498/src/assets/verified.png -------------------------------------------------------------------------------- /src/assets/verified.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AuthenticationForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { Button } from "./ui/button"; 4 | import googleIcon from "@/assets/google.svg"; 5 | import Image from "next/image"; 6 | // import { FcGoogle } from "react-icons/fc"; 7 | import { useToast } from "@/lib/use-toast"; 8 | import { signIn } from "next-auth/react"; 9 | import Logo from "./logo/Logo"; 10 | import { 11 | AlertDialog, 12 | AlertDialogAction, 13 | AlertDialogCancel, 14 | AlertDialogContent, 15 | AlertDialogDescription, 16 | AlertDialogFooter, 17 | AlertDialogHeader, 18 | AlertDialogTitle, 19 | AlertDialogTrigger, 20 | } from "@/components/ui/alert-dialog"; 21 | import { Loader, Loader2 } from "lucide-react"; 22 | 23 | function AuthenticationForm() { 24 | const [isLoading, setIsLoading] = useState(false); 25 | const { toast } = useToast(); 26 | const loginWithGoogle = async () => { 27 | setIsLoading(true); 28 | 29 | try { 30 | await signIn("google"); 31 | } catch (error) { 32 | // toast notification 33 | toast({ 34 | title: "There was an problem.", 35 | description: "There was an eror logging in with Google.", 36 | variant: "destructive", 37 | }); 38 | } finally { 39 | setIsLoading(false); 40 | } 41 | }; 42 | 43 | return ( 44 |
45 | 46 |

Threads

47 | 48 | 49 |

50 | By continuing, you agree to our{" "} 51 | 52 | User Agreement and Privacy Policy. 53 | 54 |

55 |
56 | 57 | 58 | Just Kidding 59 | 60 | I will not hack you, I promise. 61 | 62 | 63 | 64 | Ok 65 | 66 | 67 |
68 | 69 | 90 |
91 | ); 92 | } 93 | 94 | export default AuthenticationForm; 95 | -------------------------------------------------------------------------------- /src/components/SubmitThread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Separator } from "@radix-ui/react-separator"; 3 | import React from "react"; 4 | import { Button } from "./ui/button"; 5 | 6 | function SubmitThread() { 7 | return null 8 | return ( 9 |
10 | 11 |
12 |
Anyone can reply
13 | 21 |
22 |
23 | ); 24 | } 25 | 26 | export default SubmitThread; 27 | -------------------------------------------------------------------------------- /src/components/ThreadCreate.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FC, useCallback, useEffect, useRef } from "react"; 3 | import { Avatar, AvatarImage } from "./ui/avatar"; 4 | import { Separator } from "./ui/separator"; 5 | import type EditorJS from "@editorjs/editorjs"; 6 | import TextareaAutosize from "react-textarea-autosize"; 7 | // import { Image } from "lucide-react"; 8 | import { Button } from "./ui/button"; 9 | import { Carousel, Image } from "antd"; 10 | import axios from "axios"; 11 | import { ImAttachment } from "react-icons/im"; 12 | import { setLogger } from "next-auth/utils/logger"; 13 | import { useMutation } from "@tanstack/react-query"; 14 | import { ThreadCreationRequest } from "@/lib/validators/threadSubmit"; 15 | import { toast } from "@/lib/use-toast"; 16 | import { useRouter } from "next/navigation"; 17 | import { uploadFiles } from "@/lib/uploadthing"; 18 | import Slider from "react-slick"; 19 | import { X } from "lucide-react"; 20 | import { Swiper, SwiperSlide } from "swiper/react"; 21 | import "swiper/css"; 22 | import Create from "./thread/create/Create"; 23 | import { User } from "next-auth"; 24 | 25 | interface ThreadCreateProps { 26 | user: User; 27 | isReply?: boolean; 28 | } 29 | 30 | const ThreadCreate: FC = ({ user, isReply }) => { 31 | const router = useRouter(); 32 | return ( 33 | <> 34 |
35 | New Thread 36 | 39 |
40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default ThreadCreate; 48 | -------------------------------------------------------------------------------- /src/components/common/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC, useEffect, useState } from "react"; 3 | import { Home, Search, EditSquare, Heart2, User } from "react-iconly"; 4 | import { usePathname } from "next/navigation"; 5 | import { navigationUrls } from "@/urls/navigationUrls"; 6 | import Link from "next/link"; 7 | import { cn } from "@/lib/utils"; 8 | import { Button, buttonVariants } from "../ui/button"; 9 | import { Separator } from "@/components/ui/separator"; 10 | import Logo from "../logo/Logo"; 11 | import { Menu } from "lucide-react"; 12 | import { 13 | DropdownMenu, 14 | DropdownMenuContent, 15 | DropdownMenuItem, 16 | DropdownMenuLabel, 17 | DropdownMenuSeparator, 18 | DropdownMenuTrigger, 19 | } from "@/components/ui/dropdown-menu"; 20 | import { useTheme } from "next-themes"; 21 | import { signOut } from "next-auth/react"; 22 | 23 | interface NavbarProps { 24 | username?: any; 25 | unReadNotificationCount?: number; 26 | } 27 | 28 | const Navbar: FC = ({ username, unReadNotificationCount }) => { 29 | const [mounted, setMounted] = useState(false); 30 | const { theme, setTheme } = useTheme(); 31 | 32 | const switchTheme = () => { 33 | if (theme === "light") { 34 | setTheme("dark"); 35 | } else { 36 | setTheme("light"); 37 | } 38 | }; 39 | 40 | useEffect(() => setMounted(true), []); 41 | const pathname = usePathname(); 42 | return ( 43 | <> 44 | {/* For mobile devices */} 45 | {pathname !== "/create" && ( 46 |
47 |
48 | {navigationUrls.map((navItem, index) => ( 49 | 50 | {navItem.url === pathname ? ( 51 | 52 | ) : ( 53 | 54 | )} 55 | {navItem.url === "/notifications" && 56 | unReadNotificationCount !== 0 && ( 57 | 58 |

{unReadNotificationCount}

59 |
60 | )} 61 | 62 | ))} 63 | {username && ( 64 | 65 | {`/${username}` === pathname ? ( 66 | 67 | ) : ( 68 | 69 | )} 70 | 71 | )} 72 |
73 |
74 | )} 75 | 76 | {/* For desktop devices */} 77 | 78 | 161 | 162 | ); 163 | }; 164 | 165 | export default Navbar; 166 | -------------------------------------------------------------------------------- /src/components/common/ProfilePreview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ProfilePreviewProps { 4 | username: string; 5 | } 6 | 7 | const ProfilePreview = ({ username }: ProfilePreviewProps) => { 8 | console.log(username); 9 | return
ProfilePreview
; 10 | }; 11 | 12 | export default ProfilePreview; 13 | -------------------------------------------------------------------------------- /src/components/common/Providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import React from "react"; 5 | import { ThemeProvider } from "next-themes"; 6 | import NextTopLoader from "nextjs-toploader"; 7 | 8 | const Providers = ({ children }: { children: React.ReactNode }) => { 9 | const queryClient = new QueryClient(); 10 | return ( 11 | 12 | 13 | 14 | {children} 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Providers; 21 | -------------------------------------------------------------------------------- /src/components/logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState } from "react"; 3 | import { Button } from "../ui/button"; 4 | import Link from "next/link"; 5 | import Image from "next/image"; 6 | import { useTheme } from "next-themes"; 7 | 8 | function Logo({ ...props }) { 9 | const [mounted, setMounted] = useState(false); 10 | const { theme, setTheme } = useTheme(); 11 | 12 | useEffect(() => setMounted(true), []); 13 | 14 | if (!mounted) return null; 15 | return ( 16 |
17 | setTheme("light")} 19 | width={100} 20 | height={100} 21 | className="p-2 cursor-pointer active:scale-75 transition-all hidden dark:block rounded-full hover:bg-muted w-14" 22 | src="/logo/threads-dark.svg" 23 | alt="threads logo" 24 | /> 25 | setTheme("dark")} 27 | width={100} 28 | height={100} 29 | className="p-2 transition-all cursor-pointer active:scale-75 dark:hidden rounded-full hover:bg-muted w-14" 30 | src="/logo/threads.svg" 31 | alt="threads logo" 32 | /> 33 |
34 | ); 35 | } 36 | 37 | export default Logo; 38 | -------------------------------------------------------------------------------- /src/components/miscellaneous/CloseModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { X } from "lucide-react"; 3 | 4 | import { useRouter } from "next/navigation"; 5 | import { Button } from "../ui/button"; 6 | 7 | const CloseModal = () => { 8 | const router = useRouter(); 9 | return ( 10 | 18 | ); 19 | }; 20 | 21 | export default CloseModal; 22 | -------------------------------------------------------------------------------- /src/components/miscellaneous/SorryPageNotFound.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import Logo from "../logo/Logo"; 3 | 4 | interface SorryPageNotFoundProps {} 5 | 6 | const SorryPageNotFound: FC = ({}) => { 7 | return ( 8 |
9 | 10 |

11 | Sorry, this page isn't available 12 |

13 |

14 | The link you followed may be broken, or the page may have been removed 15 |

16 |
17 | ); 18 | }; 19 | 20 | export default SorryPageNotFound; 21 | -------------------------------------------------------------------------------- /src/components/notifications/AllNotifications.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC } from "react"; 3 | 4 | interface AllNotificationsProps { 5 | notifications: any; 6 | } 7 | 8 | const AllNotifications: FC = ({ notifications }) => { 9 | console.log(notifications); 10 | return
AllNotifications
; 11 | }; 12 | 13 | export default AllNotifications; 14 | -------------------------------------------------------------------------------- /src/components/notifications/Notification.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prisma, User } from "@prisma/client"; 3 | import { FC, useEffect } from "react"; 4 | import Image from "next/image"; 5 | import AuthorNameLink from "../thread/AuthorNameLink"; 6 | import Link from "next/link"; 7 | import { Heart } from "react-iconly"; 8 | import { formatTimeToNow } from "@/lib/utils"; 9 | import FollowButton from "../search/FollowButton"; 10 | import { StarIcon, User2Icon, UserIcon } from "lucide-react"; 11 | import { readNotification } from "@/lib/actions"; 12 | interface NotificationProps { 13 | data: Prisma.NotificationGetPayload<{ 14 | include: { 15 | sender: true; 16 | thread: true; 17 | }; 18 | }>; 19 | user: Prisma.UserGetPayload<{ 20 | include: { 21 | following: true; 22 | }; 23 | }>; 24 | } 25 | 26 | const Notification: FC = ({ data, user }) => { 27 | useEffect(() => { 28 | if (!data.read) readNotification(data.id); 29 | }, []); 30 | 31 | if (data.thread && data.type === "LIKE") { 32 | return ( 33 | 34 |
35 |
36 | {data.sender.name} 43 |
44 | 45 |
46 |
47 |
48 |
49 | 54 | 55 | {formatTimeToNow(data.createdAt)} 56 | 57 |
58 | 59 | 60 | {/* @ts-ignore */} 61 | {data.thread?.content.text || "liked your post"} 62 | 63 |
64 |
65 | 66 | ); 67 | } 68 | 69 | if (data.type === "FOLLOW") { 70 | const isFollowing = user.following.some( 71 | (following) => following.id === data.senderId 72 | ); 73 | 74 | return ( 75 | 76 |
77 |
78 |
79 | {data.sender.name} 86 |
87 | 88 |
89 |
90 |
91 |
92 | 97 | 98 | {formatTimeToNow(data.createdAt)} 99 | 100 |
101 | 102 | 103 | Followed you 104 | 105 |
106 |
107 | 113 |
114 | 115 | ); 116 | } 117 | 118 | if (data.thread && data.type === "REPLY") { 119 | return ( 120 | 121 |
122 |
123 | {data.sender.name} 130 |
131 | 132 |
133 |
134 |
135 |
136 | 141 | 142 | {formatTimeToNow(data.createdAt)} 143 | 144 |
145 | 146 | Replied to your post 147 | 148 | 149 | {/* @ts-ignore */} 150 | {data.thread?.content.text} 151 | 152 |
153 |
154 | 155 | ); 156 | } 157 | 158 | return
; 159 | }; 160 | 161 | export default Notification; 162 | -------------------------------------------------------------------------------- /src/components/notifications/NotificationsNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC } from "react"; 3 | import { buttonVariants } from "../ui/button"; 4 | import Link from "next/link"; 5 | import { cn } from "@/lib/utils"; 6 | import { usePathname } from "next/navigation"; 7 | import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; 8 | 9 | const notificationOptions = [ 10 | { 11 | title: "All", 12 | path: "/notifications", 13 | }, 14 | { 15 | title: "Likes", 16 | path: "/notifications/likes", 17 | }, 18 | { 19 | title: "Replies", 20 | path: "/notifications/replies", 21 | }, 22 | { 23 | title: "Mentions", 24 | path: "/notifications/mentions", 25 | }, 26 | ]; 27 | 28 | interface NotificationsNavProps {} 29 | 30 | const NotificationsNav: FC = ({}) => { 31 | const pathname = usePathname(); 32 | return ( 33 | <> 34 |

Activity

35 | 36 | 37 | 38 |
39 | {notificationOptions.map((option) => ( 40 | 50 | {option.title} 51 | 52 | ))} 53 |
54 |
55 | 56 | ); 57 | }; 58 | 59 | export default NotificationsNav; 60 | -------------------------------------------------------------------------------- /src/components/onboarding/OnboardingProfileUpdate.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/lib/use-toast"; 2 | import { validateUsername } from "@/lib/username"; 3 | import { FC, useState, useTransition } from "react"; 4 | import { Button } from "../ui/button"; 5 | import { AlertCircle, Pencil } from "lucide-react"; 6 | import { Input } from "../ui/input"; 7 | import { Card, CardContent } from "../ui/card"; 8 | import { Label } from "../ui/label"; 9 | import Filter from "bad-words"; 10 | import { onboardData } from "@/lib/actions"; 11 | import Image from "next/image"; 12 | import { uploadImage } from "@/lib/uploadImage"; 13 | interface OnboardingProfileUpdateProps { 14 | userData: { 15 | id: string; 16 | username: string; 17 | name: string; 18 | bio: string; 19 | image: string; 20 | }; 21 | next: () => void; 22 | allUsernames: string[]; 23 | } 24 | 25 | const OnboardingProfileUpdate: FC = ({ 26 | userData, 27 | next, 28 | allUsernames, 29 | }) => { 30 | const [isPending, startTransition] = useTransition(); 31 | const [username, setUsername] = useState(userData.username); 32 | const [name, setName] = useState(userData.name); 33 | const [bio, setBio] = useState(userData.bio || ""); 34 | const [image, setImage] = useState(userData.image || ""); 35 | const [loading, setLoading] = useState(false); 36 | 37 | const handleUpload = async (e: any) => { 38 | setLoading(true); 39 | 40 | const file = e.target.files[0]; 41 | const imgUrl = await uploadImage(file); 42 | setImage(imgUrl); 43 | setLoading(false); 44 | }; 45 | 46 | const { toast } = useToast(); 47 | const filter = new Filter(); 48 | 49 | return ( 50 | <> 51 | 52 | 53 |
54 |
55 |
56 |
57 | {userData.name} 64 | 65 | 68 | 76 |
77 |
78 | 79 |
80 | 81 | setUsername(e.target.value)} 84 | id="username" 85 | placeholder="Your unique username" 86 | /> 87 | {allUsernames.includes(username) && 88 | username !== userData.username ? ( 89 |
90 | Username is taken. 91 |
92 | ) : null} 93 | {filter.isProfane(username) ? ( 94 |
95 | Choose an 96 | appropriate username. 97 |
98 | ) : null} 99 | {!validateUsername(username) ? ( 100 |
101 | {" "} 102 | Only use lowercase letters, numbers, underscores, & dots 103 | (cannot start/end with last 2). 104 |
105 | ) : null} 106 | {username.length === 0 ? ( 107 |
108 | Username cannot be 109 | empty. 110 |
111 | ) : username.length > 16 ? ( 112 |
113 | Username is too 114 | long. 115 |
116 | ) : null} 117 |
118 |
119 | 120 | setName(e.target.value)} 123 | id="name" 124 | placeholder="Name displayed on your profile" 125 | /> 126 | {name.length === 0 ? ( 127 |
128 | Your name cannot be 129 | empty. 130 |
131 | ) : name.length > 16 ? ( 132 |
133 | Your name is too 134 | long. 135 |
136 | ) : null} 137 |
138 |
139 | 140 | setBio(e.target.value)} 143 | id="bio" 144 | placeholder="+ Write bio" 145 | /> 146 | {name.length > 100 ? ( 147 |
148 | Your bio is too 149 | long. 150 |
151 | ) : null} 152 |
153 |
154 |
155 | 181 |
182 |
183 | 184 | ); 185 | }; 186 | 187 | export default OnboardingProfileUpdate; 188 | -------------------------------------------------------------------------------- /src/components/onboarding/Privacy.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC } from "react"; 3 | import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"; 4 | import { Button } from "../ui/button"; 5 | import Link from "next/link"; 6 | interface PrivacyProps {} 7 | 8 | const Privacy: FC = ({}) => { 9 | return ( 10 | <> 11 | 12 | 13 | Public profile 14 | 15 | Anyone on or off Threads can see, share and interact with your 16 | content. 17 | 18 | 19 | 20 |
21 | 22 | 23 | Private profile 24 | 25 | Only your approved followers can see and interact with your 26 | content. (coming soon) 27 | 28 | 29 | 30 |
31 | 32 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Privacy; 41 | -------------------------------------------------------------------------------- /src/components/onboarding/Screens.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC, useState } from "react"; 3 | import { Button } from "../ui/button"; 4 | import { ChevronLeft } from "lucide-react"; 5 | import Privacy from "./Privacy"; 6 | import OnboardingProfileUpdate from "./OnboardingProfileUpdate"; 7 | 8 | interface ScreensProps { 9 | userData: { 10 | id: string ; 11 | onboarded: boolean; 12 | bio: string; 13 | username: string; 14 | name: string; 15 | image: string; 16 | email: string; 17 | }; 18 | allUsernames: { 19 | username: string; 20 | }[]; 21 | } 22 | 23 | const Screens: FC = ({ userData, allUsernames }) => { 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 | 72 | export default Screens; 73 | -------------------------------------------------------------------------------- /src/components/profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { User } from "@prisma/client"; 3 | import { FC, useTransition } from "react"; 4 | import { Badge } from "../ui/badge"; 5 | import { Button } from "../ui/button"; 6 | import { useToast } from "@/lib/use-toast"; 7 | import { usePathname } from "next/navigation"; 8 | import { Loader2 } from "lucide-react"; 9 | import Image from "next/image"; 10 | import EditProfile from "./EditProfile"; 11 | import { createLinks } from "@/lib/utils"; 12 | 13 | interface ProfileProps { 14 | user: User & { 15 | followedBy: User[]; 16 | }; 17 | 18 | getSelf: User | null; 19 | isFollowing: boolean; 20 | self: boolean; 21 | unfollowUser: (userId: string, followingId: string, pathname: string) => void; 22 | followUser: (userId: string, followingId: string, pathname: string) => void; 23 | allUsernames: string[]; 24 | } 25 | 26 | const Profile: FC = ({ 27 | user, 28 | getSelf, 29 | self, 30 | isFollowing, 31 | followUser, 32 | unfollowUser, 33 | allUsernames, 34 | }) => { 35 | const [isPending, startTransition] = useTransition(); 36 | const { toast } = useToast(); 37 | const pathname = usePathname(); 38 | if (!getSelf) return null; 39 | 40 | const userData = { 41 | id: user.id, 42 | username: user.username, 43 | name: user.name, 44 | bio: user.bio, 45 | image: user.image, 46 | }; 47 | 48 | const shareProfile = () => { 49 | const shareData = { 50 | title: "Threads", 51 | text: `Check out ${user.name}'s (@${user.username}) on Threads`, 52 | url: `https://threads-meta.vercel.app/${user.username}`, 53 | }; 54 | if (navigator.share) navigator.share(shareData); 55 | }; 56 | 57 | const bioWithLinks = user.bio.replace( 58 | /https?:\/\/\S+/g, 59 | (match) => 60 | `${match}` 61 | ); 62 | 63 | return ( 64 |
65 |
66 |
67 |
68 |
{user.name}
69 |
70 |
{user.username}
71 |
72 | 76 | threads.net 77 | 78 |
79 |
80 |
81 |
82 | {user.name} 89 |
90 |
91 |
92 | {/*

{user.bio}

*/} 93 |

97 |

98 |
99 | {user.followedBy.length} followers 100 |
101 | 102 | {self ? ( 103 |
104 | 105 | 113 |
114 | ) : ( 115 | 140 | )} 141 |
142 |
143 | ); 144 | }; 145 | 146 | export default Profile; 147 | -------------------------------------------------------------------------------- /src/components/profile/SignOut.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { LogOutIcon } from "lucide-react"; 3 | import { FC } from "react"; 4 | import { Button } from "../ui/button"; 5 | import { signOut } from "next-auth/react"; 6 | 7 | interface SignOutProps {} 8 | 9 | const SignOut: FC = ({}) => { 10 | return ( 11 | 14 | ); 15 | }; 16 | 17 | export default SignOut; 18 | -------------------------------------------------------------------------------- /src/components/search/Bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Search } from "lucide-react"; 4 | 5 | import { useRouter } from "next/navigation"; 6 | import { useEffect, useState } from "react"; 7 | import { Input } from "../ui/input"; 8 | 9 | export function Bar({ usersCount }: { usersCount: number }) { 10 | const router = useRouter(); 11 | 12 | const [search, setSearch] = useState(""); 13 | 14 | //query after 0.3s of no input 15 | useEffect(() => { 16 | const delayDebounceFn = setTimeout(() => { 17 | if (search) { 18 | console.log("pushing /search?q=" + search); 19 | router.push("/search?q=" + search); 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 | 34 | 35 | setSearch(e.target.value)} 39 | className="pl-8" 40 | /> 41 |
42 |
43 |
44 | {usersCount} users 45 |
46 |
47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/search/FollowButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTransition } from "react"; 4 | import { Button } from "../ui/button"; 5 | import { followUser, unfollowUser } from "@/lib/actions"; 6 | import { Loader2 } from "lucide-react"; 7 | import { usePathname } from "next/navigation"; 8 | import { toast } from "@/lib/use-toast"; 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 | 23 | const pathname = usePathname(); 24 | 25 | return ( 26 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/search/SearchUser.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prisma, User } from "@prisma/client"; 3 | import { Button } from "../ui/button"; 4 | import Image from "next/image"; 5 | 6 | import Link from "next/link"; 7 | import { nFormatter } from "@/lib/utils"; 8 | import FollowButton from "./FollowButton"; 9 | 10 | export function SearchUser({ 11 | user, 12 | isFollowing, 13 | id, 14 | }: { 15 | user: Prisma.UserGetPayload<{ 16 | include: { 17 | followedBy: true; 18 | }; 19 | }>; 20 | isFollowing: boolean; 21 | id: string; 22 | }) { 23 | return ( 24 | 25 |
26 | {user.name} 33 |
34 |
35 |
36 |
{user.username}
37 |
38 | {user.name} 39 |
40 | 41 |
42 | {nFormatter(user.followedBy.length, 1)}{" "} 43 | {user.followedBy.length === 1 ? "follower" : "followers"} 44 |
45 |
46 | 52 |
53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/settings/ThemeChange.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC, useEffect, useState } from "react"; 3 | 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuLabel, 9 | DropdownMenuSeparator, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { useTheme } from "next-themes"; 13 | import { Button } from "../ui/button"; 14 | import { Laptop, Moon, Sun } from "lucide-react"; 15 | interface ThemeChangeProps {} 16 | 17 | const ThemeChange: FC = ({}) => { 18 | return ( 19 |
20 |
21 |

Application Appearence

22 |
23 | Change how the application looks by selecting a theme from the options 24 |
25 |
26 | 27 |
28 | ); 29 | }; 30 | 31 | export default ThemeChange; 32 | 33 | const ThemeChanger = () => { 34 | const [mounted, setMounted] = useState(false); 35 | const { theme, setTheme } = useTheme(); 36 | 37 | useEffect(() => setMounted(true), []); 38 | 39 | if (!mounted) return null; 40 | return ( 41 | 42 | 43 | 46 | 47 | 48 | setTheme("light")}> 49 | 50 | Light 51 | 52 | setTheme("dark")}> 53 | 54 | Dark 55 | 56 | setTheme("system")}> 57 | 58 | System 59 | 60 | 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/thread/AuthorNameLink.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserCog } from "lucide-react"; 4 | import { BadgeCheck } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { useRouter } from "next/navigation"; 7 | import Image from "next/image"; 8 | import VerifiedBadge from "@/assets/verified.png"; 9 | 10 | export default function AuthorNameLink({ 11 | name, 12 | username, 13 | role, 14 | }: { 15 | name: string; 16 | username: string; 17 | role?: string; 18 | }) { 19 | return ( 20 | 24 | {username} 25 | 26 | {role === "ADMIN" && ( 27 |
28 | verified 29 | 30 |
31 | )} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/thread/HomeThreads.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prisma, User } from "@prisma/client"; 3 | import { FC, useEffect, useState } from "react"; 4 | import { useInView } from "react-intersection-observer"; 5 | import Thread from "./Thread"; 6 | import ThreadComponent from "./ThreadComponent"; 7 | import { Loader2 } from "lucide-react"; 8 | 9 | interface HomeThreadsProps { 10 | user: User | null; 11 | threads: Prisma.ThreadGetPayload<{ 12 | include: { 13 | author: true; 14 | children: { 15 | include: { 16 | author: true; 17 | }; 18 | }; 19 | parent: true; 20 | likes: true; 21 | }; 22 | }>[]; 23 | } 24 | 25 | const HomeThreads: FC = ({ user, threads }) => { 26 | const [items, setItems] = useState(threads); 27 | const [noMore, setNoMore] = useState(false); 28 | const [loading, setLoading] = useState(false); 29 | 30 | console.log("items", items); 31 | 32 | const { ref, inView } = useInView(); 33 | 34 | useEffect(() => { 35 | if (inView && !noMore) { 36 | setLoading(true); 37 | loadMore(); 38 | } 39 | }, [inView, noMore]); 40 | 41 | useEffect(() => { 42 | setItems(threads); 43 | }, [threads]); 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 | console.log("morePosts", morePosts); 58 | 59 | setItems([...items, ...morePosts.data]); 60 | setLoading(false); 61 | }; 62 | 63 | return ( 64 | <> 65 | {items.map((item, i) => { 66 | if (i === items.length - 1) 67 | return ( 68 |
69 | 70 |
71 | ); 72 | return ( 73 | 79 | ); 80 | })} 81 |
82 | {items.length === 0 ? ( 83 |
84 | There are no threads...
85 | Try making one! 86 |
87 | ) : null} 88 | 89 | {loading ? ( 90 | 91 | ) : null} 92 |
93 | 94 | ); 95 | }; 96 | 97 | export default HomeThreads; 98 | -------------------------------------------------------------------------------- /src/components/thread/MainThread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prisma, Thread, User } from "@prisma/client"; 3 | import Image from "next/image"; 4 | import { Image as AntImage } from "antd"; 5 | import Link from "next/link"; 6 | import { FC } from "react"; 7 | import loop from "@/assets/loop.svg"; 8 | import AuthorNameLink from "./AuthorNameLink"; 9 | import UserActions from "./controls"; 10 | import MoreMenu from "./MoreMenu"; 11 | import { createLinks, formatTimeToNow } from "@/lib/utils"; 12 | interface ThreadComponentProps { 13 | data: Prisma.ThreadGetPayload<{ 14 | include: { 15 | author: true; 16 | children: { 17 | include: { 18 | author: true; 19 | }; 20 | }; 21 | parent: true; 22 | likes: true; 23 | }; 24 | }> & { 25 | content: any; 26 | }; 27 | comment?: boolean; 28 | threads?: Prisma.ThreadGetPayload<{ 29 | include: { 30 | author: true; 31 | children: { 32 | include: { 33 | author: true; 34 | }; 35 | }; 36 | parent: true; 37 | likes: true; 38 | }; 39 | }>[]; 40 | 41 | parent?: boolean; 42 | noLink?: boolean; 43 | role?: string; 44 | } 45 | 46 | const MainThread: FC = ({ 47 | data, 48 | comment = false, 49 | threads, 50 | role, 51 | }) => { 52 | const contentWithLinks = data.content?.text.replace( 53 | /https?:\/\/\S+/g, 54 | (match: string) => 55 | `${match}` 56 | ); 57 | 58 | return ( 59 | <> 60 |
61 |
62 |
63 |
64 | {data.author.name 71 |
72 | 77 |
78 |
79 | 80 | {formatTimeToNow(data.createdAt) || "now"} 81 | 82 | 88 |
89 |
90 |
91 |
97 |
98 | {data.content?.images.length > 1 ? ( 99 |
100 | 101 | {data.content?.images.map((image: string, index: number) => ( 102 |
103 | 109 |
110 | ))} 111 |
112 |
113 | ) : data.content?.images.length === 1 ? ( 114 |
115 | {data.content?.images.map((image: string, index: number) => ( 116 |
117 | 123 |
124 | ))} 125 |
126 | ) : null} 127 |
128 | 129 | <> 130 | 131 |
132 | {data.children.length > 0 ? ( 133 |
134 | {data.children.length}{" "} 135 | {data.children.length === 1 ? "reply" : "replies"} 136 |
137 | ) : null} 138 | {data.children.length > 0 && data.likes.length > 0 ? ( 139 |
140 | ) : null} 141 | {data.likes.length > 0 ? ( 142 |
143 | {data.likes.length}{" "} 144 | {data.likes.length === 1 ? "like" : "likes"} 145 |
146 | ) : null} 147 |
148 | 149 |
150 |
151 | 152 | ); 153 | }; 154 | 155 | export default MainThread; 156 | -------------------------------------------------------------------------------- /src/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 | 12 | import { Flag, Loader2, MoreHorizontal, Trash, UserX2 } from "lucide-react"; 13 | import { useEffect, useState, useTransition } from "react"; 14 | import { usePathname, useRouter } from "next/navigation"; 15 | import { deleteThread } from "@/lib/actions"; 16 | import { useToast } from "@/lib/use-toast"; 17 | import { useSession } from "next-auth/react"; 18 | 19 | export default function MoreMenu({ 20 | author, 21 | name, 22 | mainPage = false, 23 | id, 24 | role, 25 | }: { 26 | author: string; 27 | name: string; 28 | mainPage?: boolean; 29 | id: string; 30 | role?: string; 31 | }) { 32 | const { data } = useSession(); 33 | const user = data?.user; 34 | 35 | const { toast } = useToast(); 36 | const pathname = usePathname(); 37 | const router = useRouter(); 38 | const [isPending, startTransition] = useTransition(); 39 | 40 | const [deleted, setDeleted] = useState(false); 41 | const [open, setOpen] = useState(false); 42 | 43 | const self = user?.id === author; 44 | 45 | useEffect(() => { 46 | if (deleted && !isPending) { 47 | toast({ 48 | title: "Thread deleted", 49 | }); 50 | setOpen(false); 51 | if (pathname.startsWith("/thread")) { 52 | router.push("/"); 53 | } 54 | } 55 | }, [deleted, isPending]); 56 | 57 | return ( 58 | 59 | { 61 | e.stopPropagation(); 62 | e.preventDefault(); 63 | setOpen((prev) => !prev); 64 | }} 65 | > 66 | {" "} 67 | 68 | 69 | 70 | {role === "ADMIN" || self ? ( 71 | { 73 | e.preventDefault(); 74 | startTransition(() => deleteThread(id, pathname)); 75 | setDeleted(true); 76 | }} 77 | disabled={deleted} 78 | className="!text-red-500" 79 | > 80 | {" "} 81 | {deleted ? ( 82 | 83 | ) : ( 84 | 85 | )} 86 | Delete 87 | 88 | ) : ( 89 | <> 90 | { 92 | toast({ 93 | title: name + " has been blocked", 94 | }); 95 | setOpen(false); 96 | }} 97 | > 98 | 99 | Block 100 | 101 | { 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 | -------------------------------------------------------------------------------- /src/components/thread/Others.tsx: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import Image from "next/image"; 3 | 4 | export default function Others({ 5 | others, 6 | }: { 7 | others: Prisma.ThreadGetPayload<{ 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 |
19 |
20 | {others[0].author.name} 27 |
28 |
29 | ); 30 | } 31 | if (others.length === 2) { 32 | return ( 33 |
34 |
35 | {others[0].author.name} 42 |
43 |
44 | {others[1].author.name} 51 |
52 |
53 | ); 54 | } 55 | return ( 56 |
57 |
58 | {others[0].author.name 65 |
66 |
67 | {others[1].author.name 74 |
75 |
76 | {others[2].author.name 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/thread/Thread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Thread, User } from "@prisma/client"; 3 | import { FC } from "react"; 4 | import { Separator } from "../ui/separator"; 5 | import { ArrowLeft } from "lucide-react"; 6 | import { Button } from "../ui/button"; 7 | import { Avatar, AvatarImage } from "../ui/avatar"; 8 | import { formatTimeToNow } from "@/lib/utils"; 9 | import { Image } from "antd"; 10 | 11 | interface ThreadProps { 12 | thread: Thread & { content: any }; 13 | user: User; 14 | } 15 | 16 | const Thread: FC = ({ thread, user }) => { 17 | return ( 18 |
19 |
20 | 23 | Thread 24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 | {user.username} 34 |
35 | 36 |
37 | 38 | {formatTimeToNow(thread.createdAt)} 39 | 40 |
41 |
42 | 43 |
44 |
{thread?.content?.text}
45 |
46 | {thread?.content?.images.length > 0 && ( 47 | 48 |
49 | {thread?.content?.images.map((image: string, index: number) => ( 50 |
51 | 56 |
57 | ))} 58 |
59 |
60 | )} 61 |
62 |
63 |
64 | ); 65 | }; 66 | 67 | export default Thread; 68 | -------------------------------------------------------------------------------- /src/components/thread/ThreadComponent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prisma, Thread, User } from "@prisma/client"; 3 | import Image from "next/image"; 4 | import { Image as AntImage } from "antd"; 5 | import Link from "next/link"; 6 | import { FC } from "react"; 7 | import loop from "@/assets/loop.svg"; 8 | import lightLoop from "@/assets/loop-light.svg"; 9 | import AuthorNameLink from "./AuthorNameLink"; 10 | import UserActions from "./controls"; 11 | import MoreMenu from "./MoreMenu"; 12 | import { createLinks, formatTimeToNow } from "@/lib/utils"; 13 | import Others from "./Others"; 14 | interface ThreadComponentProps { 15 | data: Prisma.ThreadGetPayload<{ 16 | include: { 17 | author: true; 18 | children: { 19 | include: { 20 | author: true; 21 | }; 22 | }; 23 | parent: true; 24 | likes: true; 25 | }; 26 | }> & { 27 | content: any; 28 | }; 29 | comment?: boolean; 30 | threads?: Prisma.ThreadGetPayload<{ 31 | include: { 32 | author: true; 33 | children: { 34 | include: { 35 | author: true; 36 | }; 37 | }; 38 | parent: true; 39 | likes: true; 40 | }; 41 | }>[]; 42 | 43 | parent?: boolean; 44 | noLink?: boolean; 45 | role?: string; 46 | } 47 | 48 | const ThreadComponent: FC = ({ 49 | data, 50 | comment = false, 51 | threads, 52 | noLink = false, 53 | 54 | parent = false, 55 | role, 56 | }) => { 57 | const mainClass = parent 58 | ? "px-3 pt-4 space-x-2 flex font-light" 59 | : comment 60 | ? `space-x-2 flex font-light ${noLink ? "pointer-events-none" : ""}` 61 | : `px-3 py-4 space-x-2 flex border-b font-light ${ 62 | noLink ? "pointer-events-none" : "" 63 | }`; 64 | 65 | const contentWithLinks = data.content?.text.replace( 66 | /https?:\/\/\S+/g, 67 | (match: string) => 68 | `${match}` 69 | ); 70 | 71 | 72 | return ( 73 | <> 74 |
75 |
76 |
77 | 78 | {data.author.name} 85 | 86 |
87 |
92 | {parent ? ( 93 |
94 | 101 | 108 |
109 | ) : null} 110 |
111 | {comment || parent ? null : } 112 |
113 |
114 |
115 | 120 | {comment ? null : ( 121 | 125 | 126 | {formatTimeToNow(data.createdAt)} 127 | 128 | 134 | 135 | )} 136 |
137 | 138 | 139 | {/*
140 | {data.content?.text} 141 |
*/} 142 | 143 |
149 | 150 |
151 | {data.content?.images.length > 1 ? ( 152 |
153 | 154 | {data.content?.images.map((image: string, index: number) => ( 155 |
156 | 162 |
163 | ))} 164 |
165 |
166 | ) : data.content?.images.length === 1 ? ( 167 |
168 | {data.content?.images.map((image: string, index: number) => ( 169 |
170 | 176 |
177 | ))} 178 |
179 | ) : null} 180 |
181 | 182 | {comment ? null : ( 183 | <> 184 | 188 | 189 |
190 | {data.children.length > 0 ? ( 191 |
192 | {data.children.length}{" "} 193 | {data.children.length === 1 ? "reply" : "replies"} 194 |
195 | ) : null} 196 | {data.children.length > 0 && data.likes.length > 0 ? ( 197 |
198 | ) : null} 199 | {data.likes.length > 0 ? ( 200 |
201 | {data.likes.length}{" "} 202 | {data.likes.length === 1 ? "like" : "likes"} 203 |
204 | ) : null} 205 |
206 | 207 | 208 | )} 209 |
210 |
211 | 212 | ); 213 | }; 214 | 215 | export default ThreadComponent; 216 | -------------------------------------------------------------------------------- /src/components/thread/comment/CreateComponent.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 | 8 | import Image from "next/image"; 9 | import { usePathname } from "next/navigation"; 10 | import { replyToThread } from "@/lib/actions"; 11 | import { toast } from "@/lib/use-toast"; 12 | import { useSession } from "next-auth/react"; 13 | import ThreadComponent from "../ThreadComponent"; 14 | import Create from "../create/Create"; 15 | 16 | export function CreateComment({ 17 | thread, 18 | }: { 19 | thread: Prisma.ThreadGetPayload<{ 20 | include: { 21 | author: true; 22 | children: { 23 | include: { 24 | author: true; 25 | }; 26 | }; 27 | parent: true; 28 | likes: true; 29 | }; 30 | }>; 31 | }) { 32 | const [comment, setComment] = useState(""); 33 | const [clicked, setClicked] = useState(false); 34 | 35 | const { data, status } = useSession(); 36 | const user = data?.user; 37 | 38 | const [isPending, startTransition] = useTransition(); 39 | const pathname = usePathname(); 40 | 41 | useEffect(() => { 42 | if (clicked && !isPending) { 43 | setComment(""); 44 | 45 | setClicked(false); 46 | toast({ 47 | title: "Replied to thread", 48 | }); 49 | } 50 | }, [isPending]); 51 | 52 | if (!user) return null; 53 | 54 | return ( 55 |
56 | 57 | 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/thread/controls/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { MessageCircleIcon } from "lucide-react"; 2 | import Link from "next/link"; 3 | import { FC } from "react"; 4 | 5 | interface CommentProps { 6 | id: string; 7 | } 8 | 9 | const Comment: FC = ({ id }) => { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default Comment; 18 | -------------------------------------------------------------------------------- /src/components/thread/controls/Like.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FC, useEffect, useState, useTransition } from "react"; 3 | import { usePathname } from "next/navigation"; 4 | import { getSession, useSession } from "next-auth/react"; 5 | import { likeThread, unlikeThread } from "@/lib/actions/threadActions"; 6 | import { Heart } from "lucide-react"; 7 | import { Prisma } from "@prisma/client"; 8 | interface LikeProps { 9 | likes: string[]; 10 | numPosts?: number; 11 | thread: Prisma.ThreadGetPayload<{ 12 | include: { 13 | author: true; 14 | children: { 15 | include: { 16 | author: true; 17 | }; 18 | }; 19 | parent: true; 20 | likes: true; 21 | }; 22 | }>; 23 | } 24 | 25 | const Like: FC = ({ likes, numPosts, thread }) => { 26 | const [liked, setLiked] = useState(false); 27 | const [isPending, startTransition] = useTransition(); 28 | const pathname = usePathname(); 29 | const { data, status } = useSession(); 30 | 31 | const user = data?.user; 32 | 33 | useEffect(() => { 34 | if (user) { 35 | if (likes.includes(user.id)) { 36 | setLiked(true); 37 | } else { 38 | setLiked(false); 39 | } 40 | } 41 | }, [user, numPosts]); 42 | 43 | const handleLike = () => { 44 | // vibrate the device if possible 45 | if (typeof window !== "undefined") { 46 | if (window.navigator.vibrate) { 47 | window.navigator.vibrate(50); 48 | } 49 | } 50 | 51 | const wasLiked = liked; 52 | setLiked(!liked); 53 | 54 | if (user) { 55 | if (!wasLiked) { 56 | startTransition(() => 57 | likeThread(thread.id, user.id, thread.author.id, pathname) 58 | ); 59 | } else { 60 | startTransition(() => 61 | unlikeThread(thread.id, user.id, thread.author.id, pathname) 62 | ); 63 | } 64 | } 65 | }; 66 | 67 | return ( 68 | 77 | ); 78 | }; 79 | 80 | export default Like; 81 | -------------------------------------------------------------------------------- /src/components/thread/controls/Repost.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuLabel, 6 | DropdownMenuSeparator, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu"; 9 | import { toast } from "@/lib/use-toast"; 10 | import { Quote, Repeat2 } from "lucide-react"; 11 | import { FC } from "react"; 12 | 13 | interface RepostProps {} 14 | 15 | const Repost: FC = ({}) => { 16 | const repostThread = () => { 17 | console.log("repost"); 18 | toast({ 19 | description: "Repost feature coming soon", 20 | }); 21 | }; 22 | 23 | const quoteThread = () => { 24 | console.log("quote"); 25 | toast({ 26 | description: "Quote feature coming soon", 27 | }); 28 | }; 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | 36 | 37 | {" "} 38 | Repost{" "} 39 | 40 | 41 | Quote 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default Repost; 50 | -------------------------------------------------------------------------------- /src/components/thread/controls/Share.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuLabel, 7 | DropdownMenuSeparator, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { toast, useToast } from "@/lib/use-toast"; 11 | import { Prisma } from "@prisma/client"; 12 | import { ClipboardCopy, Send, ShareIcon } from "lucide-react"; 13 | import { FC } from "react"; 14 | 15 | interface ShareProps { 16 | data: Prisma.ThreadGetPayload<{ 17 | include: { 18 | author: true; 19 | children: { 20 | include: { 21 | author: true; 22 | }; 23 | }; 24 | parent: true; 25 | likes: true; 26 | }; 27 | }>; 28 | } 29 | 30 | const Share: FC = ({ data }) => { 31 | const copyToClipboard = () => { 32 | const link = `https://threads-meta.vercel.app/thread/${data.id}`; 33 | navigator.clipboard.writeText(link); 34 | toast({ 35 | description: "Link copied to clipboard", 36 | }); 37 | }; 38 | 39 | const shareToOthers = () => { 40 | const shareData = { 41 | title: "Threads", 42 | text: `Check out this thread by ${data.author.name} on Threads`, 43 | url: `https://threads-meta.vercel.app/thread/${data.id}`, 44 | }; 45 | 46 | if (navigator.share) navigator.share(shareData); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | Share 56 | 57 | 58 | {" "} 59 | Copy link{" "} 60 | 61 | 62 | 63 | Share via.. 64 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | export default Share; 71 | -------------------------------------------------------------------------------- /src/components/thread/controls/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prisma } from "@prisma/client"; 3 | import Like from "./Like"; 4 | import Repost from "./Repost"; 5 | import Share from "./Share"; 6 | import Comment from "./Comment"; 7 | 8 | export default function UserActions({ 9 | data, 10 | numPosts, 11 | }: { 12 | data: Prisma.ThreadGetPayload<{ 13 | include: { 14 | author: true; 15 | children: { 16 | include: { 17 | author: true; 18 | }; 19 | }; 20 | parent: true; 21 | likes: true; 22 | }; 23 | }>; 24 | numPosts?: number; 25 | }) { 26 | const likes = data.likes.map((like) => like.userId); 27 | 28 | return ( 29 |
30 |
31 | 32 | 33 | 34 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "src/lib/utils" 7 | import { buttonVariants } from "src/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = ({ 14 | className, 15 | ...props 16 | }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 17 | 18 | ) 19 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName 20 | 21 | const AlertDialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 33 | )) 34 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 35 | 36 | const AlertDialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, ...props }, ref) => ( 40 | 41 | 42 | 50 | 51 | )) 52 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 53 | 54 | const AlertDialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | AlertDialogHeader.displayName = "AlertDialogHeader" 67 | 68 | const AlertDialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | AlertDialogFooter.displayName = "AlertDialogFooter" 81 | 82 | const AlertDialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 91 | )) 92 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 93 | 94 | const AlertDialogDescription = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, ...props }, ref) => ( 98 | 103 | )) 104 | AlertDialogDescription.displayName = 105 | AlertDialogPrimitive.Description.displayName 106 | 107 | const AlertDialogAction = React.forwardRef< 108 | React.ElementRef, 109 | React.ComponentPropsWithoutRef 110 | >(({ className, ...props }, ref) => ( 111 | 116 | )) 117 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 118 | 119 | const AlertDialogCancel = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, ...props }, ref) => ( 123 | 132 | )) 133 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 134 | 135 | export { 136 | AlertDialog, 137 | AlertDialogTrigger, 138 | AlertDialogContent, 139 | AlertDialogHeader, 140 | AlertDialogFooter, 141 | AlertDialogTitle, 142 | AlertDialogDescription, 143 | AlertDialogAction, 144 | AlertDialogCancel, 145 | } 146 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | import { cn } from "@/lib/utils" 6 | 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/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-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 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 | -------------------------------------------------------------------------------- /src/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 | import { cn } from "@/lib/utils"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-9 px-4 rounded-xl py-2", 24 | sm: "h-8 rounded-[8px] px-3 text-xs", 25 | lg: "h-10 rounded-xl px-8", 26 | icon: "h-9 w-9", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /src/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 |

41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /src/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 { Cross2Icon } from "@radix-ui/react-icons"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Dialog = DialogPrimitive.Root; 9 | 10 | const DialogTrigger = DialogPrimitive.Trigger; 11 | 12 | const DialogPortal = ({ 13 | className, 14 | ...props 15 | }: DialogPrimitive.DialogPortalProps) => ( 16 | 17 | ); 18 | DialogPortal.displayName = DialogPrimitive.Portal.displayName; 19 | 20 | const DialogOverlay = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, ...props }, ref) => ( 24 | 32 | )); 33 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 34 | 35 | const DialogContent = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, children, ...props }, ref) => ( 39 | 40 | 41 | 49 | {children} 50 | 51 | 52 | Close 53 | 54 | 55 | 56 | )); 57 | DialogContent.displayName = DialogPrimitive.Content.displayName; 58 | 59 | const DialogHeader = ({ 60 | className, 61 | ...props 62 | }: React.HTMLAttributes) => ( 63 |
70 | ); 71 | DialogHeader.displayName = "DialogHeader"; 72 | 73 | const DialogFooter = ({ 74 | className, 75 | ...props 76 | }: React.HTMLAttributes) => ( 77 |
84 | ); 85 | DialogFooter.displayName = "DialogFooter"; 86 | 87 | const DialogTitle = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => ( 91 | 99 | )); 100 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 101 | 102 | const DialogDescription = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )); 112 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 113 | 114 | export { 115 | Dialog, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | }; 123 | -------------------------------------------------------------------------------- /src/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 { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | const DropdownMenu = DropdownMenuPrimitive.Root; 14 | 15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 16 | 17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 18 | 19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 20 | 21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 22 | 23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 24 | 25 | const DropdownMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean; 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )); 44 | DropdownMenuSubTrigger.displayName = 45 | DropdownMenuPrimitive.SubTrigger.displayName; 46 | 47 | const DropdownMenuSubContent = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 59 | )); 60 | DropdownMenuSubContent.displayName = 61 | DropdownMenuPrimitive.SubContent.displayName; 62 | 63 | const DropdownMenuContent = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, sideOffset = 4, ...props }, ref) => ( 67 | 68 | 78 | 79 | )); 80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 81 | 82 | const DropdownMenuItem = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef & { 85 | inset?: boolean; 86 | } 87 | >(({ className, inset, ...props }, ref) => ( 88 | 97 | )); 98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 99 | 100 | const DropdownMenuCheckboxItem = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, children, checked, ...props }, ref) => ( 104 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )); 121 | DropdownMenuCheckboxItem.displayName = 122 | DropdownMenuPrimitive.CheckboxItem.displayName; 123 | 124 | const DropdownMenuRadioItem = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, children, ...props }, ref) => ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | )); 144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 145 | 146 | const DropdownMenuLabel = React.forwardRef< 147 | React.ElementRef, 148 | React.ComponentPropsWithoutRef & { 149 | inset?: boolean; 150 | } 151 | >(({ className, inset, ...props }, ref) => ( 152 | 161 | )); 162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 163 | 164 | const DropdownMenuSeparator = React.forwardRef< 165 | React.ElementRef, 166 | React.ComponentPropsWithoutRef 167 | >(({ className, ...props }, ref) => ( 168 | 173 | )); 174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 175 | 176 | const DropdownMenuShortcut = ({ 177 | className, 178 | ...props 179 | }: React.HTMLAttributes) => { 180 | return ( 181 | 185 | ); 186 | }; 187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 188 | 189 | export { 190 | DropdownMenu, 191 | DropdownMenuTrigger, 192 | DropdownMenuContent, 193 | DropdownMenuItem, 194 | DropdownMenuCheckboxItem, 195 | DropdownMenuRadioItem, 196 | DropdownMenuLabel, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuGroup, 200 | DropdownMenuPortal, 201 | DropdownMenuSub, 202 | DropdownMenuSubContent, 203 | DropdownMenuSubTrigger, 204 | DropdownMenuRadioGroup, 205 | }; 206 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import * as React from "react"; 3 | 4 | export interface InputProps 5 | extends React.InputHTMLAttributes {} 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ); 20 | } 21 | ); 22 | Input.displayName = "Input"; 23 | 24 | export { Input }; 25 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "src/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Separator = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >( 11 | ( 12 | { className, orientation = "horizontal", decorative = true, ...props }, 13 | ref 14 | ) => ( 15 | 26 | ) 27 | ); 28 | Separator.displayName = SeparatorPrimitive.Root.displayName; 29 | 30 | export { Separator }; 31 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "src/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |