├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.cjs ├── prisma └── schema.prisma ├── public └── favicon.ico ├── src ├── components │ ├── Button.tsx │ ├── IconHoverEffect.tsx │ ├── InfiniteTweetList.tsx │ ├── LoadingSpinner.tsx │ ├── NewTweetForm.tsx │ ├── ProfileImage.tsx │ └── SideNav.tsx ├── env.mjs ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── index.tsx │ └── profiles │ │ └── [id].tsx ├── server │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── profile.ts │ │ │ └── tweet.ts │ │ ├── ssgHelper.ts │ │ └── trpc.ts │ ├── auth.ts │ └── db.ts ├── styles │ └── globals.css └── utils │ └── api.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.mjs" 10 | # should be updated accordingly. 11 | 12 | # Prisma 13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env 14 | DATABASE_URL="file:./db.sqlite" 15 | 16 | # Next Auth 17 | # You can generate a new secret on the command line with: 18 | # openssl rand -base64 32 19 | # https://next-auth.js.org/configuration/options#secret 20 | # NEXTAUTH_SECRET="" 21 | NEXTAUTH_URL="http://localhost:3000" 22 | 23 | # Next Auth Discord Provider 24 | DISCORD_CLIENT_ID="" 25 | DISCORD_CLIENT_SECRET="" 26 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require("path"); 3 | 4 | /** @type {import("eslint").Linter.Config} */ 5 | const config = { 6 | overrides: [ 7 | { 8 | extends: [ 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | ], 11 | files: ["*.ts", "*.tsx"], 12 | // TODO: Remove if/when this gets fixed 13 | rules: { 14 | "@typescript-eslint/no-unsafe-member-access": "off", 15 | "@typescript-eslint/no-unsafe-argument": "off", 16 | "@typescript-eslint/no-unsafe-call": "off", 17 | "@typescript-eslint/no-unsafe-return": "off", 18 | "@typescript-eslint/no-unsafe-assignment": "off", 19 | "@typescript-eslint/restrict-plus-operands": "off", 20 | }, 21 | parserOptions: { 22 | project: path.join(__dirname, "tsconfig.json"), 23 | }, 24 | }, 25 | ], 26 | parser: "@typescript-eslint/parser", 27 | parserOptions: { 28 | project: path.join(__dirname, "tsconfig.json"), 29 | }, 30 | plugins: ["@typescript-eslint"], 31 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 32 | rules: { 33 | "@typescript-eslint/consistent-type-imports": [ 34 | "warn", 35 | { 36 | prefer: "type-imports", 37 | fixStyle: "inline-type-imports", 38 | }, 39 | ], 40 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 41 | }, 42 | }; 43 | 44 | module.exports = config; 45 | -------------------------------------------------------------------------------- /.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 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 WebDevSimplified 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create T3 App 2 | 3 | This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. 4 | 5 | ## What's next? How do I make an app with this? 6 | 7 | We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. 8 | 9 | If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. 10 | 11 | - [Next.js](https://nextjs.org) 12 | - [NextAuth.js](https://next-auth.js.org) 13 | - [Prisma](https://prisma.io) 14 | - [Tailwind CSS](https://tailwindcss.com) 15 | - [tRPC](https://trpc.io) 16 | 17 | ## Learn More 18 | 19 | To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: 20 | 21 | - [Documentation](https://create.t3.gg/) 22 | - [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials 23 | 24 | You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! 25 | 26 | ## How do I deploy this? 27 | 28 | Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 29 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.mjs"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | reactStrictMode: true, 10 | images: { domains: ["cdn.discordapp.com"] }, 11 | 12 | /** 13 | * If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config 14 | * out. 15 | * 16 | * @see https://github.com/vercel/next.js/issues/41980 17 | */ 18 | i18n: { 19 | locales: ["en"], 20 | defaultLocale: "en", 21 | }, 22 | }; 23 | export default config; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "postinstall": "prisma generate", 9 | "lint": "next lint", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "@next-auth/prisma-adapter": "^1.0.5", 14 | "@prisma/client": "^4.11.0", 15 | "@tanstack/react-query": "^4.28.0", 16 | "@trpc/client": "^10.18.0", 17 | "@trpc/next": "^10.18.0", 18 | "@trpc/react-query": "^10.18.0", 19 | "@trpc/server": "^10.18.0", 20 | "next": "^13.2.4", 21 | "next-auth": "^4.21.0", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "react-icons": "^4.8.0", 25 | "react-infinite-scroll-component": "^6.1.0", 26 | "superjson": "1.12.2", 27 | "zod": "^3.21.4" 28 | }, 29 | "devDependencies": { 30 | "@types/eslint": "^8.21.3", 31 | "@types/node": "^18.15.5", 32 | "@types/prettier": "^2.7.2", 33 | "@types/react": "^18.0.28", 34 | "@types/react-dom": "^18.0.11", 35 | "@typescript-eslint/eslint-plugin": "^5.56.0", 36 | "@typescript-eslint/parser": "^5.56.0", 37 | "autoprefixer": "^10.4.14", 38 | "eslint": "^8.36.0", 39 | "eslint-config-next": "^13.2.4", 40 | "postcss": "^8.4.21", 41 | "prettier": "^2.8.6", 42 | "prettier-plugin-tailwindcss": "^0.2.6", 43 | "prisma": "^4.11.0", 44 | "tailwindcss": "^3.3.0", 45 | "typescript": "^5.0.2" 46 | }, 47 | "ct3aMetadata": { 48 | "initVersion": "7.11.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | model Tweet { 15 | id String @id @default(uuid()) 16 | userId String 17 | content String 18 | createdAt DateTime @default(now()) 19 | 20 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 21 | likes Like[] 22 | 23 | @@unique([createdAt, id]) 24 | @@index([userId]) 25 | } 26 | 27 | model Like { 28 | userId String 29 | tweetId String 30 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 31 | Tweet Tweet @relation(fields: [tweetId], references: [id], onDelete: Cascade) 32 | 33 | @@id([userId, tweetId]) 34 | @@index([userId]) 35 | @@index([tweetId]) 36 | } 37 | 38 | // Necessary for Next auth 39 | model Account { 40 | id String @id @default(cuid()) 41 | userId String 42 | type String 43 | provider String 44 | providerAccountId String 45 | refresh_token String? @db.Text 46 | access_token String? @db.Text 47 | expires_at Int? 48 | token_type String? 49 | scope String? 50 | id_token String? @db.Text 51 | session_state String? 52 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 53 | 54 | @@unique([provider, providerAccountId]) 55 | @@index([userId]) 56 | } 57 | 58 | model Session { 59 | id String @id @default(cuid()) 60 | sessionToken String @unique 61 | userId String 62 | expires DateTime 63 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 64 | 65 | @@index([userId]) 66 | } 67 | 68 | model User { 69 | id String @id @default(cuid()) 70 | name String? 71 | email String? @unique 72 | emailVerified DateTime? 73 | image String? 74 | accounts Account[] 75 | sessions Session[] 76 | 77 | // Non-Next Auth 78 | tweets Tweet[] 79 | likes Like[] 80 | followers User[] @relation(name: "Followers") 81 | follows User[] @relation(name: "Followers") 82 | } 83 | 84 | model VerificationToken { 85 | identifier String 86 | token String @unique 87 | expires DateTime 88 | 89 | @@unique([identifier, token]) 90 | } 91 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevSimplified/twitter-clone/d55038a3eccaa7c96285b03b3502b2e053f2e972/public/favicon.ico -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonHTMLAttributes, DetailedHTMLProps } from "react"; 2 | 3 | type ButtonProps = { 4 | small?: boolean; 5 | gray?: boolean; 6 | className?: string; 7 | } & DetailedHTMLProps< 8 | ButtonHTMLAttributes, 9 | HTMLButtonElement 10 | >; 11 | 12 | export function Button({ 13 | small = false, 14 | gray = false, 15 | className = "", 16 | ...props 17 | }: ButtonProps) { 18 | const sizeClasses = small ? "px-2 py-1" : "px-4 py-2 font-bold"; 19 | const colorClasses = gray 20 | ? "bg-gray-400 hover:bg-gray-300 focus-visible:bg-gray-300" 21 | : "bg-blue-500 hover:bg-blue-400 focus-visible:bg-blue-400"; 22 | 23 | return ( 24 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/IconHoverEffect.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | type IconHoverEffectProps = { 4 | children: ReactNode; 5 | red?: boolean; 6 | }; 7 | 8 | export function IconHoverEffect({ 9 | children, 10 | red = false, 11 | }: IconHoverEffectProps) { 12 | const colorClasses = red 13 | ? "outline-red-400 hover:bg-red-200 group-hover-bg-red-200 group-focus-visible:bg-red-200 focus-visible:bg-red-200" 14 | : "outline-gray-400 hover:bg-gray-200 group-hover-bg-gray-200 group-focus-visible:bg-gray-200 focus-visible:bg-gray-200"; 15 | 16 | return ( 17 |
20 | {children} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/InfiniteTweetList.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import Link from "next/link"; 3 | import InfiniteScroll from "react-infinite-scroll-component"; 4 | import { ProfileImage } from "./ProfileImage"; 5 | import { VscHeart, VscHeartFilled } from "react-icons/vsc"; 6 | import { IconHoverEffect } from "./IconHoverEffect"; 7 | import { api } from "~/utils/api"; 8 | import { LoadingSpinner } from "./LoadingSpinner"; 9 | 10 | type Tweet = { 11 | id: string; 12 | content: string; 13 | createdAt: Date; 14 | likeCount: number; 15 | likedByMe: boolean; 16 | user: { id: string; image: string | null; name: string | null }; 17 | }; 18 | 19 | type InfiniteTweetListProps = { 20 | isLoading: boolean; 21 | isError: boolean; 22 | hasMore: boolean | undefined; 23 | fetchNewTweets: () => Promise; 24 | tweets?: Tweet[]; 25 | }; 26 | 27 | export function InfiniteTweetList({ 28 | tweets, 29 | isError, 30 | isLoading, 31 | fetchNewTweets, 32 | hasMore = false, 33 | }: InfiniteTweetListProps) { 34 | if (isLoading) return ; 35 | if (isError) return

Error...

; 36 | 37 | if (tweets == null || tweets.length === 0) { 38 | return ( 39 |

No Tweets

40 | ); 41 | } 42 | 43 | return ( 44 |
    45 | } 50 | > 51 | {tweets.map((tweet) => { 52 | return ; 53 | })} 54 | 55 |
56 | ); 57 | } 58 | 59 | const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { 60 | dateStyle: "short", 61 | }); 62 | 63 | function TweetCard({ 64 | id, 65 | user, 66 | content, 67 | createdAt, 68 | likeCount, 69 | likedByMe, 70 | }: Tweet) { 71 | const trpcUtils = api.useContext(); 72 | const toggleLike = api.tweet.toggleLike.useMutation({ 73 | onSuccess: ({ addedLike }) => { 74 | const updateData: Parameters< 75 | typeof trpcUtils.tweet.infiniteFeed.setInfiniteData 76 | >[1] = (oldData) => { 77 | if (oldData == null) return; 78 | 79 | const countModifier = addedLike ? 1 : -1; 80 | 81 | return { 82 | ...oldData, 83 | pages: oldData.pages.map((page) => { 84 | return { 85 | ...page, 86 | tweets: page.tweets.map((tweet) => { 87 | if (tweet.id === id) { 88 | return { 89 | ...tweet, 90 | likeCount: tweet.likeCount + countModifier, 91 | likedByMe: addedLike, 92 | }; 93 | } 94 | 95 | return tweet; 96 | }), 97 | }; 98 | }), 99 | }; 100 | }; 101 | 102 | trpcUtils.tweet.infiniteFeed.setInfiniteData({}, updateData); 103 | trpcUtils.tweet.infiniteFeed.setInfiniteData( 104 | { onlyFollowing: true }, 105 | updateData 106 | ); 107 | trpcUtils.tweet.infiniteProfileFeed.setInfiniteData( 108 | { userId: user.id }, 109 | updateData 110 | ); 111 | }, 112 | }); 113 | 114 | function handleToggleLike() { 115 | toggleLike.mutate({ id }); 116 | } 117 | 118 | return ( 119 |
  • 120 | 121 | 122 | 123 |
    124 |
    125 | 129 | {user.name} 130 | 131 | - 132 | 133 | {dateTimeFormatter.format(createdAt)} 134 | 135 |
    136 |

    {content}

    137 | 143 |
    144 |
  • 145 | ); 146 | } 147 | 148 | type HeartButtonProps = { 149 | onClick: () => void; 150 | isLoading: boolean; 151 | likedByMe: boolean; 152 | likeCount: number; 153 | }; 154 | 155 | function HeartButton({ 156 | isLoading, 157 | onClick, 158 | likedByMe, 159 | likeCount, 160 | }: HeartButtonProps) { 161 | const session = useSession(); 162 | const HeartIcon = likedByMe ? VscHeartFilled : VscHeart; 163 | 164 | if (session.status !== "authenticated") { 165 | return ( 166 |
    167 | 168 | {likeCount} 169 |
    170 | ); 171 | } 172 | 173 | return ( 174 | 194 | ); 195 | } 196 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { VscRefresh } from "react-icons/vsc"; 2 | 3 | type LoadingSpinnerProps = { 4 | big?: boolean; 5 | }; 6 | 7 | export function LoadingSpinner({ big = false }: LoadingSpinnerProps) { 8 | const sizeClasses = big ? "w-16 h-16" : "w-10 h-10"; 9 | 10 | return ( 11 |
    12 | 13 |
    14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/NewTweetForm.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import { 3 | FormEvent, 4 | useCallback, 5 | useLayoutEffect, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import { api } from "~/utils/api"; 10 | import { Button } from "./Button"; 11 | import { ProfileImage } from "./ProfileImage"; 12 | 13 | function updateTextAreaSize(textArea?: HTMLTextAreaElement) { 14 | if (textArea == null) return; 15 | textArea.style.height = "0"; 16 | textArea.style.height = `${textArea.scrollHeight}px`; 17 | } 18 | 19 | export function NewTweetForm() { 20 | const session = useSession(); 21 | if (session.status !== "authenticated") return null; 22 | 23 | return
    ; 24 | } 25 | 26 | function Form() { 27 | const session = useSession(); 28 | const [inputValue, setInputValue] = useState(""); 29 | const textAreaRef = useRef(); 30 | const inputRef = useCallback((textArea: HTMLTextAreaElement) => { 31 | updateTextAreaSize(textArea); 32 | textAreaRef.current = textArea; 33 | }, []); 34 | const trpcUtils = api.useContext(); 35 | 36 | useLayoutEffect(() => { 37 | updateTextAreaSize(textAreaRef.current); 38 | }, [inputValue]); 39 | 40 | const createTweet = api.tweet.create.useMutation({ 41 | onSuccess: (newTweet) => { 42 | setInputValue(""); 43 | 44 | if (session.status !== "authenticated") return; 45 | 46 | trpcUtils.tweet.infiniteFeed.setInfiniteData({}, (oldData) => { 47 | if (oldData == null || oldData.pages[0] == null) return; 48 | 49 | const newCacheTweet = { 50 | ...newTweet, 51 | likeCount: 0, 52 | likedByMe: false, 53 | user: { 54 | id: session.data.user.id, 55 | name: session.data.user.name || null, 56 | image: session.data.user.image || null, 57 | }, 58 | }; 59 | 60 | return { 61 | ...oldData, 62 | pages: [ 63 | { 64 | ...oldData.pages[0], 65 | tweets: [newCacheTweet, ...oldData.pages[0].tweets], 66 | }, 67 | ...oldData.pages.slice(1), 68 | ], 69 | }; 70 | }); 71 | }, 72 | }); 73 | 74 | if (session.status !== "authenticated") return null; 75 | 76 | function handleSubmit(e: FormEvent) { 77 | e.preventDefault(); 78 | 79 | createTweet.mutate({ content: inputValue }); 80 | } 81 | 82 | return ( 83 | 87 |
    88 | 89 |