Fullstack-chat-app

Real-time chat-app

Stars
1
Committers
2

🔋 Функции

👉 Авторизация/Регистрация: Система авторизации/регистрации с интеграцией Google Cloud Platform

👉 Список Пользователей: Список доступных пользователей для начала диалога

👉 Чаты: Диалоговые окна для обмена сообщениями, фотографиями c другими пользователями

👉 Кастомизация: Возможность смены аватарки/имени

👉 В реальном времени: Отображение сообщений и пользователей в реальном времени при помощи pusher

👉 Групповые чаты: Возможность создавать групповые чаты от 3х пользователей

👉 Информация о собеседнике: В каждом чате можно изучить информацию о собеседнике (город, email)

👉 Адаптивный дизайн: Оптимизация под мобильные, планшетные и десктопные устройства

⚙️ Стэк

  • Next.js
  • TypeScript
  • MongoDB
  • React
  • Tailwind CSS
  • Pusher

🤸 Установка

Следуйте этим шагам для успешной установки.

Предварительные условия

Убедитесь, что на вашем компьютере установлено следующее:

Клонирование репозитория

git clone https://github.com/PonomarevAleksandr/Fullstack-chat-app.git
cd Fullstack-chat-app

Установка

Установите зависимости проекта с помощью npm:

npm install

Настройка переменных среды

Создайте новый файл с именем .env в корне вашего проекта и добавьте следующий контент:

DATABASE_URL=
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
NEXT_PUBLIC_CLOUDINARY_API_KEY=
NEXT_PUBLIC_CLOUDINARY_API_SECRET=
CLOUDINARY_URL=
NEXT_PUBLIC_PUSHER_APP_KEY=
PUSHER_APP_ID=
PUSHER_SECRET=

Запуск проекта

npm run dev

Перейди по ссылке http://localhost:3000 в твоем браузере чтобы увидеть проект.

🕸️ Snippets

    generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model User {
  id             String    @id @default(auto()) @map("_id") @db.ObjectId
  name           String?
  email          String?   @unique
  emailVerified  DateTime?
  image          String?
  hashedPassword String?
  town           String?   @default("Новосибирск")
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  role           String?   @default("user")

  conversationIds String[]       @db.ObjectId
  conversations   Conversation[] @relation(fields: [conversationIds], references: [id])

  seenMessageIds String[]  @db.ObjectId
  seenMessages   Message[] @relation("Seen", fields: [seenMessageIds], references: [id])

  accounts Account[]
  messages Message[]
}

model Account {
  id                 String    @id @default(auto()) @map("_id") @db.ObjectId
  userId             String    @db.ObjectId
  type               String
  provider           String
  providerType       String?
  providerAccountId  String
  refresh_token      String?   @db.String
  access_token       String?   @db.String
  accessTokenExpires DateTime?
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?   @db.String
  createdAt          DateTime  @default(now())
  updatedAt          DateTime  @updatedAt
  session_state      String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Conversation {
  id            String   @id @default(auto()) @map("_id") @db.ObjectId
  createdAt     DateTime @default(now())
  lastMessageAt DateTime @default(now())
  name          String?
  isGroup       Boolean?

  messageIds String[]  @db.ObjectId
  messages   Message[]

  userIds String[] @db.ObjectId
  users   User[]   @relation(fields: [userIds], references: [id])
}

model Message {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  body      String?
  image     String?
  createdAt DateTime @default(now())

  seenIds String[] @db.ObjectId
  seen    User[]   @relation("Seen", fields: [seenIds], references: [id])

  conversationId String       @db.ObjectId
  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)

  senderId String @db.ObjectId
  sender   User   @relation(fields: [senderId], references: [id], onDelete: Cascade)
}
'use client';
import {useCallback, useEffect, useState} from "react";
import {FieldValues, SubmitHandler, useForm} from "react-hook-form";
import Input from "@/app/components/inputs/input";
import Button from "@/app/components/Button";
import AuthSocialButton from "@/app/(site)/components/AuthSocialButton";
import {BsGoogle} from "react-icons/bs";
import axios from "axios";
import toast from "react-hot-toast";
import {signIn, useSession} from "next-auth/react";
import {useRouter} from "next/navigation";
import RegionSelect from "@/app/components/inputs/RegionSelect"

type Variant = 'LOGIN' | 'REGISTER';


