├── public
├── favicon.ico
├── images
│ └── placeholder.png
├── vercel.svg
├── thirteen.svg
└── next.svg
├── postcss.config.js
├── next.config.js
├── libs
├── fetcher.ts
├── prismadb.ts
└── serverAuth.ts
├── styles
└── globals.css
├── pages
├── search.tsx
├── index.tsx
├── api
│ ├── current.ts
│ ├── users
│ │ ├── index.ts
│ │ └── [userId].ts
│ ├── register.ts
│ ├── posts
│ │ ├── [postId].ts
│ │ └── index.ts
│ ├── notifications
│ │ └── [userId].ts
│ ├── edit.ts
│ ├── auth
│ │ └── [...nextauth].ts
│ ├── comments.ts
│ ├── follow.ts
│ └── like.ts
├── _app.tsx
├── notifications.tsx
├── users
│ └── [userId].tsx
└── posts
│ └── [postId].tsx
├── tailwind.config.js
├── hooks
├── useUsers.ts
├── useCurrentUser.ts
├── usePost.ts
├── useUser.ts
├── useEditModal.ts
├── useLoginModal.ts
├── usePosts.ts
├── useRegisterModal.ts
├── useNotifications.ts
├── useFollow.ts
└── useLike.ts
├── Dockerfile
├── components
├── posts
│ ├── CommentFeed.tsx
│ ├── PostFeed.tsx
│ ├── CommentItem.tsx
│ └── PostItem.tsx
├── layout
│ ├── SidebarLogo.tsx
│ ├── FollowBar.tsx
│ ├── SidebarTweetButton.tsx
│ ├── Sidebar.tsx
│ └── SidebarItem.tsx
├── users
│ ├── UserHero.tsx
│ └── UserBio.tsx
├── Layout.tsx
├── Header.tsx
├── Input.tsx
├── NotificationsFeed.tsx
├── Button.tsx
├── Avatar.tsx
├── ImageUpload.tsx
├── modals
│ ├── LoginModal.tsx
│ ├── EditModal.tsx
│ └── RegisterModal.tsx
├── Modal.tsx
└── Form.tsx
├── tsconfig.json
├── package.json
├── README.md
├── docker-compose.yml-og
├── docker-compose.yml
└── prisma
└── schema.prisma
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mandeepsingh10/chwitter/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mandeepsingh10/chwitter/HEAD/public/images/placeholder.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | }
5 |
6 | module.exports = nextConfig
7 |
--------------------------------------------------------------------------------
/libs/fetcher.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const fetcher = (url: string) => axios.get(url).then((res) => res.data);
4 |
5 | export default fetcher;
6 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | @apply h-full bg-black
7 | }
8 |
9 | body {
10 | @apply h-full bg-black
11 | }
12 |
--------------------------------------------------------------------------------
/pages/search.tsx:
--------------------------------------------------------------------------------
1 | import Header from "@/components/Header";
2 |
3 | const Search = () => {
4 | return (
5 | <>
6 |
7 | >
8 | );
9 | }
10 |
11 | export default Search;
--------------------------------------------------------------------------------
/libs/prismadb.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client"
2 |
3 | declare global {
4 | var prisma: PrismaClient | undefined
5 | }
6 |
7 | const client = globalThis.prisma || new PrismaClient()
8 | if (process.env.NODE_ENV !== "production") globalThis.prisma = client
9 |
10 | export default client
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./app/**/*.{js,ts,jsx,tsx}",
5 | "./pages/**/*.{js,ts,jsx,tsx}",
6 | "./components/**/*.{js,ts,jsx,tsx}",
7 | ],
8 | theme: {
9 | extend: {},
10 | },
11 | plugins: [],
12 | }
13 |
--------------------------------------------------------------------------------
/hooks/useUsers.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | import fetcher from '@/libs/fetcher';
4 |
5 | const useUsers = () => {
6 | const { data, error, isLoading, mutate } = useSWR('/api/users', fetcher);
7 |
8 | return {
9 | data,
10 | error,
11 | isLoading,
12 | mutate
13 | }
14 | };
15 |
16 | export default useUsers;
17 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import PostFeed from "@/components/posts/PostFeed"
2 | import Header from "@/components/Header"
3 | import Form from "@/components/Form"
4 |
5 | export default function Home() {
6 | return (
7 | <>
8 |
9 |
10 |
11 | >
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/hooks/useCurrentUser.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | import fetcher from '@/libs/fetcher';
4 |
5 | const useCurrentUser = () => {
6 | const { data, error, isLoading, mutate } = useSWR('/api/current', fetcher);
7 |
8 | return {
9 | data,
10 | error,
11 | isLoading,
12 | mutate
13 | }
14 | };
15 |
16 | export default useCurrentUser;
17 |
--------------------------------------------------------------------------------
/hooks/usePost.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | import fetcher from '@/libs/fetcher';
4 |
5 | const usePost = (postId: string) => {
6 | const { data, error, isLoading, mutate } = useSWR(postId ? `/api/posts/${postId}` : null, fetcher);
7 |
8 | return {
9 | data,
10 | error,
11 | isLoading,
12 | mutate
13 | }
14 | };
15 |
16 | export default usePost;
17 |
--------------------------------------------------------------------------------
/hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | import fetcher from '@/libs/fetcher';
4 |
5 | const useUser = (userId: string) => {
6 | const { data, error, isLoading, mutate } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
7 |
8 | return {
9 | data,
10 | error,
11 | isLoading,
12 | mutate
13 | }
14 | };
15 |
16 | export default useUser;
17 |
--------------------------------------------------------------------------------
/hooks/useEditModal.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | interface EditModalStore {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | }
8 |
9 | const useEditModal = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false })
13 | }));
14 |
15 |
16 | export default useEditModal;
17 |
--------------------------------------------------------------------------------
/hooks/useLoginModal.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | interface LoginModalStore {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | }
8 |
9 | const useLoginModal = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false })
13 | }));
14 |
15 |
16 | export default useLoginModal;
17 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14.21
2 |
3 | WORKDIR /usr/src/app
4 |
5 | ENV DATABASE_URL "mongodb://root:prisma@mongodb-primary:27017/test?authSource=admin&retryWrites=false"
6 |
7 | ENV NEXTAUTH_SECRET "secret"
8 |
9 | ENV NEXTAUTH_JWT_SECRET "secret"
10 |
11 | COPY package*.json ./
12 |
13 | RUN npm install \
14 | npm install prisma
15 | COPY . .
16 |
17 | RUN npx prisma generate
18 |
19 | CMD ["npm", "run", "dev"]
20 |
--------------------------------------------------------------------------------
/hooks/usePosts.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | import fetcher from '@/libs/fetcher';
4 |
5 | const usePosts = (userId?: string) => {
6 | const url = userId ? `/api/posts?userId=${userId}` : '/api/posts';
7 | const { data, error, isLoading, mutate } = useSWR(url, fetcher);
8 |
9 | return {
10 | data,
11 | error,
12 | isLoading,
13 | mutate
14 | }
15 | };
16 |
17 | export default usePosts;
18 |
--------------------------------------------------------------------------------
/hooks/useRegisterModal.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | interface RegisterModalStore {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | }
8 |
9 | const useRegisterModal = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false })
13 | }));
14 |
15 |
16 | export default useRegisterModal;
17 |
--------------------------------------------------------------------------------
/hooks/useNotifications.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | import fetcher from '@/libs/fetcher';
4 |
5 | const useNotifications = (userId?: string) => {
6 | const url = userId ? `/api/notifications/${userId}` : null;
7 | const { data, error, isLoading, mutate } = useSWR(url, fetcher);
8 |
9 | return {
10 | data,
11 | error,
12 | isLoading,
13 | mutate
14 | }
15 | };
16 |
17 | export default useNotifications;
18 |
--------------------------------------------------------------------------------
/components/posts/CommentFeed.tsx:
--------------------------------------------------------------------------------
1 | import CommentItem from './CommentItem';
2 |
3 | interface CommentFeedProps {
4 | comments?: Record[];
5 | }
6 |
7 | const CommentFeed: React.FC = ({ comments = [] }) => {
8 | return (
9 | <>
10 | {comments.map((comment: Record,) => (
11 |
12 | ))}
13 | >
14 | );
15 | };
16 |
17 | export default CommentFeed;
18 |
--------------------------------------------------------------------------------
/components/posts/PostFeed.tsx:
--------------------------------------------------------------------------------
1 | import usePosts from '@/hooks/usePosts';
2 |
3 | import PostItem from './PostItem';
4 |
5 | interface PostFeedProps {
6 | userId?: string;
7 | }
8 |
9 | const PostFeed: React.FC = ({ userId }) => {
10 | const { data: posts = [] } = usePosts(userId);
11 |
12 | return (
13 | <>
14 | {posts.map((post: Record,) => (
15 |
16 | ))}
17 | >
18 | );
19 | };
20 |
21 | export default PostFeed;
22 |
--------------------------------------------------------------------------------
/pages/api/current.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | import serverAuth from '@/libs/serverAuth';
4 |
5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method !== 'GET') {
7 | return res.status(405).end();
8 | }
9 |
10 | try {
11 | const { currentUser } = await serverAuth(req);
12 |
13 | return res.status(200).json(currentUser);
14 | } catch (error) {
15 | console.log(error);
16 | return res.status(400).end();
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/pages/api/users/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import prisma from '@/libs/prismadb';
4 |
5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method !== 'GET') {
7 | return res.status(405).end();
8 | }
9 |
10 | try {
11 | const users = await prisma.user.findMany({
12 | orderBy: {
13 | createdAt: 'desc'
14 | }
15 | });
16 |
17 | return res.status(200).json(users);
18 | } catch(error) {
19 | console.log(error);
20 | return res.status(400).end();
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/libs/serverAuth.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest } from 'next';
2 | import { getSession } from 'next-auth/react';
3 |
4 | import prisma from '@/libs/prismadb';
5 |
6 | const serverAuth = async (req: NextApiRequest) => {
7 | const session = await getSession({ req });
8 |
9 | if (!session?.user?.email) {
10 | throw new Error('Not signed in');
11 | }
12 |
13 | const currentUser = await prisma.user.findUnique({
14 | where: {
15 | email: session.user.email,
16 | }
17 | });
18 |
19 | if (!currentUser) {
20 | throw new Error('Not signed in');
21 | }
22 |
23 | return { currentUser };
24 | };
25 |
26 | export default serverAuth;
27 |
--------------------------------------------------------------------------------
/components/layout/SidebarLogo.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { BsTwitter } from "react-icons/bs";
3 |
4 | const SidebarLogo = () => {
5 | const router = useRouter();
6 |
7 | return (
8 | router.push('/')}
10 | className="
11 | rounded-full
12 | h-14
13 | w-14
14 | p-4
15 | flex
16 | items-center
17 | justify-center
18 | hover:bg-blue-300
19 | hover:bg-opacity-10
20 | cursor-pointer
21 | ">
22 |
23 |
24 | );
25 | };
26 |
27 | export default SidebarLogo;
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@/*": ["./*"]
20 | }
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import type { AppProps } from 'next/app'
2 | import { Toaster } from 'react-hot-toast';
3 | import { SessionProvider } from 'next-auth/react';
4 |
5 | import Layout from '@/components/Layout'
6 | import LoginModal from '@/components/modals/LoginModal'
7 | import RegisterModal from '@/components/modals/RegisterModal'
8 | import '@/styles/globals.css'
9 | import EditModal from '@/components/modals/EditModal';
10 |
11 | export default function App({ Component, pageProps }: AppProps) {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/users/UserHero.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import useUser from "@/hooks/useUser";
4 |
5 | import Avatar from "../Avatar"
6 |
7 | interface UserHeroProps {
8 | userId: string;
9 | }
10 |
11 | const UserHero: React.FC = ({ userId }) => {
12 | const { data: fetchedUser } = useUser(userId);
13 |
14 | return (
15 |
16 |
17 | {fetchedUser?.coverImage && (
18 |
19 | )}
20 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default UserHero;
--------------------------------------------------------------------------------
/pages/api/register.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcrypt';
2 | import { NextApiRequest, NextApiResponse } from "next";
3 |
4 | import prisma from '@/libs/prismadb';
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | if (req.method !== 'POST') {
8 | return res.status(405).end();
9 | }
10 |
11 | try {
12 | const { email, username, name, password } = req.body;
13 |
14 | const hashedPassword = await bcrypt.hash(password, 12);
15 |
16 | const user = await prisma.user.create({
17 | data: {
18 | email,
19 | username,
20 | name,
21 | hashedPassword,
22 | }
23 | });
24 |
25 | return res.status(200).json(user);
26 | } catch (error) {
27 | console.log(error);
28 | return res.status(400).end();
29 | }
30 | }
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import FollowBar from "@/components/layout/FollowBar"
4 | import Sidebar from "@/components/layout/Sidebar"
5 |
6 | const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
7 | return (
8 |
9 |
10 |
11 |
12 |
19 | {children}
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | export default Layout;
29 |
--------------------------------------------------------------------------------
/pages/notifications.tsx:
--------------------------------------------------------------------------------
1 | import Header from "@/components/Header";
2 | import NotificationsFeed from "@/components/NotificationsFeed";
3 | import useCurrentUser from "@/hooks/useCurrentUser";
4 | import { NextPageContext } from "next";
5 | import { getSession } from "next-auth/react";
6 |
7 | export async function getServerSideProps(context: NextPageContext) {
8 | const session = await getSession(context);
9 |
10 | if (!session) {
11 | return {
12 | redirect: {
13 | destination: '/',
14 | permanent: false,
15 | }
16 | }
17 | }
18 |
19 | return {
20 | props: {
21 | session
22 | }
23 | }
24 | }
25 |
26 | const Notifications = () => {
27 | return (
28 | <>
29 |
30 |
31 | >
32 | );
33 | }
34 |
35 | export default Notifications;
--------------------------------------------------------------------------------
/pages/api/users/[userId].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import prisma from '@/libs/prismadb';
4 |
5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method !== 'GET') {
7 | return res.status(405).end();
8 | }
9 |
10 | try {
11 | const { userId } = req.query;
12 |
13 | if (!userId || typeof userId !== 'string') {
14 | throw new Error('Invalid ID');
15 | }
16 |
17 | const existingUser = await prisma.user.findUnique({
18 | where: {
19 | id: userId
20 | }
21 | });
22 |
23 | const followersCount = await prisma.user.count({
24 | where: {
25 | followingIds: {
26 | has: userId
27 | }
28 | }
29 | })
30 |
31 | return res.status(200).json({ ...existingUser, followersCount });
32 | } catch (error) {
33 | console.log(error);
34 | return res.status(400).end();
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/pages/api/posts/[postId].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import prisma from "@/libs/prismadb";
4 |
5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method !== 'GET') {
7 | return res.status(405).end();
8 | }
9 |
10 | try {
11 | const { postId } = req.query;
12 |
13 | if (!postId || typeof postId !== 'string') {
14 | throw new Error('Invalid ID');
15 | }
16 |
17 | const post = await prisma.post.findUnique({
18 | where: {
19 | id: postId,
20 | },
21 | include: {
22 | user: true,
23 | comments: {
24 | include: {
25 | user: true
26 | },
27 | orderBy: {
28 | createdAt: 'desc'
29 | }
30 | },
31 | },
32 | });
33 |
34 | return res.status(200).json(post);
35 | } catch (error) {
36 | console.log(error);
37 | return res.status(400).end();
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pages/api/notifications/[userId].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import prisma from '@/libs/prismadb';
4 |
5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method !== 'GET') {
7 | return res.status(405).end();
8 | }
9 |
10 | try {
11 | const { userId } = req.query;
12 |
13 | if (!userId || typeof userId !== 'string') {
14 | throw new Error('Invalid ID');
15 | }
16 |
17 | const notifications = await prisma.notification.findMany({
18 | where: {
19 | userId,
20 | },
21 | orderBy: {
22 | createdAt: 'desc'
23 | }
24 | });
25 |
26 | await prisma.user.update({
27 | where: {
28 | id: userId
29 | },
30 | data: {
31 | hasNotification: false,
32 | }
33 | });
34 |
35 | return res.status(200).json(notifications);
36 | } catch (error) {
37 | console.log(error);
38 | return res.status(400).end();
39 | }
40 | }
--------------------------------------------------------------------------------
/pages/api/edit.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import serverAuth from "@/libs/serverAuth";
4 | import prisma from "@/libs/prismadb";
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | if (req.method !== 'PATCH') {
8 | return res.status(405).end();
9 | }
10 |
11 | try {
12 | const { currentUser } = await serverAuth(req);
13 |
14 | const { name, username, bio, profileImage, coverImage } = req.body;
15 |
16 | if (!name || !username) {
17 | throw new Error('Missing fields');
18 | }
19 |
20 | const updatedUser = await prisma.user.update({
21 | where: {
22 | id: currentUser.id,
23 | },
24 | data: {
25 | name,
26 | username,
27 | bio,
28 | profileImage,
29 | coverImage
30 | }
31 | });
32 |
33 | return res.status(200).json(updatedUser);
34 | } catch (error) {
35 | console.log(error);
36 | return res.status(400).end();
37 | }
38 | }
--------------------------------------------------------------------------------
/components/layout/FollowBar.tsx:
--------------------------------------------------------------------------------
1 | import useUsers from '@/hooks/useUsers';
2 |
3 | import Avatar from '../Avatar';
4 |
5 | const FollowBar = () => {
6 | const { data: users = [] } = useUsers();
7 |
8 | if (users.length === 0) {
9 | return null;
10 | }
11 |
12 | return (
13 |
14 |
15 |
Who to follow
16 |
17 | {users.map((user: Record
) => (
18 |
19 |
20 |
21 |
{user.name}
22 |
@{user.username}
23 |
24 |
25 | ))}
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default FollowBar;
33 |
--------------------------------------------------------------------------------
/pages/users/[userId].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { ClipLoader } from "react-spinners";
3 |
4 | import useUser from "@/hooks/useUser";
5 |
6 | import PostFeed from "@/components/posts/PostFeed";
7 | import Header from "@/components/Header";
8 | import UserBio from "@/components/users/UserBio";
9 | import UserHero from "@/components/users/UserHero";
10 |
11 |
12 |
13 | const UserView = () => {
14 | const router = useRouter();
15 | const { userId } = router.query;
16 |
17 | const { data: fetchedUser, isLoading } = useUser(userId as string);
18 |
19 | if (isLoading || !fetchedUser) {
20 | return (
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | return (
28 | <>
29 |
30 |
31 |
32 |
33 | >
34 | );
35 | }
36 |
37 | export default UserView;
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { useCallback } from "react";
3 | import { BiArrowBack } from "react-icons/bi";
4 |
5 | interface HeaderProps {
6 | showBackArrow?: boolean;
7 | label: string;
8 | }
9 |
10 | const Header: React.FC = ({showBackArrow, label }) => {
11 | const router = useRouter();
12 |
13 | const handleBack = useCallback(() => {
14 | router.back();
15 | }, [router]);
16 |
17 | return (
18 |
19 |
20 | {showBackArrow && (
21 |
30 | )}
31 |
32 | {label}
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | export default Header;
40 |
--------------------------------------------------------------------------------
/pages/posts/[postId].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { ClipLoader } from "react-spinners";
3 |
4 | import usePost from "@/hooks/usePost";
5 |
6 | import Header from "@/components/Header";
7 | import Form from "@/components/Form";
8 | import PostItem from "@/components/posts/PostItem";
9 | import CommentFeed from "@/components/posts/CommentFeed";
10 |
11 |
12 | const PostView = () => {
13 | const router = useRouter();
14 | const { postId } = router.query;
15 |
16 | const { data: fetchedPost, isLoading } = usePost(postId as string);
17 |
18 | if (isLoading || !fetchedPost) {
19 | return (
20 |
21 |
22 |
23 | )
24 | }
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
32 | >
33 | );
34 | }
35 |
36 | export default PostView;
--------------------------------------------------------------------------------
/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/Input.tsx:
--------------------------------------------------------------------------------
1 | interface InputProps {
2 | placeholder?: string;
3 | value?: string;
4 | type?: string;
5 | disabled?: boolean;
6 | onChange: (event: React.ChangeEvent) => void;
7 | label?: string;
8 | }
9 |
10 | const Input: React.FC = ({ placeholder, value, type = "text", onChange, disabled, label }) => {
11 | return (
12 |
13 | {label &&
{label}
}
14 |
38 |
39 | );
40 | }
41 |
42 | export default Input;
--------------------------------------------------------------------------------
/components/NotificationsFeed.tsx:
--------------------------------------------------------------------------------
1 | import { BsTwitter } from "react-icons/bs";
2 |
3 | import useNotifications from "@/hooks/useNotifications";
4 | import useCurrentUser from "@/hooks/useCurrentUser";
5 | import { useEffect } from "react";
6 |
7 | const NotificationsFeed = () => {
8 | const { data: currentUser, mutate: mutateCurrentUser } = useCurrentUser();
9 | const { data: fetchedNotifications = [] } = useNotifications(currentUser?.id);
10 |
11 | useEffect(() => {
12 | mutateCurrentUser();
13 | }, [mutateCurrentUser]);
14 |
15 | if (fetchedNotifications.length === 0) {
16 | return (
17 |
18 | No notifications
19 |
20 | )
21 | }
22 |
23 | return (
24 |
25 | {fetchedNotifications.map((notification: Record
) => (
26 |
27 |
28 |
29 | {notification.body}
30 |
31 |
32 | ))}
33 |
34 | );
35 | }
36 |
37 | export default NotificationsFeed;
--------------------------------------------------------------------------------
/components/Button.tsx:
--------------------------------------------------------------------------------
1 | interface ButtonProps {
2 | label: string;
3 | secondary?: boolean;
4 | fullWidth?: boolean;
5 | large?: boolean;
6 | onClick: () => void;
7 | disabled?: boolean;
8 | outline?: boolean;
9 | }
10 |
11 | const Button: React.FC = ({
12 | label,
13 | secondary,
14 | fullWidth,
15 | onClick,
16 | large,
17 | disabled,
18 | outline
19 | }) => {
20 | return (
21 |
46 | );
47 | }
48 |
49 | export default Button;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitter-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 | },
11 | "prisma": {
12 | "schema": "prisma/schema.prisma"
13 | },
14 | "dependencies": {
15 | "@next-auth/prisma-adapter": "^1.0.5",
16 | "@prisma/client": "^4.11.0",
17 | "@types/lodash": "^4.14.191",
18 | "@types/node": "18.14.2",
19 | "@types/react": "18.0.28",
20 | "@types/react-dom": "18.0.11",
21 | "axios": "^1.3.4",
22 | "bcrypt": "^5.1.0",
23 | "date-fns": "^2.29.3",
24 | "eslint": "8.35.0",
25 | "eslint-config-next": "13.2.1",
26 | "lodash": "^4.17.21",
27 | "next": "13.2.1",
28 | "next-auth": "^4.20.1",
29 | "react": "18.2.0",
30 | "react-dom": "18.2.0",
31 | "react-dropzone": "^14.2.3",
32 | "react-hot-toast": "^2.4.0",
33 | "react-icons": "^4.7.1",
34 | "react-spinners": "^0.13.8",
35 | "react-toastify": "^9.1.1",
36 | "swr": "^2.0.4",
37 | "typescript": "4.9.5",
38 | "zustand": "^4.3.5"
39 | },
40 | "devDependencies": {
41 | "@types/bcrypt": "^5.0.0",
42 | "autoprefixer": "^10.4.13",
43 | "postcss": "^8.4.21",
44 | "tailwindcss": "^3.2.7"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { useRouter } from "next/router";
3 | import { useCallback } from "react";
4 |
5 | import useUser from "@/hooks/useUser";
6 |
7 | interface AvatarProps {
8 | userId: string;
9 | isLarge?: boolean;
10 | hasBorder?: boolean;
11 | }
12 |
13 | const Avatar: React.FC = ({ userId, isLarge, hasBorder }) => {
14 | const router = useRouter();
15 |
16 | const { data: fetchedUser } = useUser(userId);
17 |
18 | const onClick = useCallback((event: any) => {
19 | event.stopPropagation();
20 |
21 | const url = `/users/${userId}`;
22 |
23 | router.push(url);
24 | }, [router, userId]);
25 |
26 | return (
27 |
39 |
49 |
50 | );
51 | }
52 |
53 | export default Avatar;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## The original Full stack application is developed by https://github.com/AntonioErdeljac/twitter-clone. All the credit for the application development goes to him and I do not intend to take any credit for it.
2 |
3 | ## What I have done is Dockerized this app, which was a complicated task because it uses prisma with MongoDB, we need MongoDB replica sets to establish the connectivity between prisma and MongoDB.
4 |
5 | ## For the original app Antonio uses MongoDB Atlas and Vercel to deploy the app, but I wanted to dockerize it so that we can run it on our local machine and to experience what itactually takes to create a containerized app. It took some effort but it's done.
6 |
7 | ## Please feel free to check out the Dockerized version.
8 |
9 |
10 |
11 | # Build and Deploy: TWITTER clone - Chwitter with React, Tailwind, Next, Prisma, Mongo, NextAuth & Docker
12 |
13 |
14 | 
15 |
16 |
17 | This is a repository for a FullStack Twitter clone tutorial using React, NextJS, TailwindCSS & Prisma, MondoDB and Docker.
18 |
19 |
20 |
21 |
22 | ```
23 |
24 | ### Start the app
25 |
26 | ```shell
27 | git clone https://github.com/mandeepsingh10/chwitter.git
28 | cd chwitter
29 | docker-compose up
30 | go to localhost:3000 to check the app.
31 | ```
32 |
--------------------------------------------------------------------------------
/hooks/useFollow.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useCallback, useMemo } from "react";
3 | import { toast } from "react-hot-toast";
4 |
5 | import useCurrentUser from "./useCurrentUser";
6 | import useLoginModal from "./useLoginModal";
7 | import useUser from "./useUser";
8 |
9 | const useFollow = (userId: string) => {
10 | const { data: currentUser, mutate: mutateCurrentUser } = useCurrentUser();
11 | const { mutate: mutateFetchedUser } = useUser(userId);
12 |
13 | const loginModal = useLoginModal();
14 |
15 | const isFollowing = useMemo(() => {
16 | const list = currentUser?.followingIds || [];
17 |
18 | return list.includes(userId);
19 | }, [currentUser, userId]);
20 |
21 | const toggleFollow = useCallback(async () => {
22 | if (!currentUser) {
23 | return loginModal.onOpen();
24 | }
25 |
26 | try {
27 | let request;
28 |
29 | if (isFollowing) {
30 | request = () => axios.delete('/api/follow', { data: { userId } });
31 | } else {
32 | request = () => axios.post('/api/follow', { userId });
33 | }
34 |
35 | await request();
36 | mutateCurrentUser();
37 | mutateFetchedUser();
38 |
39 | toast.success('Success');
40 | } catch (error) {
41 | toast.error('Something went wrong');
42 | }
43 | }, [currentUser, isFollowing, userId, mutateCurrentUser, mutateFetchedUser, loginModal]);
44 |
45 | return {
46 | isFollowing,
47 | toggleFollow,
48 | }
49 | }
50 |
51 | export default useFollow;
52 |
--------------------------------------------------------------------------------
/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import bcrypt from "bcrypt"
2 | import NextAuth from "next-auth"
3 | import CredentialsProvider from "next-auth/providers/credentials"
4 | import { PrismaAdapter } from "@next-auth/prisma-adapter"
5 |
6 | import prisma from "@/libs/prismadb"
7 |
8 | export default NextAuth({
9 | adapter: PrismaAdapter(prisma),
10 | providers: [
11 | CredentialsProvider({
12 | name: 'credentials',
13 | credentials: {
14 | email: { label: 'email', type: 'text' },
15 | password: { label: 'password', type: 'password' }
16 | },
17 | async authorize(credentials) {
18 | if (!credentials?.email || !credentials?.password) {
19 | throw new Error('Invalid credentials');
20 | }
21 |
22 | const user = await prisma.user.findUnique({
23 | where: {
24 | email: credentials.email
25 | }
26 | });
27 |
28 | if (!user || !user?.hashedPassword) {
29 | throw new Error('Invalid credentials');
30 | }
31 |
32 | const isCorrectPassword = await bcrypt.compare(
33 | credentials.password,
34 | user.hashedPassword
35 | );
36 |
37 | if (!isCorrectPassword) {
38 | throw new Error('Invalid credentials');
39 | }
40 |
41 | return user;
42 | }
43 | })
44 | ],
45 | debug: process.env.NODE_ENV === 'development',
46 | session: {
47 | strategy: 'jwt',
48 | },
49 | jwt: {
50 | secret: process.env.NEXTAUTH_JWT_SECRET,
51 | },
52 | secret: process.env.NEXTAUTH_SECRET,
53 | });
54 |
--------------------------------------------------------------------------------
/hooks/useLike.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useCallback, useMemo } from "react";
3 | import { toast } from "react-hot-toast";
4 |
5 | import useCurrentUser from "./useCurrentUser";
6 | import useLoginModal from "./useLoginModal";
7 | import usePost from "./usePost";
8 | import usePosts from "./usePosts";
9 |
10 | const useLike = ({ postId, userId }: { postId: string, userId?: string }) => {
11 | const { data: currentUser } = useCurrentUser();
12 | const { data: fetchedPost, mutate: mutateFetchedPost } = usePost(postId);
13 | const { mutate: mutateFetchedPosts } = usePosts(userId);
14 |
15 | const loginModal = useLoginModal();
16 |
17 | const hasLiked = useMemo(() => {
18 | const list = fetchedPost?.likedIds || [];
19 |
20 | return list.includes(currentUser?.id);
21 | }, [fetchedPost, currentUser]);
22 |
23 | const toggleLike = useCallback(async () => {
24 | if (!currentUser) {
25 | return loginModal.onOpen();
26 | }
27 |
28 | try {
29 | let request;
30 |
31 | if (hasLiked) {
32 | request = () => axios.delete('/api/like', { data: { postId } });
33 | } else {
34 | request = () => axios.post('/api/like', { postId });
35 | }
36 |
37 | await request();
38 | mutateFetchedPost();
39 | mutateFetchedPosts();
40 |
41 | toast.success('Success');
42 | } catch (error) {
43 | toast.error('Something went wrong');
44 | }
45 | }, [currentUser, hasLiked, postId, mutateFetchedPosts, mutateFetchedPost, loginModal]);
46 |
47 | return {
48 | hasLiked,
49 | toggleLike,
50 | }
51 | }
52 |
53 | export default useLike;
54 |
--------------------------------------------------------------------------------
/pages/api/comments.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import serverAuth from "@/libs/serverAuth";
4 | import prisma from "@/libs/prismadb";
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | if (req.method !== 'POST') {
8 | return res.status(405).end();
9 | }
10 |
11 | try {
12 | const { currentUser } = await serverAuth(req);
13 | const { body } = req.body;
14 | const { postId } = req.query;
15 |
16 | if (!postId || typeof postId !== 'string') {
17 | throw new Error('Invalid ID');
18 | }
19 |
20 | const comment = await prisma.comment.create({
21 | data: {
22 | body,
23 | userId: currentUser.id,
24 | postId
25 | }
26 | });
27 |
28 | // NOTIFICATION PART START
29 | try {
30 | const post = await prisma.post.findUnique({
31 | where: {
32 | id: postId,
33 | }
34 | });
35 |
36 | if (post?.userId) {
37 | await prisma.notification.create({
38 | data: {
39 | body: 'Someone replied on your tweet!',
40 | userId: post.userId
41 | }
42 | });
43 |
44 | await prisma.user.update({
45 | where: {
46 | id: post.userId
47 | },
48 | data: {
49 | hasNotification: true
50 | }
51 | });
52 | }
53 | }
54 | catch (error) {
55 | console.log(error);
56 | }
57 | // NOTIFICATION PART END
58 |
59 | return res.status(200).json(comment);
60 | } catch (error) {
61 | console.log(error);
62 | return res.status(400).end();
63 | }
64 | }
--------------------------------------------------------------------------------
/docker-compose.yml-og:
--------------------------------------------------------------------------------
1 | version: '2'
2 | services:
3 | twitter:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | container_name: twitter-app
8 | restart: always
9 | ports:
10 | - "3000:3000"
11 |
12 | mongodb-primary:
13 | image: 'bitnami/mongodb:latest'
14 | environment:
15 | - MONGODB_ADVERTISED_HOSTNAME=mongodb-primary
16 | - MONGODB_REPLICA_SET_MODE=primary
17 | - MONGODB_ROOT_PASSWORD=prisma
18 | - MONGODB_REPLICA_SET_KEY=replicasetkey123
19 | ports:
20 | - 27017:27017
21 |
22 | volumes:
23 | - 'mongodb_master_data:/bitnami'
24 |
25 | mongodb-secondary:
26 | image: 'bitnami/mongodb:latest'
27 | depends_on:
28 | - mongodb-primary
29 | environment:
30 | - MONGODB_ADVERTISED_HOSTNAME=mongodb-secondary
31 | - MONGODB_REPLICA_SET_MODE=secondary
32 | - MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary
33 | - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
34 | - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=prisma
35 | - MONGODB_REPLICA_SET_KEY=replicasetkey123
36 | ports:
37 | - 27027:27017
38 |
39 | mongodb-arbiter:
40 | image: 'bitnami/mongodb:latest'
41 | depends_on:
42 | - mongodb-primary
43 | environment:
44 | - MONGODB_ADVERTISED_HOSTNAME=mongodb-arbiter
45 | - MONGODB_REPLICA_SET_MODE=arbiter
46 | - MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary
47 | - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
48 | - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=prisma
49 | - MONGODB_REPLICA_SET_KEY=replicasetkey123
50 | ports:
51 | - 27037:27017
52 |
53 | volumes:
54 | mongodb_master_data:
55 | driver: local
56 |
57 |
--------------------------------------------------------------------------------
/pages/api/posts/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import serverAuth from "@/libs/serverAuth";
4 | import prisma from "@/libs/prismadb";
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | if (req.method !== 'POST' && req.method !== 'GET') {
8 | return res.status(405).end();
9 | }
10 |
11 | try {
12 |
13 | if (req.method === 'POST') {
14 | const { currentUser } = await serverAuth(req);
15 | const { body } = req.body;
16 |
17 | const post = await prisma.post.create({
18 | data: {
19 | body,
20 | userId: currentUser.id
21 | }
22 | });
23 |
24 | return res.status(200).json(post);
25 | }
26 |
27 | if (req.method === 'GET') {
28 | const { userId } = req.query;
29 |
30 | console.log({ userId })
31 |
32 | let posts;
33 |
34 | if (userId && typeof userId === 'string') {
35 | posts = await prisma.post.findMany({
36 | where: {
37 | userId
38 | },
39 | include: {
40 | user: true,
41 | comments: true
42 | },
43 | orderBy: {
44 | createdAt: 'desc'
45 | },
46 | });
47 | } else {
48 | posts = await prisma.post.findMany({
49 | include: {
50 | user: true,
51 | comments: true
52 | },
53 | orderBy: {
54 | createdAt: 'desc'
55 | }
56 | });
57 | }
58 |
59 | return res.status(200).json(posts);
60 | }
61 | } catch (error) {
62 | console.log(error);
63 | return res.status(400).end();
64 | }
65 | }
--------------------------------------------------------------------------------
/components/layout/SidebarTweetButton.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { FaFeather } from "react-icons/fa";
3 | import { useRouter } from "next/router";
4 |
5 | import useLoginModal from "@/hooks/useLoginModal";
6 | import useCurrentUser from "@/hooks/useCurrentUser";
7 |
8 | const SidebarTweetButton = () => {
9 | const router = useRouter();
10 | const loginModal = useLoginModal();
11 | const { data: currentUser } = useCurrentUser();
12 |
13 | const onClick = useCallback(() => {
14 | if (!currentUser) {
15 | return loginModal.onOpen();
16 | }
17 |
18 | router.push('/');
19 | }, [loginModal, router, currentUser]);
20 |
21 | return (
22 |
23 |
38 |
39 |
40 |
51 |
60 | Tweet
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default SidebarTweetButton;
68 |
--------------------------------------------------------------------------------
/components/ImageUpload.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { useCallback, useState } from "react";
3 | import { useDropzone } from "react-dropzone";
4 |
5 | interface DropzoneProps {
6 | onChange: (base64: string) => void;
7 | label: string;
8 | value?: string;
9 | disabled?: boolean;
10 | }
11 |
12 | const ImageUpload: React.FC = ({ onChange, label, value, disabled }) => {
13 | const [base64, setBase64] = useState(value);
14 |
15 | const handleChange = useCallback((base64: string) => {
16 | onChange(base64);
17 | }, [onChange]);
18 |
19 | const handleDrop = useCallback((files: any) => {
20 | const file = files[0]
21 | const reader = new FileReader();
22 | reader.onload = (event: any) => {
23 | setBase64(event.target.result);
24 | handleChange(event.target.result);
25 | };
26 | reader.readAsDataURL(file);
27 | }, [handleChange])
28 |
29 | const { getRootProps, getInputProps } = useDropzone({
30 | maxFiles: 1,
31 | onDrop: handleDrop,
32 | disabled,
33 | accept: {
34 | 'image/jpeg': [],
35 | 'image/png': [],
36 | }
37 | });
38 |
39 | return (
40 |
41 |
42 | {base64 ? (
43 |
44 |
50 |
51 | ) : (
52 |
{label}
53 | )}
54 |
55 | );
56 | }
57 |
58 | export default ImageUpload;
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2'
2 | services:
3 | mongodb-primary:
4 | image: 'bitnami/mongodb:latest'
5 | environment:
6 | - MONGODB_ADVERTISED_HOSTNAME=mongodb-primary
7 | - MONGODB_REPLICA_SET_MODE=primary
8 | - MONGODB_ROOT_PASSWORD=prisma
9 | - MONGODB_REPLICA_SET_KEY=replicasetkey123
10 | ports:
11 | - 27017:27017
12 |
13 | volumes:
14 | - 'mongodb_master_data:/bitnami'
15 |
16 | mongodb-secondary:
17 | image: 'bitnami/mongodb:latest'
18 | depends_on:
19 | - mongodb-primary
20 | environment:
21 | - MONGODB_ADVERTISED_HOSTNAME=mongodb-secondary
22 | - MONGODB_REPLICA_SET_MODE=secondary
23 | - MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary
24 | - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
25 | - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=prisma
26 | - MONGODB_REPLICA_SET_KEY=replicasetkey123
27 | ports:
28 | - 27027:27017
29 |
30 | mongodb-arbiter:
31 | image: 'bitnami/mongodb:latest'
32 | depends_on:
33 | - mongodb-primary
34 | environment:
35 | - MONGODB_ADVERTISED_HOSTNAME=mongodb-arbiter
36 | - MONGODB_REPLICA_SET_MODE=arbiter
37 | - MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary
38 | - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
39 | - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=prisma
40 | - MONGODB_REPLICA_SET_KEY=replicasetkey123
41 | ports:
42 | - 27037:27017
43 |
44 | nxtjs-frontend:
45 | build:
46 | context: .
47 | dockerfile: Dockerfile
48 | restart: always
49 | ports:
50 | - "3000:3000"
51 | depends_on:
52 | - mongodb-primary
53 | - mongodb-secondary
54 | - mongodb-arbiter
55 |
56 | volumes:
57 | mongodb_master_data:
58 | driver: local
59 |
60 |
--------------------------------------------------------------------------------
/components/layout/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { signOut } from 'next-auth/react';
2 | import { BiLogOut } from 'react-icons/bi';
3 | import { BsHouseFill, BsBellFill } from 'react-icons/bs';
4 | import { FaUser } from 'react-icons/fa';
5 |
6 | import useCurrentUser from '@/hooks/useCurrentUser';
7 |
8 | import SidebarItem from './SidebarItem';
9 | import SidebarLogo from './SidebarLogo';
10 | import SidebarTweetButton from './SidebarTweetButton';
11 |
12 | const Sidebar = () => {
13 | const { data: currentUser } = useCurrentUser();
14 |
15 | const items = [
16 | {
17 | icon: BsHouseFill,
18 | label: 'Home',
19 | href: '/',
20 | },
21 | {
22 | icon: BsBellFill,
23 | label: 'Notifications',
24 | href: '/notifications',
25 | auth: true,
26 | alert: currentUser?.hasNotification
27 | },
28 | {
29 | icon: FaUser,
30 | label: 'Profile',
31 | href: `/users/${currentUser?.id}`,
32 | auth: true,
33 | },
34 | ]
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | {items.map((item) => (
42 |
50 | ))}
51 | {currentUser && signOut()} icon={BiLogOut} label="Logout" />}
52 |
53 |
54 |
55 |
56 | )
57 | };
58 |
59 | export default Sidebar;
60 |
--------------------------------------------------------------------------------
/pages/api/follow.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import prisma from '@/libs/prismadb';
4 | import serverAuth from "@/libs/serverAuth";
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | if (req.method !== 'POST' && req.method !== 'DELETE') {
8 | return res.status(405).end();
9 | }
10 |
11 | try {
12 | const { userId } = req.body;
13 |
14 | const { currentUser } = await serverAuth(req);
15 |
16 | if (!userId || typeof userId !== 'string') {
17 | throw new Error('Invalid ID');
18 | }
19 |
20 | const user = await prisma.user.findUnique({
21 | where: {
22 | id: userId
23 | }
24 | });
25 |
26 | if (!user) {
27 | throw new Error('Invalid ID');
28 | }
29 |
30 | let updatedFollowingIds = [...(user.followingIds || [])];
31 |
32 | if (req.method === 'POST') {
33 | updatedFollowingIds.push(userId);
34 |
35 | // NOTIFICATION PART START
36 | try {
37 | await prisma.notification.create({
38 | data: {
39 | body: 'Someone followed you!',
40 | userId,
41 | },
42 | });
43 |
44 | await prisma.user.update({
45 | where: {
46 | id: userId,
47 | },
48 | data: {
49 | hasNotification: true,
50 | }
51 | });
52 | } catch (error) {
53 | console.log(error);
54 | }
55 | // NOTIFICATION PART END
56 |
57 | }
58 |
59 | if (req.method === 'DELETE') {
60 | updatedFollowingIds = updatedFollowingIds.filter((followingId) => followingId !== userId);
61 | }
62 |
63 | const updatedUser = await prisma.user.update({
64 | where: {
65 | id: currentUser.id
66 | },
67 | data: {
68 | followingIds: updatedFollowingIds
69 | }
70 | });
71 |
72 | return res.status(200).json(updatedUser);
73 | } catch (error) {
74 | console.log(error);
75 | return res.status(400).end();
76 | }
77 | }
--------------------------------------------------------------------------------
/components/posts/CommentItem.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useCallback, useMemo } from 'react';
3 | import { formatDistanceToNowStrict } from 'date-fns';
4 |
5 | import Avatar from '../Avatar';
6 |
7 | interface CommentItemProps {
8 | data: Record;
9 | }
10 |
11 | const CommentItem: React.FC = ({ data = {} }) => {
12 | const router = useRouter();
13 |
14 | const goToUser = useCallback((ev: any) => {
15 | ev.stopPropagation();
16 |
17 | router.push(`/users/${data.user.id}`)
18 | }, [router, data.user.id]);
19 |
20 | const createdAt = useMemo(() => {
21 | if (!data?.createdAt) {
22 | return null;
23 | }
24 |
25 | return formatDistanceToNowStrict(new Date(data.createdAt));
26 | }, [data.createdAt])
27 |
28 | return (
29 |
38 |
39 |
40 |
41 |
42 |
50 | {data.user.name}
51 |
52 |
61 | @{data.user.username}
62 |
63 |
64 | {createdAt}
65 |
66 |
67 |
68 | {data.body}
69 |
70 |
71 |
72 |
73 | )
74 | }
75 |
76 | export default CommentItem;
77 |
--------------------------------------------------------------------------------
/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 = "mongodb"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model User {
14 | id String @id @default(auto()) @map("_id") @db.ObjectId
15 | name String?
16 | username String? @unique
17 | bio String?
18 | email String? @unique
19 | emailVerified DateTime?
20 | image String?
21 | coverImage String?
22 | profileImage String?
23 | hashedPassword String?
24 | createdAt DateTime @default(now())
25 | updatedAt DateTime @updatedAt
26 | followingIds String[] @db.ObjectId
27 | hasNotification Boolean?
28 |
29 | posts Post[]
30 | comments Comment[]
31 | notifications Notification[]
32 | }
33 |
34 | model Post {
35 | id String @id @default(auto()) @map("_id") @db.ObjectId
36 | body String
37 | createdAt DateTime @default(now())
38 | updatedAt DateTime @updatedAt
39 | userId String @db.ObjectId
40 | likedIds String[] @db.ObjectId
41 | image String?
42 |
43 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
44 |
45 | comments Comment[]
46 | }
47 |
48 | model Comment {
49 | id String @id @default(auto()) @map("_id") @db.ObjectId
50 | body String
51 | createdAt DateTime @default(now())
52 | updatedAt DateTime @updatedAt
53 | userId String @db.ObjectId
54 | postId String @db.ObjectId
55 |
56 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
57 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
58 | }
59 |
60 | model Notification {
61 | id String @id @default(auto()) @map("_id") @db.ObjectId
62 | body String
63 | userId String @db.ObjectId
64 | createdAt DateTime @default(now())
65 |
66 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
67 | }
--------------------------------------------------------------------------------
/pages/api/like.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import prisma from '@/libs/prismadb';
4 | import serverAuth from "@/libs/serverAuth";
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | if (req.method !== 'POST' && req.method !== 'DELETE') {
8 | return res.status(405).end();
9 | }
10 |
11 | try {
12 | const { postId } = req.body;
13 |
14 | const { currentUser } = await serverAuth(req);
15 |
16 | if (!postId || typeof postId !== 'string') {
17 | throw new Error('Invalid ID');
18 | }
19 |
20 | const post = await prisma.post.findUnique({
21 | where: {
22 | id: postId
23 | }
24 | });
25 |
26 | if (!post) {
27 | throw new Error('Invalid ID');
28 | }
29 |
30 | let updatedLikedIds = [...(post.likedIds || [])];
31 |
32 | if (req.method === 'POST') {
33 | updatedLikedIds.push(currentUser.id);
34 |
35 | // NOTIFICATION PART START
36 | try {
37 | const post = await prisma.post.findUnique({
38 | where: {
39 | id: postId,
40 | }
41 | });
42 |
43 | if (post?.userId) {
44 | await prisma.notification.create({
45 | data: {
46 | body: 'Someone liked your tweet!',
47 | userId: post.userId
48 | }
49 | });
50 |
51 | await prisma.user.update({
52 | where: {
53 | id: post.userId
54 | },
55 | data: {
56 | hasNotification: true
57 | }
58 | });
59 | }
60 | } catch(error) {
61 | console.log(error);
62 | }
63 | // NOTIFICATION PART END
64 | }
65 |
66 | if (req.method === 'DELETE') {
67 | updatedLikedIds = updatedLikedIds.filter((likedId) => likedId !== currentUser?.id);
68 | }
69 |
70 | const updatedPost = await prisma.post.update({
71 | where: {
72 | id: postId
73 | },
74 | data: {
75 | likedIds: updatedLikedIds
76 | }
77 | });
78 |
79 | return res.status(200).json(updatedPost);
80 | } catch (error) {
81 | console.log(error);
82 | return res.status(400).end();
83 | }
84 | }
--------------------------------------------------------------------------------
/components/layout/SidebarItem.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { IconType } from "react-icons";
3 | import { useRouter } from 'next/router';
4 |
5 | import useLoginModal from '@/hooks/useLoginModal';
6 | import useCurrentUser from '@/hooks/useCurrentUser';
7 | import { BsDot } from 'react-icons/bs';
8 |
9 | interface SidebarItemProps {
10 | label: string;
11 | icon: IconType;
12 | href?: string;
13 | onClick?: () => void;
14 | auth?: boolean;
15 | alert?: boolean;
16 | }
17 |
18 | const SidebarItem: React.FC = ({ label, icon: Icon, href, auth, onClick, alert }) => {
19 | const router = useRouter();
20 | const loginModal = useLoginModal();
21 |
22 | const { data: currentUser } = useCurrentUser();
23 |
24 | const handleClick = useCallback(() => {
25 | if (onClick) {
26 | return onClick();
27 | }
28 |
29 | if (auth && !currentUser) {
30 | loginModal.onOpen();
31 | } else if (href) {
32 | router.push(href);
33 | }
34 | }, [router, href, auth, loginModal, onClick, currentUser]);
35 |
36 | return (
37 |
38 |
52 |
53 | {alert ? : null}
54 |
55 |
68 |
69 |
70 | {label}
71 |
72 | {alert ?
: null}
73 |
74 |
75 | );
76 | }
77 |
78 | export default SidebarItem;
--------------------------------------------------------------------------------
/components/modals/LoginModal.tsx:
--------------------------------------------------------------------------------
1 | import { signIn } from "next-auth/react";
2 | import { useCallback, useState } from "react";
3 | import { toast } from "react-hot-toast";
4 |
5 | import useLoginModal from "@/hooks/useLoginModal";
6 | import useRegisterModal from "@/hooks/useRegisterModal";
7 |
8 | import Input from "../Input";
9 | import Modal from "../Modal";
10 |
11 | const LoginModal = () => {
12 | const loginModal = useLoginModal();
13 | const registerModal = useRegisterModal();
14 |
15 | const [email, setEmail] = useState('');
16 | const [password, setPassword] = useState('');
17 | const [isLoading, setIsLoading] = useState(false);
18 |
19 | const onSubmit = useCallback(async () => {
20 | try {
21 | setIsLoading(true);
22 |
23 | await signIn('credentials', {
24 | email,
25 | password,
26 | });
27 |
28 | toast.success('Logged in');
29 |
30 | loginModal.onClose();
31 | } catch (error) {
32 | toast.error('Something went wrong');
33 | } finally {
34 | setIsLoading(false);
35 | }
36 | }, [email, password, loginModal]);
37 |
38 | const onToggle = useCallback(() => {
39 | loginModal.onClose();
40 | registerModal.onOpen();
41 | }, [loginModal, registerModal])
42 |
43 | const bodyContent = (
44 |
45 | setEmail(e.target.value)}
48 | value={email}
49 | disabled={isLoading}
50 | />
51 | setPassword(e.target.value)}
55 | value={password}
56 | disabled={isLoading}
57 | />
58 |
59 | )
60 |
61 | const footerContent = (
62 |
63 |
First time using Twitter?
64 | Create an account
72 |
73 |
74 | )
75 |
76 | return (
77 |
87 | );
88 | }
89 |
90 | export default LoginModal;
91 |
--------------------------------------------------------------------------------
/components/users/UserBio.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { BiCalendar } from "react-icons/bi";
3 | import { format } from "date-fns";
4 |
5 | import useCurrentUser from "@/hooks/useCurrentUser";
6 | import useUser from "@/hooks/useUser";
7 | import useFollow from "@/hooks/useFollow";
8 | import useEditModal from "@/hooks/useEditModal";
9 |
10 | import Button from "../Button";
11 |
12 | interface UserBioProps {
13 | userId: string;
14 | }
15 |
16 | const UserBio: React.FC = ({ userId }) => {
17 | const { data: currentUser } = useCurrentUser();
18 | const { data: fetchedUser } = useUser(userId);
19 |
20 | const editModal = useEditModal();
21 |
22 | const { isFollowing, toggleFollow } = useFollow(userId);
23 |
24 | const createdAt = useMemo(() => {
25 | if (!fetchedUser?.createdAt) {
26 | return null;
27 | }
28 |
29 | return format(new Date(fetchedUser.createdAt), 'MMMM yyyy');
30 | }, [fetchedUser?.createdAt])
31 |
32 |
33 | return (
34 |
35 |
36 | {currentUser?.id === userId ? (
37 |
38 | ) : (
39 |
45 | )}
46 |
47 |
48 |
49 |
50 | {fetchedUser?.name}
51 |
52 |
53 | @{fetchedUser?.username}
54 |
55 |
56 |
57 |
58 | {fetchedUser?.bio}
59 |
60 |
69 |
70 |
71 | Joined {createdAt}
72 |
73 |
74 |
75 |
76 |
77 |
{fetchedUser?.followingIds?.length}
78 |
Following
79 |
80 |
81 |
{fetchedUser?.followersCount || 0}
82 |
Followers
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
90 | export default UserBio;
--------------------------------------------------------------------------------
/components/modals/EditModal.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useCallback, useEffect, useState } from "react";
3 | import { toast } from "react-hot-toast";
4 |
5 | import useCurrentUser from "@/hooks/useCurrentUser";
6 | import useEditModal from "@/hooks/useEditModal";
7 | import useUser from "@/hooks/useUser";
8 |
9 | import Input from "../Input";
10 | import Modal from "../Modal";
11 | import ImageUpload from "../ImageUpload";
12 |
13 | const EditModal = () => {
14 | const { data: currentUser } = useCurrentUser();
15 | const { mutate: mutateFetchedUser } = useUser(currentUser?.id);
16 | const editModal = useEditModal();
17 |
18 | const [profileImage, setProfileImage] = useState('');
19 | const [coverImage, setCoverImage] = useState('');
20 | const [name, setName] = useState('');
21 | const [username, setUsername] = useState('');
22 | const [bio, setBio] = useState('');
23 |
24 | useEffect(() => {
25 | setProfileImage(currentUser?.profileImage)
26 | setCoverImage(currentUser?.coverImage)
27 | setName(currentUser?.name)
28 | setUsername(currentUser?.username)
29 | setBio(currentUser?.bio)
30 | }, [currentUser?.name, currentUser?.username, currentUser?.bio, currentUser?.profileImage, currentUser?.coverImage]);
31 |
32 | const [isLoading, setIsLoading] = useState(false);
33 |
34 | const onSubmit = useCallback(async () => {
35 | try {
36 | setIsLoading(true);
37 |
38 | await axios.patch('/api/edit', { name, username, bio, profileImage, coverImage });
39 | mutateFetchedUser();
40 |
41 | toast.success('Updated');
42 |
43 | editModal.onClose();
44 | } catch (error) {
45 | toast.error('Something went wrong');
46 | } finally {
47 | setIsLoading(false);
48 | }
49 | }, [editModal, name, username, bio, mutateFetchedUser, profileImage, coverImage]);
50 |
51 | const bodyContent = (
52 |
53 | setProfileImage(image)} label="Upload profile image" />
54 | setCoverImage(image)} label="Upload cover image" />
55 | setName(e.target.value)}
58 | value={name}
59 | disabled={isLoading}
60 | />
61 | setUsername(e.target.value)}
64 | value={username}
65 | disabled={isLoading}
66 | />
67 | setBio(e.target.value)}
70 | value={bio}
71 | disabled={isLoading}
72 | />
73 |
74 | )
75 |
76 | return (
77 |
86 | );
87 | }
88 |
89 | export default EditModal;
90 |
--------------------------------------------------------------------------------
/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { AiOutlineClose } from "react-icons/ai";
3 | import Button from "./Button";
4 |
5 | interface ModalProps {
6 | isOpen?: boolean;
7 | onClose: () => void;
8 | onSubmit: () => void;
9 | title?: string;
10 | body?: React.ReactElement;
11 | footer?: React.ReactElement;
12 | actionLabel: string;
13 | disabled?: boolean;
14 | }
15 |
16 | const Modal: React.FC = ({ isOpen, onClose, onSubmit, title, body, actionLabel, footer, disabled }) => {
17 | const handleClose = useCallback(() => {
18 | if (disabled) {
19 | return;
20 | }
21 |
22 | onClose();
23 | }, [onClose, disabled]);
24 |
25 | const handleSubmit = useCallback(() => {
26 | if (disabled) {
27 | return;
28 | }
29 |
30 | onSubmit();
31 | }, [onSubmit, disabled]);
32 |
33 | if (!isOpen) {
34 | return null;
35 | }
36 |
37 | return (
38 | <>
39 |
55 |
56 | {/*content*/}
57 |
72 | {/*header*/}
73 |
81 |
82 | {title}
83 |
84 |
97 |
98 | {/*body*/}
99 |
100 | {body}
101 |
102 | {/*footer*/}
103 |
104 |
105 | {footer}
106 |
107 |
108 |
109 |
110 | >
111 | );
112 | }
113 |
114 | export default Modal;
115 |
--------------------------------------------------------------------------------
/components/modals/RegisterModal.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { toast } from "react-hot-toast";
3 | import { useCallback, useState } from "react";
4 | import { signIn } from 'next-auth/react';
5 |
6 | import useLoginModal from "@/hooks/useLoginModal";
7 | import useRegisterModal from "@/hooks/useRegisterModal";
8 |
9 | import Input from "../Input";
10 | import Modal from "../Modal";
11 |
12 | const RegisterModal = () => {
13 | const loginModal = useLoginModal();
14 | const registerModal = useRegisterModal();
15 |
16 | const [email, setEmail] = useState('');
17 | const [password, setPassword] = useState('');
18 | const [username, setUsername] = useState('');
19 | const [name, setName] = useState('');
20 |
21 | const [isLoading, setIsLoading] = useState(false);
22 |
23 | const onToggle = useCallback(() => {
24 | if (isLoading) {
25 | return;
26 | }
27 |
28 | registerModal.onClose();
29 | loginModal.onOpen();
30 | }, [loginModal, registerModal, isLoading]);
31 |
32 | const onSubmit = useCallback(async () => {
33 | try {
34 | setIsLoading(true);
35 |
36 | await axios.post('/api/register', {
37 | email,
38 | password,
39 | username,
40 | name,
41 | });
42 |
43 | setIsLoading(false)
44 |
45 | toast.success('Account created.');
46 |
47 | signIn('credentials', {
48 | email,
49 | password,
50 | });
51 |
52 | registerModal.onClose();
53 | } catch (error) {
54 | toast.error('Something went wrong');
55 | } finally {
56 | setIsLoading(false);
57 | }
58 | }, [email, password, registerModal, username, name]);
59 |
60 | const bodyContent = (
61 |
62 | setEmail(e.target.value)}
67 | />
68 | setName(e.target.value)}
73 | />
74 | setUsername(e.target.value)}
79 | />
80 | setPassword(e.target.value)}
86 | />
87 |
88 | )
89 |
90 | const footerContent = (
91 |
92 |
Already have an account?
93 | Sign in
101 |
102 |
103 | )
104 |
105 | return (
106 |
116 | );
117 | }
118 |
119 | export default RegisterModal;
120 |
--------------------------------------------------------------------------------
/components/Form.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { useCallback, useState } from 'react';
3 | import { toast } from 'react-hot-toast';
4 |
5 | import useLoginModal from '@/hooks/useLoginModal';
6 | import useRegisterModal from '@/hooks/useRegisterModal';
7 | import useCurrentUser from '@/hooks/useCurrentUser';
8 | import usePosts from '@/hooks/usePosts';
9 | import usePost from '@/hooks/usePost';
10 |
11 | import Avatar from './Avatar';
12 | import Button from './Button';
13 |
14 | interface FormProps {
15 | placeholder: string;
16 | isComment?: boolean;
17 | postId?: string;
18 | }
19 |
20 | const Form: React.FC = ({ placeholder, isComment, postId }) => {
21 | const registerModal = useRegisterModal();
22 | const loginModal = useLoginModal();
23 |
24 | const { data: currentUser } = useCurrentUser();
25 | const { mutate: mutatePosts } = usePosts();
26 | const { mutate: mutatePost } = usePost(postId as string);
27 |
28 | const [body, setBody] = useState('');
29 | const [isLoading, setIsLoading] = useState(false);
30 |
31 | const onSubmit = useCallback(async () => {
32 | try {
33 | setIsLoading(true);
34 |
35 | const url = isComment ? `/api/comments?postId=${postId}` : '/api/posts';
36 |
37 | await axios.post(url, { body });
38 |
39 | toast.success('Tweet created');
40 | setBody('');
41 | mutatePosts();
42 | mutatePost();
43 | } catch (error) {
44 | toast.error('Something went wrong');
45 | } finally {
46 | setIsLoading(false);
47 | }
48 | }, [body, mutatePosts, isComment, postId, mutatePost]);
49 |
50 | return (
51 |
52 | {currentUser ? (
53 |
54 |
57 |
58 |
77 |
86 |
87 |
88 |
89 |
90 |
91 | ) : (
92 |
93 |
Welcome to Twitter
94 |
95 |
96 |
97 |
98 |
99 | )}
100 |
101 | );
102 | };
103 |
104 | export default Form;
105 |
--------------------------------------------------------------------------------
/components/posts/PostItem.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useCallback, useMemo } from 'react';
3 | import { AiFillHeart, AiOutlineHeart, AiOutlineMessage } from 'react-icons/ai';
4 | import { formatDistanceToNowStrict } from 'date-fns';
5 |
6 | import useLoginModal from '@/hooks/useLoginModal';
7 | import useCurrentUser from '@/hooks/useCurrentUser';
8 | import useLike from '@/hooks/useLike';
9 |
10 | import Avatar from '../Avatar';
11 | interface PostItemProps {
12 | data: Record;
13 | userId?: string;
14 | }
15 |
16 | const PostItem: React.FC = ({ data = {}, userId }) => {
17 | const router = useRouter();
18 | const loginModal = useLoginModal();
19 |
20 | const { data: currentUser } = useCurrentUser();
21 | const { hasLiked, toggleLike } = useLike({ postId: data.id, userId});
22 |
23 | const goToUser = useCallback((ev: any) => {
24 | ev.stopPropagation();
25 | router.push(`/users/${data.user.id}`)
26 | }, [router, data.user.id]);
27 |
28 | const goToPost = useCallback(() => {
29 | router.push(`/posts/${data.id}`);
30 | }, [router, data.id]);
31 |
32 | const onLike = useCallback(async (ev: any) => {
33 | ev.stopPropagation();
34 |
35 | if (!currentUser) {
36 | return loginModal.onOpen();
37 | }
38 |
39 | toggleLike();
40 | }, [loginModal, currentUser, toggleLike]);
41 |
42 | const LikeIcon = hasLiked ? AiFillHeart : AiOutlineHeart;
43 |
44 | const createdAt = useMemo(() => {
45 | if (!data?.createdAt) {
46 | return null;
47 | }
48 |
49 | return formatDistanceToNowStrict(new Date(data.createdAt));
50 | }, [data.createdAt])
51 |
52 | return (
53 |
63 |
64 |
65 |
66 |
67 |
75 | {data.user.name}
76 |
77 |
86 | @{data.user.username}
87 |
88 |
89 | {createdAt}
90 |
91 |
92 |
93 | {data.body}
94 |
95 |
96 |
107 |
108 |
109 | {data.comments?.length || 0}
110 |
111 |
112 |
124 |
125 |
126 | {data.likedIds.length}
127 |
128 |
129 |
130 |
131 |
132 |
133 | )
134 | }
135 |
136 | export default PostItem;
137 |
--------------------------------------------------------------------------------