const AuthForm = () => {
    const session = useSession();
    const router = useRouter();
    const [variant, setVariant] = useState<Variant>('LOGIN');
    const [isLoading, setIsLoading] = useState<boolean>(false);

    useEffect(() => {
        if (session?.status === 'authenticated') {
            router.push('/users')
        }
    }, [session?.status, router]);

    const toggleVariant = useCallback(() => {
        if (variant === 'LOGIN') {
            setVariant('REGISTER');
        } else {
            setVariant('LOGIN')
        }
    }, [variant]);

    const {
        register,
        handleSubmit,
        formState: {
            errors
        }
    } = useForm<FieldValues>({
        defaultValues: {
            name: '',
            email: '',
            password: ''

        }
    });

    const onSubmit: SubmitHandler<FieldValues> = (data) => {
        setIsLoading(true);

        if (variant === 'REGISTER') {
            axios.post('/api/register', data)
                .then(() => signIn('credentials', data))
                .catch(() => toast.error('Что-то пошло не так'))
                .finally(() => setIsLoading(false))

        }

        if (variant === 'LOGIN') {
            signIn('credentials', {
                ...data,
                redirect: false
            })
                .then((callback) => {
                    if (callback?.error) {
                        toast.error('Не верный логин или пароль')
                    }
                    if (callback?.ok && !callback?.error) {
                        toast.success('Успешный вход!')
                        router.push('/users')
                    }
                })
                .finally(() => setIsLoading(false))
        }
    }

    const SocialAction = (action: string) => {
        setIsLoading(true);
        signIn(action, {redirect: false})
            .then((callback) => {
                if (callback?.error) {
                    toast.error('Не верный логин или пароль')
                }

                if (callback?.ok && !callback?.error) {
                    toast.success('Успешный вход!')
                }
            })
            .finally(() => setIsLoading(false))

    }

    return (
        <div
            className="
              mt-8
              sm:mx-auto
              sm:w-full
              sm:max-w-md
            "
        >
            <div
                className="
                      bg-white
                      px-4
                      py-8
                      shadow
                      sm:rounded-lg
                      sm:px-10
                "
            >
                <form
                    className="space-y-6"
                    onSubmit={handleSubmit(onSubmit)}
                >
                    {variant === 'REGISTER' && (
                        <><Input
                            id="name"
                            label="Name"
                            register={register}
                            errors={errors}
                            disabled={isLoading}/>

                            <RegionSelect
                            register={register}
                            id="town"
                            errors={errors}
                            disabled={isLoading}/>
                        </>
                    )}
                    <Input
                        id="email"
                        label="Email"
                        type="email"
                        register={register}
                        errors={errors}
                        disabled={isLoading}
                    />
                    <Input
                        id="password"
                        label="Password"
                        type="password"
                        register={register}
                        errors={errors}
                        disabled={isLoading}
                    />
                    <div>
                        <Button
                            disabled={isLoading}
                            fullWidth
                            type="submit"
                        >
                            {variant === 'LOGIN' ? 'Войти' : 'Зарегестрироваться'}
                        </Button>
                    </div>
                </form>

                <div className="mt-6">
                    <div className="relative">
                        <div
                            className="
                                absolute
                                inset-0
                                flex
                                items-center
                                "
                        >
                            <div
                                className="
                                    w-full
                                    border-t
                                    border-gray-300"
                            />
                        </div>
                        <div className="
                            relative
                            flex
                            justify-center
                            text-sm
                            "
                        >
                            <span className="
                                bg-white
                                px-2
                                text-gray-500">
                                Или
                            </span>
                        </div>
                    </div>
                    {/*<div className="mt-6 flex gap-2">*/}
                    {/*    <AuthSocialButton*/}
                    {/*        icon={BsGoogle}*/}
                    {/*        onClick={() => SocialAction('google')}*/}
                    {/*    />*/}
                    {/*</div>*/}
                </div>

                <div className="
                flex
                gap-2
                justify-center
                text-sm
                mt-6
                px-2
                text-gray-500
                ">
                    <div>
                        {variant === 'LOGIN' ? 'Не зарегестрированы?' : 'Уже есть аккаунт?'}
                    </div>
                    <div
                        onClick={toggleVariant}
                        className="underline cursor-pointer"
                    >
                        {variant === 'LOGIN' ? 'Создать аккаунт' : 'Войти'}

                    </div>
                </div>
            </div>
        </div>
    );
}

export default AuthForm;

"use client";

import {FullConversationType} from "@/app/types";
import {useEffect, useMemo, useState} from "react";
import {useRouter} from "next/navigation";
import useConversation from "@/app/hooks/useConversation";
import clsx from "clsx";
import {MdOutlineGroupAdd} from "react-icons/md";
import ConversationBox from "@/app/conversations/components/ConversationBox";
import GroupChatModal from "@/app/conversations/components/GroupChatModal";
import {User} from "@prisma/client";
import {useSession} from "next-auth/react";
import {pusherClient} from "@/app/libs/pusher";
import {find} from "lodash";

interface ConversationsListProps {
    initialItems: FullConversationType[];
    users: User[]
}

const ConversationsList: React.FC<ConversationsListProps> = ({
                                                                 initialItems,
                                                                 users
                                                             }) => {
    const session = useSession();
    const [items, setItems] = useState(initialItems);
    const [isModalOpen, setIsModalOpen] = useState(false);
    const router = useRouter();
    const {conversationId, isOpen} = useConversation()

    const pusherKey = useMemo(() => {
        return session.data?.user?.email;
    }, [session.data?.user?.email]);

    useEffect(() => {
        if (!pusherKey) {
            return;
        }

        pusherClient.subscribe(pusherKey);

        const newHandler = (conversation: FullConversationType) => {
            setItems((current) => {
                if (find(current, {id: conversation.id})) {
                return current;
                }

                return [conversation, ...current];
            })
        };

        const updateHandler = (conversation: FullConversationType) => {
            setItems((current) => current.map((currentConversation) => {
                if (currentConversation.id === conversation.id) {
                    return {
                        ...currentConversation,
                        messages: conversation.messages
                    }
                }
                return currentConversation;
            }));
        }

        const removeHandler = (conversation: FullConversationType) => {
            setItems((current) => {
               return [...current.filter((convo) => convo.id !== conversation.id)]
            });

            if (conversationId === conversation.id) {
                router.push('/conversations');
            }

        };

        pusherClient.bind('conversation:new', newHandler)
        pusherClient.bind('conversation:update', updateHandler)
        pusherClient.bind('conversation:remove', removeHandler)

        return () => {
            pusherClient.unsubscribe(pusherKey);
            pusherClient.unbind('conversation:new', newHandler);
            pusherClient.unbind('conversation:update', updateHandler)
            pusherClient.unbind('conversation:remove', removeHandler)


        }

    }, [pusherKey, conversationId])

    return (
        <>
            <GroupChatModal
            users={users}
            isOpen={isModalOpen}
            onClose={() => setIsModalOpen(false)}
            />
            <aside
                className={clsx(`
                    fixed
                    inset-y-0
                    pb-20
                    lg:pb-0
                    lg:left-20
                    lg:w-80
                    lg:block
                    overflow-y-auto
                    border-r
                    border-gray-200

                    `,
                    isOpen ? 'hidden' : 'block w-full left-0'
                )}>
                    <div className="px-5">
                        <div className="flex justify-between mb-4 pt-4">
                            <div className="
                                text-2xl
                                font-normal
                                text-neutral-800">
                                Чаты
                            </div>
                            <div
                                onClick={() => setIsModalOpen(true)}
                                className="
                                    rounded-full
                                    p-2
                                    bg-gray-100
                                    text-gray-600
                                    cursor-pointer
                                    hover:opacity-75
                                    transition">
                                <MdOutlineGroupAdd size={20}/>
                            </div>
                        </div>
                        {items.map((item) => (
                            <ConversationBox
                                key={item.id}
                                data={item}
                                selected={conversationId === item.id}
                            />
                        ))}
                    </div>
            </aside>
        </>
    );
};

export default ConversationsList;

"use client";

import { User } from "@prisma/client"
import UserBox from "./UserBox"

interface UserListProps {
    items: User[]
}

const UserList: React.FC<UserListProps> = ({
    items
                                           }) => {
    return(
        <aside
        className="
        fixed
        inset-y-0
        pb-20
        lg:pb-0
        lg:left-20
        lg:w-80
        lg:block
        overflow-y-auto
        border-r
        border-gray-200
        block
        w-full
        left-0

        ">
            <div className="px-5">
                <div className="flex-col">
                    <div
                    className="error
                    text-2xl
                    font-normal
                    text-neutral-800
                    py-4">
                        Пользователи

                    </div>
                </div>
            {items.map((item) => (
                <UserBox
                key={item.id}
                data={item}
                />
            ))}
            </div>
        </aside>
    );
};

export default UserList;