├── src ├── App.css ├── lib │ ├── auth.tsx │ └── authorization.tsx ├── stores │ ├── notification.ts │ ├── refetchSlice.ts │ ├── store.ts │ ├── notificationSlice.ts │ └── userSlice.ts ├── components │ ├── Layout │ │ ├── MainLayout.tsx │ │ └── ContentLayout.tsx │ ├── Elements │ │ ├── Link.tsx │ │ ├── LoadImage.tsx │ │ ├── CloseModal.tsx │ │ ├── Toggle.tsx │ │ ├── Spinner.tsx │ │ ├── Button.tsx │ │ ├── Dialog.tsx │ │ ├── Drawer.tsx │ │ └── ConfirmationDialog.tsx │ ├── Shimmer │ │ ├── ShimmerChatList.tsx │ │ ├── ShimmerChat.tsx │ │ ├── ShimmerCreatePost.tsx │ │ ├── ShimmerChatSection.tsx │ │ ├── ShimmerPosts.tsx │ │ ├── ShimmerComment.tsx │ │ ├── ShimmerAvatar.tsx │ │ ├── Shimmer.tsx │ │ └── ShimmerProfile.tsx │ ├── Shared │ │ ├── Logo.tsx │ │ ├── GradientText.tsx │ │ ├── AnimatedPage.tsx │ │ ├── Like.tsx │ │ ├── Bookmark.tsx │ │ ├── TestErrorBoundary.tsx │ │ ├── UploadImage.tsx │ │ ├── ErrorRouteElement.tsx │ │ ├── FallbackErrorBoundary.tsx │ │ ├── Carousel.tsx │ │ └── AvailableLanguages.tsx │ ├── Button.tsx │ ├── Form │ │ ├── TextareaField.tsx │ │ ├── InputFieldForm.tsx │ │ ├── FieldWrapper.tsx │ │ ├── SelectField.tsx │ │ ├── Form.tsx │ │ └── TextArea.tsx │ └── Notifications │ │ ├── Notifications.tsx │ │ └── Notification.tsx ├── vite-env.d.ts ├── assets │ └── images │ │ ├── png │ │ ├── analytics.png │ │ ├── audit_cards.png │ │ ├── graph-visual.png │ │ └── post-performance.png │ │ ├── w-linkedin.svg │ │ ├── w-twitter.svg │ │ ├── react.svg │ │ ├── nfluence-logo.svg │ │ └── w-instagram.svg ├── types │ ├── responseType.ts │ └── index.d.ts ├── features │ ├── posts │ │ ├── context │ │ │ ├── CommentContext.ts │ │ │ └── PostContext.ts │ │ ├── utils │ │ │ ├── createPostSchema.ts │ │ │ └── settingSchema.ts │ │ ├── Components │ │ │ ├── PostsByTag.tsx │ │ │ ├── BookmarkedPosts.tsx │ │ │ ├── PostAuthor.tsx │ │ │ ├── Tags.tsx │ │ │ ├── CreateTags.tsx │ │ │ ├── CreatePostTags.tsx │ │ │ ├── DeletePost.tsx │ │ │ ├── CreatePostDisplay.tsx │ │ │ └── Posts.tsx │ │ ├── api │ │ │ ├── postBookmark.ts │ │ │ ├── deletePost.ts │ │ │ ├── createPost.ts │ │ │ ├── getPosts.ts │ │ │ └── postLike.ts │ │ └── types │ │ │ └── postType.ts │ ├── auth │ │ ├── utils │ │ │ ├── loginValidation.ts │ │ │ └── signupValidation.ts │ │ ├── api │ │ │ ├── logoutUser.tsx │ │ │ ├── registerUser.tsx │ │ │ └── loginUser.tsx │ │ └── Components │ │ │ ├── Container.tsx │ │ │ ├── __tests__ │ │ │ └── login.test.tsx │ │ │ ├── AuthFormEnhancements.tsx │ │ │ └── Login.tsx │ ├── chat │ │ ├── api │ │ │ ├── getUserChat.ts │ │ │ ├── getAllUser.ts │ │ │ ├── getChatMessages.ts │ │ │ ├── addDeleteMembers.ts │ │ │ ├── createOnetoOneChat.ts │ │ │ ├── deleteChat.ts │ │ │ ├── updateGroupChatName.ts │ │ │ └── postMessage.ts │ │ ├── Components │ │ │ ├── TypingChat.tsx │ │ │ ├── ChatMembers.tsx │ │ │ ├── Chat.tsx │ │ │ ├── EditGroupPostName.tsx │ │ │ ├── AvailableUserOption.tsx │ │ │ └── ChatAuthorProfile.tsx │ │ ├── utils │ │ │ └── utils.ts │ │ └── hooks │ │ │ └── useSocketEvents.ts │ ├── landing │ │ ├── LandingPage.tsx │ │ ├── Components │ │ │ ├── Footer.tsx │ │ │ ├── MarketingFuture.tsx │ │ │ ├── MailingList.tsx │ │ │ ├── Title.tsx │ │ │ └── Highlights.tsx │ │ └── utils │ │ │ └── features.ts │ ├── user │ │ ├── api │ │ │ ├── getUser.ts │ │ │ ├── getUserByUsername.ts │ │ │ ├── getFollowersList.ts │ │ │ ├── postUserFollow.ts │ │ │ ├── updateUserProfile.ts │ │ │ └── postImage.ts │ │ ├── Components │ │ │ ├── Settings.tsx │ │ │ ├── Avatar.tsx │ │ │ ├── Follow.tsx │ │ │ ├── AuthorProfile.tsx │ │ │ ├── UploadProfileImage.tsx │ │ │ ├── UserProfileImage.tsx │ │ │ └── UserProfileAbout.tsx │ │ └── hooks │ │ │ └── usePreviousPage.ts │ └── comments │ │ ├── api │ │ ├── getComments.ts │ │ └── postLikeComment.ts │ │ └── Components │ │ ├── CommentContent.tsx │ │ └── CreateComment.tsx ├── utils │ ├── helpers.ts │ ├── convertToBlob.ts │ ├── index.ts │ └── navbarLinks.tsx ├── hooks │ ├── useDisclosure.ts │ ├── useScreenSize.ts │ ├── useAuthCheck.ts │ └── useInfiniteScroll.ts ├── config │ └── constants.ts ├── index.css ├── main.tsx ├── App.tsx ├── context │ ├── SocketContext.tsx │ └── LanguageContext.tsx └── services │ └── apiClient.ts ├── .gitignore ├── .eslintignore ├── postcss.config.js ├── vite.config.ts ├── tsconfig.node.json ├── .eslintrc.js ├── jest.config.ts ├── index.html ├── .vscode └── launch.json ├── tsconfig.json ├── tailwind.config.js ├── README.md ├── public └── vite.svg └── package.json /src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /src/lib/auth.tsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/lib/authorization.tsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/stores/notification.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | postcss.config.js 2 | tailwind.config.js -------------------------------------------------------------------------------- /src/components/Layout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/images/png/analytics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njsaugat/polobolo/HEAD/src/assets/images/png/analytics.png -------------------------------------------------------------------------------- /src/assets/images/png/audit_cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njsaugat/polobolo/HEAD/src/assets/images/png/audit_cards.png -------------------------------------------------------------------------------- /src/assets/images/png/graph-visual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njsaugat/polobolo/HEAD/src/assets/images/png/graph-visual.png -------------------------------------------------------------------------------- /src/assets/images/png/post-performance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njsaugat/polobolo/HEAD/src/assets/images/png/post-performance.png -------------------------------------------------------------------------------- /src/types/responseType.ts: -------------------------------------------------------------------------------- 1 | export type ResponseType = { 2 | data: T; 3 | message: string; 4 | statusCode: number; 5 | success: boolean; 6 | }; 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "unused-imports"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | rules: { 7 | "unused-imports/no-unused-imports-ts": 2, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/features/posts/context/CommentContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type CommentRefetchContext = { 4 | refetch: ({}: object) => void; 5 | page: number; 6 | }; 7 | export const CommentRefetchContext = createContext({ 8 | refetch: () => {}, 9 | page: 0, 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | export function formatDateStringToBirthday(dateString: string) { 2 | const options = { year: "numeric", month: "long", day: "numeric" }; 3 | const formattedDate = new Date(dateString).toLocaleDateString( 4 | undefined, 5 | options as Intl.DateTimeFormatOptions 6 | ); 7 | return formattedDate; 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: "ts-jest", 3 | testEnvironment: "jest-environment-jsdom", 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest", 6 | // process `*.tsx` files with `ts-jest` 7 | }, 8 | moduleNameMapper: { 9 | "\\.(gif|ttf|eot|svg|png)$": "/test/__ mocks __/fileMock.js", 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/features/posts/context/PostContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type PostRefetchContext = { 4 | refetch: ({}: object) => void; 5 | page: number; 6 | postId: string; 7 | }; 8 | export const PostRefetchContext = createContext({ 9 | refetch: () => {}, 10 | page: 0, 11 | postId: "", 12 | }); 13 | -------------------------------------------------------------------------------- /src/features/posts/utils/createPostSchema.ts: -------------------------------------------------------------------------------- 1 | import { t } from "i18next"; 2 | import { z } from "zod"; 3 | export const createPostValidationSchema = z.object({ 4 | content: z.string().min(3, { message: t("validationMessages.postContent") }), 5 | }); 6 | 7 | export type CreatePostValidationSchema = z.infer< 8 | typeof createPostValidationSchema 9 | >; 10 | -------------------------------------------------------------------------------- /src/utils/convertToBlob.ts: -------------------------------------------------------------------------------- 1 | export const convertToBlob = (dataURL: string) => { 2 | const base64String = dataURL.split(",")[1]; // Split the Data URL to get the base64 part 3 | const binaryData = atob(base64String); 4 | const blob = new Blob( 5 | [new Uint8Array([...binaryData].map((char) => char.charCodeAt(0)))], 6 | { type: "application/octet-stream" } 7 | ); 8 | return blob; 9 | }; 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Polobolo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/Elements/Link.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { LinkProps, Link as RouterLink } from "react-router-dom"; 3 | 4 | export const Link = ({ className, children, ...props }: LinkProps) => { 5 | return ( 6 | 10 | {children} 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerChatList.tsx: -------------------------------------------------------------------------------- 1 | import ShimmerComment from "./ShimmerComment"; 2 | 3 | const ShimmerChatList = () => { 4 | return ( 5 | <> 6 | {new Array(10).fill(1).map((value, index) => ( 7 |
8 | 9 |
10 | ))} 11 | 12 | ); 13 | }; 14 | 15 | export default ShimmerChatList; 16 | -------------------------------------------------------------------------------- /src/features/auth/utils/loginValidation.ts: -------------------------------------------------------------------------------- 1 | import { t } from "i18next"; 2 | import { z } from "zod"; 3 | 4 | export const loginValidationSchema = z.object({ 5 | email: z.string().email({ 6 | message: t("validationMessages.validEmail"), 7 | }), 8 | password: z 9 | .string() 10 | .min(1, { message: t("validationMessages.requirePassword") }), 11 | }); 12 | 13 | export type LoginValidationSchema = z.infer; 14 | -------------------------------------------------------------------------------- /src/hooks/useDisclosure.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const useDisclosure = (initial = false) => { 4 | const [isOpen, setIsOpen] = React.useState(initial); 5 | 6 | const open = React.useCallback(() => setIsOpen(true), []); 7 | const close = React.useCallback(() => setIsOpen(false), []); 8 | const toggle = React.useCallback(() => setIsOpen((state) => !state), []); 9 | 10 | return { isOpen, open, close, toggle }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Shared/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import GradientText from "./GradientText"; 3 | 4 | export interface LogoProps { 5 | className?: string; 6 | content?: string; 7 | } 8 | const Logo = ({ className, content = "PoloBolo" }: LogoProps) => { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default Logo; 17 | -------------------------------------------------------------------------------- /src/features/chat/api/getUserChat.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { axios } from "../../../services/apiClient"; 3 | 4 | const getUserChat = () => { 5 | const queryClient = useQueryClient(); 6 | const getUserChat = () => { 7 | return axios.get(`/chat-app/chats`); 8 | }; 9 | return useQuery({ 10 | queryKey: ["chats"], 11 | queryFn: getUserChat, 12 | staleTime: 1000 * 60 * 60 * 24, 13 | }); 14 | }; 15 | 16 | export default getUserChat; 17 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | interface ButtonProps { 2 | actionItem: string; 3 | type?: "button" | "submit" | "reset"; 4 | className?: string; 5 | } 6 | const Button = ({ actionItem, type, className }: ButtonProps) => { 7 | return ( 8 | 14 | ); 15 | }; 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Run Chrome", 11 | "url": "http://localhost:5173", 12 | "webRoot": "${workspaceFolder}/src" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /src/components/Shared/GradientText.tsx: -------------------------------------------------------------------------------- 1 | import { LogoProps } from "./Logo"; 2 | 3 | const GradientText = ({ content, className }: LogoProps) => { 4 | return ( 5 |

10 | {content} 11 |

12 | ); 13 | }; 14 | 15 | export default GradientText; 16 | -------------------------------------------------------------------------------- /src/stores/refetchSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | export type Refetch = ({}: object) => void; 4 | 5 | const initialState: { refetch: Refetch } = { refetch: () => {} }; 6 | 7 | export const refetchSlice = createSlice({ 8 | name: "refetch", 9 | initialState, 10 | reducers: { 11 | addRefetch: (state, action: PayloadAction) => { 12 | state.refetch = action.payload; 13 | }, 14 | }, 15 | }); 16 | 17 | export const { addRefetch } = refetchSlice.actions; 18 | export default refetchSlice.reducer; 19 | -------------------------------------------------------------------------------- /src/components/Elements/LoadImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | const LoadImage = ({ 4 | className, 5 | src, 6 | }: React.ImgHTMLAttributes) => { 7 | const [loading, setLoading] = useState(true); 8 | return ( 9 | {` setLoading(false)} 13 | className={`${ 14 | loading ? " bg-gradient-to-r from-slate-200 to-gray-200 animate-pulse " : " bg-white" 15 | } ${className}`} 16 | /> 17 | ); 18 | }; 19 | 20 | export default LoadImage; 21 | -------------------------------------------------------------------------------- /src/features/landing/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "./Components/Footer"; 2 | import Highlights from "./Components/Highlights"; 3 | import MarketingFuture from "./Components/MarketingFuture"; 4 | import Navbar from "./Components/Navbar"; 5 | import Title from "./Components/Title"; 6 | 7 | const LandingPage = () => { 8 | return ( 9 |
10 | 11 | 12 | <Highlights /> 13 | <MarketingFuture /> 14 | <Footer /> 15 | </div> 16 | ); 17 | }; 18 | 19 | export default LandingPage; 20 | -------------------------------------------------------------------------------- /src/features/user/api/getUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { Author } from "features/posts/types/postType"; 3 | import { ResponseType } from "types/responseType"; 4 | import { axios } from "../../../services/apiClient"; 5 | 6 | const getUser = () => { 7 | const getUserProfile = () => { 8 | return axios.get<ResponseType<Author>>("/social-media/profile"); 9 | }; 10 | return useQuery({ 11 | queryKey: ["user"], 12 | queryFn: getUserProfile, 13 | useErrorBoundary: true, 14 | staleTime: 60 * 60 * 1000, 15 | }); 16 | }; 17 | 18 | export default getUser; 19 | -------------------------------------------------------------------------------- /src/components/Layout/ContentLayout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type ContentLayoutProps = { 4 | children: React.ReactNode; 5 | title: string; 6 | }; 7 | 8 | export const ContentLayout = ({ children, title }: ContentLayoutProps) => { 9 | return ( 10 | <> 11 | <div className="py-6"> 12 | <div className="px-4 mx-auto max-w-7xl sm:px-6 md:px-8"> 13 | <h1 className="text-2xl font-semibold text-gray-900">{title}</h1> 14 | </div> 15 | <div className="px-4 mx-auto max-w-7xl sm:px-6 md:px-8">{children}</div> 16 | </div> 17 | </> 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/Shared/AnimatedPage.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | import { ReactNode } from "react"; 3 | 4 | const animations = { 5 | initial: { opacity: 0, x: 100 }, 6 | animate: { opacity: 1, x: 0 }, 7 | exit: { opacity: 0, x: -100 }, 8 | }; 9 | 10 | const AnimatedPage = ({ children }: { children: ReactNode }) => { 11 | return ( 12 | <motion.div 13 | variants={animations} 14 | initial="initial" 15 | animate="animate" 16 | exit="exit" 17 | transition={{ duration: 1 }} 18 | > 19 | {children} 20 | </motion.div> 21 | ); 22 | }; 23 | 24 | export default AnimatedPage; 25 | -------------------------------------------------------------------------------- /src/features/chat/Components/TypingChat.tsx: -------------------------------------------------------------------------------- 1 | 2 | const TypingChat = () => { 3 | return ( 4 | <div className="flex items-center justify-center w-20 h-10 px-2 py-3 mb-2 ml-10 transition-all duration-300 rounded-t-full rounded-br-full gap-x-2 bg-gradient-to-b from-slate-100 to-slate-300 "> 5 | {new Array(3).fill(1).map((dot, index) => ( 6 | <span 7 | key={index + dot} 8 | className={`w-3 h-3 animation${ 9 | index + 1 10 | } rounded-full bg-gradient-to-b from-teal-100 to-teal-300`} 11 | ></span> 12 | ))} 13 | </div> 14 | ); 15 | }; 16 | 17 | export default TypingChat; 18 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerChat.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ShimmerChatList from "./ShimmerChatList"; 3 | import ShimmerChatSection from "./ShimmerChatSection"; 4 | 5 | const ShimmerChat = () => { 6 | return ( 7 | <div className="w-screen flex border-t-[0.1px] h-[calc(100vh-115px)] overflow-auto "> 8 | <div className="w-full md:w-2/5 lg:w-1/3 border-r-[0.1px] h-full pt-4"> 9 | <ShimmerChatList /> 10 | </div> 11 | <div className="hidden w-full h-full md:block md:w-3/5 lg:w-2/3"> 12 | <ShimmerChatSection /> 13 | </div> 14 | </div> 15 | ); 16 | }; 17 | 18 | export default ShimmerChat; 19 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const JWT_SECRET = "123456" as string; 2 | export const onboardingThresholdSeconds = 60 * 60 * 24; // 1 day 3 | export const CONNECTED_EVENT = "connected"; 4 | export const DISCONNECT_EVENT = "disconnect"; 5 | export const JOIN_CHAT_EVENT = "joinChat"; 6 | export const NEW_CHAT_EVENT = "newChat"; 7 | export const TYPING_EVENT = "typing"; 8 | export const STOP_TYPING_EVENT = "stopTyping"; 9 | export const MESSAGE_RECEIVED_EVENT = "messageReceived"; 10 | export const LEAVE_CHAT_EVENT = "leaveChat"; 11 | export const UPDATE_GROUP_NAME_EVENT = "updateGroupName"; 12 | export const TOTAL_TAGS = 10; 13 | export const TOTAL_UPLOADABLE_IMAGES = 6; 14 | -------------------------------------------------------------------------------- /src/features/user/Components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { Navigate, useParams } from "react-router-dom"; 3 | import { RootState } from "stores/store"; 4 | import { Author } from "../../posts/types/postType"; 5 | import UserDetails from "./UserDetails"; 6 | 7 | const Settings = () => { 8 | const user = useSelector<RootState, Author | undefined>( 9 | (store) => store.user.user 10 | ); 11 | const { username } = useParams(); 12 | 13 | if (user?.account.username !== username) { 14 | return <Navigate to={`/user/${username}/about`} />; 15 | } 16 | return <UserDetails />; 17 | }; 18 | 19 | export default Settings; 20 | -------------------------------------------------------------------------------- /src/hooks/useScreenSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useScreenSize = (screenSize: number) => { 4 | const [isScreenSmall, setIsScreenSmall] = useState(true); 5 | 6 | const handleResize = () => { 7 | if (window.innerWidth >= screenSize) { 8 | setIsScreenSmall(false); 9 | } else { 10 | setIsScreenSmall(true); 11 | } 12 | }; 13 | 14 | useEffect(() => { 15 | handleResize(); 16 | window.addEventListener("resize", handleResize); 17 | 18 | return () => window.removeEventListener("resize", handleResize); 19 | }, []); 20 | 21 | return isScreenSmall; 22 | }; 23 | 24 | export default useScreenSize; 25 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerCreatePost.tsx: -------------------------------------------------------------------------------- 1 | import ShimmerAvatar from "./ShimmerAvatar"; 2 | 3 | const ShimmerCreatePost = () => { 4 | return ( 5 | <div className="flex flex-col w-full"> 6 | <div className="flex w-full"> 7 | <ShimmerAvatar> 8 | <p className="w-full h-10 mt-6 mb-0 bg-gray-400 rounded-full animate-pulse "></p> 9 | </ShimmerAvatar> 10 | </div> 11 | <div className="flex justify-around w-full "> 12 | <div className="w-16 h-4 animate-pulse bg-slate-300"></div> 13 | <div className="w-16 h-4 animate-pulse bg-slate-300"></div> 14 | </div> 15 | </div> 16 | ); 17 | }; 18 | 19 | export default ShimmerCreatePost; 20 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerChatSection.tsx: -------------------------------------------------------------------------------- 1 | import ShimmerComment from "./ShimmerComment"; 2 | 3 | const ShimmerChatSection = () => { 4 | return ( 5 | <> 6 | {new Array(10).fill(1).map((value, index) => ( 7 | <div key={value + index} className={`flex flex-col w-full px-5 py-1 `}> 8 | <ShimmerComment 9 | isChat={true} 10 | key={value + index} 11 | className={ 12 | index % 2 === 1 13 | ? "flex-row-reverse items-end justify-center teal" 14 | : "flex-row " 15 | } 16 | /> 17 | </div> 18 | ))} 19 | </> 20 | ); 21 | }; 22 | 23 | export default ShimmerChatSection; 24 | -------------------------------------------------------------------------------- /src/features/chat/api/getAllUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { ChatUser } from "features/posts/types/postType"; 3 | import { ResponseType } from "types/responseType"; 4 | import { axios } from "../../../services/apiClient"; 5 | 6 | const getAllUsers = (showUsers: boolean) => { 7 | const queryClient = useQueryClient(); 8 | const getAllUserData = () => { 9 | return axios.get<ResponseType<ChatUser[]>>(`/chat-app/chats/users`); 10 | }; 11 | return useQuery({ 12 | queryKey: ["chats", "users"], 13 | queryFn: getAllUserData, 14 | staleTime: 1000 * 60 * 60 * 24, 15 | enabled: showUsers, 16 | }); 17 | }; 18 | 19 | export default getAllUsers; 20 | -------------------------------------------------------------------------------- /src/features/landing/Components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import Logo from "../../../components/Shared/Logo"; 3 | const Footer = () => { 4 | const { t } = useTranslation(); 5 | return ( 6 | <div className="flex flex-col items-center justify-center w-screen py-20 bg-gradient-to-b from-teal-50 to-teal-100 px-28"> 7 | <div className="-translate-x-10"> 8 | <Logo /> 9 | </div> 10 | <div className="my-5"> 11 | <p> 12 | {t("landingPage.copyright")} © {new Date().getFullYear()} Polobolo 13 | LLC. 14 | </p> 15 | <p>{t("landingPage.rights")}</p> 16 | </div> 17 | </div> 18 | ); 19 | }; 20 | 21 | export default Footer; 22 | -------------------------------------------------------------------------------- /src/features/user/api/getUserByUsername.ts: -------------------------------------------------------------------------------- 1 | import { UserProfile } from ".../../features/posts/types/postType"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { ResponseType } from "types/responseType"; 4 | import { axios } from "../../../services/apiClient"; 5 | 6 | const getUserByUsername = (userName: string | undefined) => { 7 | const getUserProfile = () => { 8 | return axios.get<ResponseType<UserProfile>>( 9 | `/social-media/profile/u/${userName}` 10 | ); 11 | }; 12 | return useQuery({ 13 | queryKey: ["user", userName], 14 | queryFn: getUserProfile, 15 | useErrorBoundary: true, 16 | staleTime: 60 * 60 * 24 * 1000, 17 | }); 18 | }; 19 | 20 | export default getUserByUsername; 21 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerPosts.tsx: -------------------------------------------------------------------------------- 1 | import Shimmer from "./Shimmer"; 2 | import ShimmerCreatePost from "./ShimmerCreatePost"; 3 | 4 | const ShimmerPosts = () => { 5 | return ( 6 | <div className="flex flex-col items-center justify-center gap-3 overflow-hidden "> 7 | <div className="w-11/12 p-4 bg-white rounded-lg shadow-2xl drop- md:w-3/5 lg:w-1/2"> 8 | <ShimmerCreatePost /> 9 | </div> 10 | {new Array(6).fill(1).map((value, index) => ( 11 | <div 12 | key={value + index} 13 | className="w-11/12 p-4 bg-white rounded-lg shadow-2xl drop- md:w-3/5 lg:w-1/2" 14 | > 15 | <Shimmer /> 16 | </div> 17 | ))} 18 | </div> 19 | ); 20 | }; 21 | 22 | export default ShimmerPosts; 23 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const isBrowser = typeof window !== undefined; 2 | export class LocalStorage { 3 | static get(key: string) { 4 | if (!isBrowser) return; 5 | const value = localStorage.getItem(key); 6 | if (value) { 7 | try { 8 | return JSON.parse(value); 9 | } catch (err) { 10 | return null; 11 | } 12 | } 13 | return null; 14 | } 15 | 16 | static set(key: string, value: any) { 17 | if (!isBrowser) return; 18 | localStorage.setItem(key, JSON.stringify(value)); 19 | } 20 | 21 | static remove(key: string) { 22 | if (!isBrowser) return; 23 | localStorage.removeItem(key); 24 | } 25 | 26 | static clear() { 27 | if (!isBrowser) return; 28 | localStorage.clear(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/features/posts/Components/PostsByTag.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary } from "react-error-boundary"; 2 | import { useParams } from "react-router-dom"; 3 | import { FallbackErrorBoundary } from "../../../components/Shared/FallbackErrorBoundary"; 4 | import Posts from "./Posts"; 5 | 6 | const PostsByTag = () => { 7 | const { tag } = useParams(); 8 | return ( 9 | <div> 10 | <h1 className="my-5 text-2xl text-center md:text-3xl"> 11 | Explore <span className="font-bold">#{tag}</span> Posts 12 | </h1> 13 | <ErrorBoundary 14 | FallbackComponent={FallbackErrorBoundary} 15 | onReset={() => {}} 16 | > 17 | <Posts tag={tag} /> 18 | </ErrorBoundary> 19 | </div> 20 | ); 21 | }; 22 | 23 | export default PostsByTag; 24 | -------------------------------------------------------------------------------- /src/hooks/useAuthCheck.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { LocalStorage } from "../utils"; 4 | import { useSelector } from "react-redux"; 5 | import { RootState } from "stores/store"; 6 | import { Author } from "features/posts/types/postType"; 7 | import { QueryClient } from "@tanstack/react-query"; 8 | 9 | const queryClient = new QueryClient(); 10 | const useAuthCheck = () => { 11 | const user = queryClient.getQueryData(["user"]); 12 | const userData = useSelector<RootState, boolean>( 13 | (store) => store.user.isLoggedIn 14 | ); 15 | const accessToken: string = LocalStorage.get("accessToken"); 16 | 17 | return userData || !!accessToken; 18 | }; 19 | 20 | export default useAuthCheck; 21 | -------------------------------------------------------------------------------- /src/components/Shared/Like.tsx: -------------------------------------------------------------------------------- 1 | import { faHeart } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { useState } from "react"; 4 | 5 | type LikeProps = { 6 | className?: string; 7 | isLike?: boolean; 8 | }; 9 | const Like = ({ className, isLike }: LikeProps) => { 10 | const [isLiked, setIsLiked] = useState(isLike ? isLike : false); 11 | return ( 12 | <button 13 | onClick={() => setIsLiked((prevIsLiked) => !prevIsLiked)} 14 | className={` hover:animate-pulse hover:text-red-600 ${ 15 | isLiked ? "text-red-500 " : " text-slate-200 " 16 | } ${className}`} 17 | > 18 | <FontAwesomeIcon icon={faHeart} /> 19 | </button> 20 | ); 21 | }; 22 | 23 | export default Like; 24 | -------------------------------------------------------------------------------- /src/features/posts/Components/BookmarkedPosts.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { Navigate, useParams } from "react-router-dom"; 3 | import { RootState } from "../../../stores/store"; 4 | import { Author } from "../types/postType"; 5 | import Posts from "./Posts"; 6 | 7 | const Bookmarks = () => { 8 | const user = useSelector<RootState, Author | undefined>( 9 | (store) => store.user.user 10 | ); 11 | const { username } = useParams(); 12 | 13 | if (user?.account.username !== username) { 14 | return <Navigate to={`/user/${username}/about`} />; 15 | } 16 | return ( 17 | <div> 18 | <h1 className="my-5 text-2xl text-center md:text-3xl"></h1> 19 | <Posts bookmarks={true} /> 20 | </div> 21 | ); 22 | }; 23 | 24 | export default Bookmarks; 25 | -------------------------------------------------------------------------------- /src/features/chat/api/getChatMessages.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { JOIN_CHAT_EVENT } from "../../../config/constants"; 3 | import { useSocket } from "../../../context/SocketContext"; 4 | import { axios } from "../../../services/apiClient"; 5 | 6 | const getChatMessages = (chatId: string | undefined) => { 7 | const { socket } = useSocket(); 8 | const getUserChatMessages = () => { 9 | return axios.get(`/chat-app/messages/${chatId}`); 10 | }; 11 | return useQuery({ 12 | queryKey: ["chats", "messages", chatId], 13 | useErrorBoundary: true, 14 | queryFn: getUserChatMessages, 15 | staleTime: 1000 * 60 * 60, 16 | onSuccess: (data) => { 17 | socket?.emit(JOIN_CHAT_EVENT, chatId); 18 | }, 19 | }); 20 | }; 21 | 22 | export default getChatMessages; 23 | -------------------------------------------------------------------------------- /src/features/posts/Components/PostAuthor.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import AuthorProfile from "../../user/Components/AuthorProfile"; 3 | import { PostCardProp } from "../types/postType"; 4 | 5 | type PostAuthor = { 6 | className?: string; 7 | children: ReactNode; 8 | }; 9 | const PostAuthor = ({ 10 | post, 11 | className, 12 | children, 13 | }: PostCardProp & PostAuthor) => { 14 | return ( 15 | <div className={` ${className}`}> 16 | <AuthorProfile 17 | username={post?.author?.account?.username} 18 | url={post.author.account.avatar.url} 19 | firstName={post.author.firstName} 20 | lastName={post.author.lastName} 21 | bio={post.author.bio} 22 | /> 23 | 24 | {children} 25 | </div> 26 | ); 27 | }; 28 | 29 | export default PostAuthor; 30 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | declare module "axios" { 4 | export interface AxiosInstance { 5 | request<T = any>(config: AxiosRequestConfig): Promise<T>; 6 | get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>; 7 | delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>; 8 | head<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>; 9 | post<T = any>( 10 | url: string, 11 | data?: any, 12 | config?: AxiosRequestConfig 13 | ): Promise<T>; 14 | put<T = any>( 15 | url: string, 16 | data?: any, 17 | config?: AxiosRequestConfig 18 | ): Promise<T>; 19 | patch<T = any>( 20 | url: string, 21 | data?: any, 22 | config?: AxiosRequestConfig 23 | ): Promise<T>; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/features/landing/Components/MarketingFuture.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import FreeAccess from "../../../assets/images/free-access.svg"; 3 | import MailingList from "./MailingList"; 4 | const MarketingFuture = () => { 5 | const { t } = useTranslation(); 6 | return ( 7 | <div className="flex flex-col-reverse items-center p-10 mt-20 md:p-20 lg:p-40 md:flex-row bg-orange-dim "> 8 | <div className="w-full md:w-1/2 "> 9 | <h2 className="my-5 text-4xl font-bold leading-snug tracking-wider"> 10 | {t("landingPage.marketingFuture")} 11 | </h2> 12 | <MailingList /> 13 | </div> 14 | <div className="flex justify-end w-1/2"> 15 | <img src={FreeAccess} alt="free-access" className="w-52" /> 16 | </div> 17 | </div> 18 | ); 19 | }; 20 | 21 | export default MarketingFuture; 22 | -------------------------------------------------------------------------------- /src/features/posts/utils/settingSchema.ts: -------------------------------------------------------------------------------- 1 | import { t } from "i18next"; 2 | import { z } from "zod"; 3 | 4 | export const settingsValidationSchema = z.object({ 5 | firstName: z 6 | .string() 7 | .min(3, { message: t("validationMessages.firstName") }) 8 | .max(255, { message: t("validationMessages.maxFirstName") }), 9 | lastName: z 10 | .string() 11 | .min(3, { message: t("validationMessages.lastName") }) 12 | .max(255, { message: t("validationMessages.maxLastName") }), 13 | bio: z 14 | .string() 15 | .min(10, { message: t("validationMessages.bio") }) 16 | .max(255, { message: t("validationMessages.maxBio") }), 17 | location: z.string().max(100).min(1), 18 | dob: z.string(), 19 | phoneNumber: z.string().regex(/^\d{9,15}$/), 20 | }); 21 | 22 | export type SettingsValidationSchema = z.infer<typeof settingsValidationSchema>; 23 | -------------------------------------------------------------------------------- /src/stores/store.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, configureStore } from "@reduxjs/toolkit"; 2 | import notificationReducer from "./notificationSlice"; 3 | import refetchReducer from "./refetchSlice"; 4 | import userSlice from "./userSlice"; 5 | 6 | const loggerMiddleware: Middleware = () => (next) => (action) => { 7 | console.log("Dispatching action:", action); 8 | return next(action); 9 | }; 10 | 11 | const store = configureStore({ 12 | reducer: { 13 | notification: notificationReducer, 14 | refetch: refetchReducer, 15 | user: userSlice, 16 | }, 17 | middleware: (getDefaultMiddleware) => 18 | getDefaultMiddleware({ 19 | serializableCheck: false, 20 | }).concat(loggerMiddleware), 21 | }); 22 | 23 | export default store; 24 | 25 | export type AppDispatch = typeof store.dispatch; 26 | export type RootState = ReturnType<typeof store.getState>; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "src" 19 | }, 20 | "typeRoots": ["node_modules/@types", "src/types"], 21 | "include": ["src"], 22 | "exclude": ["src/features/chat/Components/AvailableUsers.tsx"] 23 | // "references": [{ "path": "./tsconfig.node.json" }] 24 | // "paths": { 25 | // "src/*": ["./src/*"] 26 | // } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Form/TextareaField.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { UseFormRegisterReturn } from "react-hook-form"; 3 | 4 | import { FieldWrapper, FieldWrapperPassThroughProps } from "./FieldWrapper"; 5 | 6 | type TextAreaFieldProps = FieldWrapperPassThroughProps & { 7 | className?: string; 8 | registration: Partial<UseFormRegisterReturn>; 9 | }; 10 | 11 | export const TextAreaField = (props: TextAreaFieldProps) => { 12 | const { label, className, registration, error } = props; 13 | return ( 14 | <FieldWrapper label={label} error={error}> 15 | <textarea 16 | className={clsx( 17 | "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm", 18 | className 19 | )} 20 | {...registration} 21 | /> 22 | </FieldWrapper> 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/features/posts/Components/Tags.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useParams } from "react-router-dom"; 2 | import { PostCardProp } from "../types/postType"; 3 | 4 | const Tags = ({ post }: PostCardProp) => { 5 | const { tag } = useParams(); 6 | return ( 7 | <> 8 | <div className="flex flex-wrap mt-4 text-sm gap-x-2 gap-y-3"> 9 | {post.tags.map((currentTag, index) => ( 10 | <Link key={index + currentTag} to={`/posts/tags/${currentTag}`}> 11 | <span 12 | className={`px-2 py-1 mr-2 rounded-full cursor-pointer ${ 13 | currentTag === tag 14 | ? "bg-slate-700 text-gray-200 font-bold" 15 | : " text-gray-700 bg-gray-200 " 16 | }`} 17 | > 18 | #{currentTag} 19 | </span> 20 | </Link> 21 | ))} 22 | </div> 23 | </> 24 | ); 25 | }; 26 | 27 | export default Tags; 28 | -------------------------------------------------------------------------------- /src/assets/images/w-linkedin.svg: -------------------------------------------------------------------------------- 1 | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M13 21H9V9H13V11C13.4211 10.4643 13.9555 10.0284 14.565 9.72356C15.1744 9.41875 15.8438 9.25259 16.525 9.237C17.7164 9.24361 18.8565 9.72242 19.6954 10.5684C20.5343 11.4144 21.0035 12.5586 21 13.75V21H17V14.25C16.9226 13.7096 16.6527 13.2153 16.2398 12.8581C15.8269 12.5009 15.2989 12.3048 14.753 12.306C14.5087 12.3137 14.2684 12.3707 14.0466 12.4736C13.8249 12.5765 13.6262 12.7231 13.4624 12.9046C13.2987 13.0862 13.1734 13.2989 13.0939 13.5301C13.0144 13.7613 12.9825 14.0062 13 14.25V21ZM7 21H3V9H7V21ZM5 7C4.46957 7 3.96086 6.78929 3.58579 6.41421C3.21071 6.03914 3 5.53043 3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3C5.53043 3 6.03914 3.21071 6.41421 3.58579C6.78929 3.96086 7 4.46957 7 5C7 5.53043 6.78929 6.03914 6.41421 6.41421C6.03914 6.78929 5.53043 7 5 7Z" fill="white"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /src/components/Form/InputFieldForm.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { UseFormRegisterReturn } from "react-hook-form"; 3 | 4 | import { FieldWrapper, FieldWrapperPassThroughProps } from "./FieldWrapper"; 5 | 6 | type InputFieldProps = FieldWrapperPassThroughProps & { 7 | type?: "text" | "email" | "password"; 8 | className?: string; 9 | registration: Partial<UseFormRegisterReturn>; 10 | }; 11 | 12 | export const InputField = (props: InputFieldProps) => { 13 | const { type = "text", label, className, registration, error } = props; 14 | return ( 15 | <FieldWrapper label={label} error={error}> 16 | <input 17 | type={type} 18 | className={clsx( 19 | "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 sm:text-sm", 20 | className 21 | )} 22 | {...registration} 23 | /> 24 | </FieldWrapper> 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerComment.tsx: -------------------------------------------------------------------------------- 1 | import ShimmerAvatar from "./ShimmerAvatar"; 2 | 3 | const ShimmerComment = ({ 4 | isChat, 5 | className, 6 | }: { 7 | isChat?: boolean; 8 | className?: string; 9 | }) => { 10 | return ( 11 | <div className={`flex w-full ${className}`}> 12 | <div className="flex flex-col w-full"> 13 | <ShimmerAvatar className={className}> 14 | <p 15 | className={`${className ? "w-0" : "w-full"} 16 | bg-gray-400 17 | h-2 mt-2 mb-2 animate-pulse`} 18 | ></p> 19 | {!isChat ? ( 20 | <p className={`w-10/12 h-2 mb-2 bg-gray-400 animate-pulse`}></p> 21 | ) : null} 22 | </ShimmerAvatar> 23 | </div> 24 | <div 25 | className={`${ 26 | isChat ? " h-2 w-16 " : "w-4 h-4" 27 | } animate-pulse bg-slate-500`} 28 | ></div> 29 | </div> 30 | ); 31 | }; 32 | 33 | export default ShimmerComment; 34 | -------------------------------------------------------------------------------- /src/features/posts/api/postBookmark.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { axios } from "../../../services/apiClient"; 4 | import { addNotification } from "../../../stores/notificationSlice"; 5 | import store from "../../../stores/store"; 6 | 7 | const postBookmark = (postId: string | undefined) => { 8 | const { t } = useTranslation(); 9 | const postData = () => { 10 | return axios.post(`/social-media/bookmarks/${postId}`); 11 | }; 12 | return useMutation({ 13 | mutationFn: postData, 14 | onError: () => {}, 15 | onSuccess: (response) => { 16 | const { dispatch } = store; 17 | 18 | dispatch( 19 | addNotification({ 20 | type: "success", 21 | title: t("notification.success"), 22 | message: t("notificationMessages.bookmarkPost"), 23 | }) 24 | ); 25 | }, 26 | }); 27 | }; 28 | 29 | export default postBookmark; 30 | -------------------------------------------------------------------------------- /src/components/Elements/CloseModal.tsx: -------------------------------------------------------------------------------- 1 | import { faTimes } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { Button } from "./Button"; 4 | 5 | type CloseModalProps = { 6 | closeModal: () => void; 7 | closeComment?: boolean; 8 | variant?: string; 9 | }; 10 | const CloseModal = ({ closeModal, closeComment, variant }: CloseModalProps) => { 11 | return ( 12 | <Button 13 | variant={variant ? "transparent" : closeComment ? "transparent" : "blend"} 14 | className={`${ 15 | closeComment 16 | ? "-translate-y-1 border-0 text-slate-600" 17 | : "fixed top-1 md:top-1 right-1 " 18 | } w-10 h-10 p-0 px-0 py-0 rounded-full focus:outline-none bg-slate-300 `} 19 | onClick={closeModal} 20 | style={{ padding: "0px" }} 21 | > 22 | <FontAwesomeIcon icon={faTimes} className="text-xl" /> 23 | </Button> 24 | ); 25 | }; 26 | 27 | export default CloseModal; 28 | -------------------------------------------------------------------------------- /src/components/Shared/Bookmark.tsx: -------------------------------------------------------------------------------- 1 | import { faBookmark } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { useState } from "react"; 4 | 5 | type BookmarkProps = { 6 | className?: string; 7 | handleBookmarkPost: () => void; 8 | isBookmarkedPost: boolean; 9 | }; 10 | const Bookmark = ({ 11 | className, 12 | handleBookmarkPost, 13 | isBookmarkedPost, 14 | }: BookmarkProps) => { 15 | const [isBookmarked, setIsBookmarked] = useState(isBookmarkedPost); 16 | return ( 17 | <button 18 | onClick={() => { 19 | setIsBookmarked((prevIsBookmarked) => !prevIsBookmarked); 20 | handleBookmarkPost(); 21 | }} 22 | className={` hover:animate-pulse hover:text-teal-600 ${ 23 | isBookmarked ? "text-teal-500 " : " text-slate-200 " 24 | } ${className}`} 25 | > 26 | <FontAwesomeIcon icon={faBookmark} /> 27 | </button> 28 | ); 29 | }; 30 | 31 | export default Bookmark; 32 | -------------------------------------------------------------------------------- /src/features/landing/Components/MailingList.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { Button } from "../../../components/Elements/Button"; 5 | 6 | const MailingList = () => { 7 | const { t } = useTranslation(); 8 | const navigate = useNavigate(); 9 | const mailingInputRef = useRef<HTMLInputElement>(null); 10 | return ( 11 | <form 12 | onSubmit={() => { 13 | navigate(`/signup/${mailingInputRef.current?.value}`); 14 | }} 15 | className="flex p-1 mb-10 border-2 rounded-lg border-slate-300 w-80" 16 | > 17 | <input 18 | ref={mailingInputRef} 19 | type="text" 20 | placeholder={t("landingPage.signupMailingList")} 21 | className="w-3/4 p-2 text-xs border-0 outline-none" 22 | /> 23 | <Button type="submit">{t("landingPage.signup")}</Button> 24 | </form> 25 | ); 26 | }; 27 | 28 | export default MailingList; 29 | -------------------------------------------------------------------------------- /src/features/user/hooks/usePreviousPage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | import { LocalStorage } from "../../../utils/index"; 4 | 5 | const usePreviousPage = () => { 6 | const location = useLocation(); 7 | const navigate = useNavigate(); 8 | 9 | useEffect(() => { 10 | const popStateHandler = (event: PopStateEvent) => { 11 | const currentLocation = (event.target as Window)?.location.pathname; 12 | if ( 13 | (currentLocation.includes("/login") && 14 | LocalStorage.get("accessToken")) || 15 | currentLocation.includes("/signup") 16 | ) { 17 | navigate("/home"); 18 | } 19 | if (LocalStorage.get("accessToken")) { 20 | navigate("/login"); 21 | } 22 | }; 23 | window.addEventListener("popstate", popStateHandler, false); 24 | 25 | return () => window.removeEventListener("popstate", popStateHandler); 26 | }, []); 27 | }; 28 | 29 | export default usePreviousPage; 30 | -------------------------------------------------------------------------------- /src/features/auth/api/logoutUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { axios } from "../../../services/apiClient"; 4 | import store from "../../../stores/store"; 5 | import { handleLogoutUser } from "../../../stores/userSlice"; 6 | import { LocalStorage } from "../../../utils"; 7 | 8 | const useLogoutUser = () => { 9 | const navigate = useNavigate(); 10 | const queryClient = useQueryClient(); 11 | const logoutUser = () => { 12 | return axios.post("/users/logout"); 13 | }; 14 | return useMutation({ 15 | mutationFn: logoutUser, 16 | onSuccess: (response) => { 17 | const statusCode = response.statusCode; 18 | if (statusCode === 200) { 19 | LocalStorage.remove("accessToken"); 20 | queryClient.removeQueries(["auth-user"]); 21 | const { dispatch } = store; 22 | dispatch(handleLogoutUser()); 23 | navigate("/login"); 24 | } 25 | }, 26 | }); 27 | }; 28 | 29 | export default useLogoutUser; 30 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | @import url("https://fonts.googleapis.com/css2?family=Manrope&family=Mooli&display=swap"); 5 | @import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap"); 6 | @import url("https://fonts.googleapis.com/css2?family=Cedarville+Cursive&display=swap"); 7 | @import url("https://fonts.googleapis.com/css2?family=Allura&display=swap"); 8 | .deleteDialog { 9 | min-height: 12rem; 10 | } 11 | 12 | @keyframes bounce { 13 | 0% { 14 | transform: translateY(-60%); 15 | opacity: 0.7; 16 | } 17 | 100% { 18 | transform: translateY(20%); 19 | opacity: 1; 20 | } 21 | } 22 | 23 | .animation1 { 24 | animation: bounce 0.4s ease-in-out infinite alternate; 25 | animation-delay: -0.4s; 26 | } 27 | 28 | .animation2 { 29 | animation: bounce 0.4s ease-in-out infinite alternate; 30 | animation-delay: -0.2s; 31 | } 32 | 33 | .animation3 { 34 | animation: bounce 0.4s ease-in-out infinite alternate; 35 | animation-delay: 0s; 36 | } 37 | -------------------------------------------------------------------------------- /src/features/auth/Components/Container.tsx: -------------------------------------------------------------------------------- 1 | import { faBolt } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { ReactNode } from "react"; 4 | import Logo from "../../../components/Shared/Logo"; 5 | interface ContainerProps { 6 | children: ReactNode; 7 | } 8 | const Container = ({ children }: ContainerProps) => { 9 | return ( 10 | <div className="flex flex-col w-screen h-screen md:flex-row font-montserrat"> 11 | <div className="flex items-center justify-center h-2/5 md:h-full md:w-2/5 bg-gradient-to-b from-teal-200 to-teal-500 "> 12 | <div className="relative "> 13 | <FontAwesomeIcon 14 | icon={faBolt} 15 | className="absolute text-teal-500 text-9xl left-1/3 -top-1/2" 16 | /> 17 | <Logo className="text-white cursor-default" /> 18 | </div> 19 | </div> 20 | <div className="flex items-center justify-center w-full md:h-full md:w-3/5"> 21 | {children} 22 | </div> 23 | </div> 24 | ); 25 | }; 26 | 27 | export default Container; 28 | -------------------------------------------------------------------------------- /src/components/Form/FieldWrapper.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import * as React from "react"; 3 | import { FieldError } from "react-hook-form"; 4 | 5 | type FieldWrapperProps = { 6 | label?: string; 7 | className?: string; 8 | children: React.ReactNode; 9 | error?: FieldError | undefined; 10 | description?: string; 11 | }; 12 | 13 | export type FieldWrapperPassThroughProps = Omit< 14 | FieldWrapperProps, 15 | "className" | "children" 16 | >; 17 | 18 | export const FieldWrapper = (props: FieldWrapperProps) => { 19 | const { label, className, error, children } = props; 20 | return ( 21 | <div> 22 | <label 23 | className={clsx("block text-sm font-medium text-gray-700", className)} 24 | > 25 | {label} 26 | <div className="mt-1">{children}</div> 27 | </label> 28 | {error?.message && ( 29 | <div 30 | role="alert" 31 | aria-label={error.message} 32 | className="text-sm font-semibold text-red-500" 33 | > 34 | {error.message} 35 | </div> 36 | )} 37 | </div> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/stores/notificationSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { nanoid } from "nanoid"; 3 | 4 | export type Notification = { 5 | id: string; 6 | type: "info" | "warning" | "success" | "error"; 7 | title: string; 8 | message?: string; 9 | }; 10 | 11 | const initialState: { notifications: Notification[] } = { 12 | notifications: [], 13 | }; 14 | 15 | export const notificationSlice = createSlice({ 16 | name: "notification", 17 | initialState, 18 | reducers: { 19 | addNotification: ( 20 | state, 21 | action: PayloadAction<Omit<Notification, "id">> 22 | ) => { 23 | state.notifications.push({ ...action.payload, id: nanoid() }); 24 | }, 25 | dismissNotification: (state, action: PayloadAction<Notification>) => { 26 | state.notifications = state.notifications.filter( 27 | (notification) => notification.id !== action.payload.id 28 | ); 29 | }, 30 | }, 31 | }); 32 | 33 | export const { addNotification, dismissNotification } = 34 | notificationSlice.actions; 35 | export default notificationSlice.reducer; 36 | -------------------------------------------------------------------------------- /src/components/Notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import { RootState } from "stores/store"; 3 | import { 4 | Notification, 5 | dismissNotification, 6 | } from "../../stores/notificationSlice"; 7 | import { SingleNotification } from "./Notification"; 8 | 9 | export const Notifications = () => { 10 | const notifications = useSelector<RootState, Notification[]>( 11 | (store) => store.notification.notifications 12 | ); 13 | const dispatch = useDispatch(); 14 | const handleDismissNotification = (notification: Notification) => { 15 | dispatch(dismissNotification(notification)); 16 | }; 17 | 18 | return ( 19 | <div className="fixed inset-0 z-50 flex flex-col items-end px-4 py-6 space-y-4 pointer-events-none sm:p-6 sm:items-start"> 20 | {notifications.map((notification) => { 21 | return ( 22 | <SingleNotification 23 | key={notification.id} 24 | notification={notification} 25 | onDismiss={() => handleDismissNotification(notification)} 26 | /> 27 | ); 28 | })} 29 | </div> 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/features/auth/Components/__tests__/login.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render } from "@testing-library/react"; 3 | import App from "../../../../App"; 4 | import Login from "../Login"; 5 | import * as ReactDOM from "react-dom"; 6 | test("demo", () => { 7 | expect(true).toBe(true); 8 | }); 9 | 10 | test("Renders the main page", () => { 11 | // render(<App />); 12 | render(<App />); 13 | expect(true).toBeTruthy(); 14 | }); 15 | 16 | describe("Login component tests", () => { 17 | let container: HTMLDivElement; 18 | beforeEach(() => { 19 | container = document.createElement("div"); 20 | document.body.appendChild(container); 21 | ReactDOM.render(<Login />, container); 22 | }); 23 | 24 | afterEach(() => { 25 | document.body.removeChild(container); 26 | container.remove(); 27 | }); 28 | it("Renders all inputs fields correctly", () => { 29 | const inputs = container.querySelectorAll("input"); 30 | expect(inputs).toHaveLength(2); 31 | 32 | expect(inputs[0].name.toLowerCase()).toBe("email"); 33 | expect(inputs[1].name.toLowerCase()).toBe("email"); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/hooks/useInfiniteScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useInfiniteScroll = (fetchNextPage: any) => { 4 | const [isLoading, setIsLoading] = useState(false); 5 | useEffect(() => { 6 | let debounceTimer: number; 7 | const handleScroll = () => { 8 | clearTimeout(debounceTimer); 9 | debounceTimer = setTimeout(() => { 10 | const windowHeight = window.innerHeight; 11 | const scrollY = window.scrollY || window.pageYOffset; 12 | const pageHeight = document.body.scrollHeight; 13 | const threshold = 100; 14 | 15 | if (!isLoading && windowHeight + scrollY >= pageHeight - threshold) { 16 | setIsLoading(true); 17 | fetchNextPage() 18 | .then(() => setIsLoading(false)) 19 | .catch(() => setIsLoading(false)); 20 | } 21 | }, 200); 22 | }; 23 | 24 | window.addEventListener("scroll", handleScroll); 25 | return () => { 26 | window.removeEventListener("scroll", handleScroll); 27 | clearTimeout(debounceTimer); 28 | }; 29 | }, [fetchNextPage, isLoading]); 30 | }; 31 | 32 | export default useInfiniteScroll; 33 | -------------------------------------------------------------------------------- /src/features/chat/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { Author, Chat } from "features/posts/types/postType"; 2 | 3 | export const getChatObjectMetadata = ( 4 | chat: Chat, 5 | loggedInUser: Author | undefined 6 | ) => { 7 | const lastMessage = chat.lastMessage?.content 8 | ? chat.lastMessage?.content 9 | : chat.lastMessage 10 | ? `${chat.lastMessage?.attachments?.length} attachment${ 11 | chat.lastMessage.attachments.length > 1 ? "s" : "" 12 | }` 13 | : "No messages yet"; 14 | 15 | if (chat.isGroupChat) { 16 | return { 17 | avatar: "https://via.placeholder.com/100x100.png", 18 | title: chat.name, 19 | description: `${chat.participants.length} members in the chat`, 20 | lastMessage: chat.lastMessage 21 | ? chat.lastMessage?.sender?.username + ": " + lastMessage 22 | : lastMessage, 23 | }; 24 | } else { 25 | const participant = chat.participants.find( 26 | (p) => p._id !== loggedInUser?.account._id 27 | ); 28 | return { 29 | avatar: participant?.avatar.url, 30 | title: participant?.username, 31 | description: participant?.email, 32 | lastMessage, 33 | }; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/features/posts/api/deletePost.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { axios } from "../../../services/apiClient"; 4 | import { addNotification } from "../../../stores/notificationSlice"; 5 | import store from "../../../stores/store"; 6 | 7 | const deletePost = (postId: string | undefined) => { 8 | const queryClient = useQueryClient(); 9 | const { t } = useTranslation(); 10 | const deleteData = () => { 11 | const config = { 12 | headers: { 13 | "Content-Type": "multipart/form-data", 14 | }, 15 | }; 16 | return axios.delete(`/social-media/posts/${postId}`, config); 17 | }; 18 | return useMutation({ 19 | mutationFn: deleteData, 20 | onError: () => {}, 21 | onSuccess: (response) => { 22 | queryClient.invalidateQueries(["posts"]); 23 | const { dispatch } = store; 24 | dispatch( 25 | addNotification({ 26 | type: "success", 27 | title: t("notification.success"), 28 | message: t("notificationMessages.deletePost"), 29 | }) 30 | ); 31 | }, 32 | }); 33 | }; 34 | 35 | export default deletePost; 36 | -------------------------------------------------------------------------------- /src/components/Shared/TestErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ErrorBoundary } from "react-error-boundary"; 3 | import { FallbackErrorBoundary } from "./FallbackErrorBoundary"; 4 | 5 | const BuggyCounter = () => { 6 | const [counter, setCounter] = useState(0); 7 | if (counter === 5) { 8 | throw new Error("I crashed"); 9 | } 10 | return ( 11 | <button 12 | className="border-8" 13 | onClick={() => { 14 | setCounter((prevCount) => prevCount + 1); 15 | }} 16 | > 17 | {counter} 18 | </button> 19 | ); 20 | }; 21 | 22 | const TestErrorBoundary = () => { 23 | return ( 24 | <div className="p-10"> 25 | <ErrorBoundary FallbackComponent={FallbackErrorBoundary}> 26 | <BuggyCounter /> 27 | <br /> 28 | <BuggyCounter /> 29 | </ErrorBoundary> 30 | 31 | <hr /> 32 | <ErrorBoundary FallbackComponent={FallbackErrorBoundary}> 33 | <BuggyCounter /> 34 | </ErrorBoundary> 35 | <br /> 36 | 37 | <ErrorBoundary FallbackComponent={FallbackErrorBoundary}> 38 | <BuggyCounter /> 39 | </ErrorBoundary> 40 | </div> 41 | ); 42 | }; 43 | 44 | export default TestErrorBoundary; 45 | -------------------------------------------------------------------------------- /src/features/auth/api/registerUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { axios } from "../../../services/apiClient"; 5 | import { addNotification } from "../../../stores/notificationSlice"; 6 | import store from "../../../stores/store"; 7 | 8 | export interface SignupData { 9 | email: string; 10 | password: string; 11 | role: string; 12 | username: string; 13 | } 14 | const useRegisterUser = () => { 15 | const navigate = useNavigate(); 16 | const { t } = useTranslation(); 17 | const registerUser = (signupData: SignupData) => { 18 | return axios.post("/users/register", signupData); 19 | }; 20 | return useMutation({ 21 | mutationKey: ["register"], 22 | mutationFn: registerUser, 23 | onSuccess: () => { 24 | const { dispatch } = store; 25 | dispatch( 26 | addNotification({ 27 | type: "success", 28 | title: t("notification.success"), 29 | message: t("notificationMessages.signupUser"), 30 | }) 31 | ); 32 | navigate("/login"); 33 | }, 34 | }); 35 | }; 36 | 37 | export default useRegisterUser; 38 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerAvatar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | type ShimmerAvatarProps = { 3 | children?: ReactNode; 4 | className?: string; 5 | }; 6 | const ShimmerAvatar = ({ children, className }: ShimmerAvatarProps) => { 7 | return ( 8 | <div className={`flex w-full ${className ? className : "mb-8"}`}> 9 | <div className="self-start w-1/6"> 10 | <div className="w-12 h-12 rounded-full image bg-slate-200"></div> 11 | </div> 12 | <div className={`w-full mx-3 flex flex-col ${className} `}> 13 | <h1 14 | className={`w-1/2 h-6 mb-2 15 | ${ 16 | className?.includes("teal") ? "bg-teal-200" : "bg-gray-400" 17 | } animate-pulse`} 18 | ></h1> 19 | <h2 20 | className={`w-1/4 h-2 mb-4 21 | ${className?.includes("teal") ? "bg-teal-200" : "bg-gray-400"} 22 | animate-pulse`} 23 | ></h2> 24 | {children} 25 | </div> 26 | </div> 27 | ); 28 | }; 29 | 30 | export default ShimmerAvatar; 31 | 32 | export const ShimmerAvatars = () => ( 33 | <> 34 | {new Array(6).fill(1).map((profile, index) => ( 35 | <ShimmerAvatar key={profile + index} /> 36 | ))} 37 | </> 38 | ); 39 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 3 | import { FallbackErrorBoundary } from "./components/Shared/FallbackErrorBoundary"; 4 | import ReactDOM from "react-dom/client"; 5 | import { ErrorBoundary } from "react-error-boundary"; 6 | import { Provider } from "react-redux"; 7 | import { Notifications } from "./components/Notifications/Notifications"; 8 | import { LanguageProvider } from "./context/LanguageContext"; 9 | import "./index.css"; 10 | import { AppRoutes } from "./routes/AppRouter"; 11 | import "./services/i18n"; 12 | import store from "./stores/store"; 13 | const queryClient = new QueryClient(); 14 | 15 | const RootApp = () => ( 16 | <ErrorBoundary FallbackComponent={FallbackErrorBoundary}> 17 | <Provider store={store}> 18 | <QueryClientProvider client={queryClient}> 19 | <LanguageProvider> 20 | <Notifications /> 21 | <AppRoutes /> 22 | <ReactQueryDevtools /> 23 | </LanguageProvider> 24 | </QueryClientProvider> 25 | </Provider> 26 | </ErrorBoundary> 27 | ); 28 | ReactDOM.createRoot(document.getElementById("root")!).render(<RootApp />); 29 | -------------------------------------------------------------------------------- /src/utils/navbarLinks.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faBookmark, 3 | faGear, 4 | faPencilSquare, 5 | faUser, 6 | } from "@fortawesome/free-solid-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import { useMemo } from "react"; 9 | import { useTranslation } from "react-i18next"; 10 | const useLinks = () => { 11 | const { t } = useTranslation(); 12 | return useMemo( 13 | () => [ 14 | { 15 | id: 1, 16 | link: "about", 17 | text: t("userPages.about"), 18 | icon: <FontAwesomeIcon icon={faUser} />, 19 | }, 20 | { 21 | id: 2, 22 | link: "posts", 23 | text: t("userPages.posts"), 24 | icon: <FontAwesomeIcon icon={faPencilSquare} />, 25 | }, 26 | { 27 | id: 3, 28 | link: "bookmarks", 29 | text: t("userPages.bookmarks"), 30 | icon: <FontAwesomeIcon icon={faBookmark} />, 31 | }, 32 | { 33 | id: 4, 34 | link: "settings", 35 | text: t("userPages.settings"), 36 | icon: <FontAwesomeIcon icon={faGear} />, 37 | }, 38 | ], 39 | [t] // No dependencies, meaning it will only be calculated once 40 | ); 41 | }; 42 | 43 | export default useLinks; 44 | -------------------------------------------------------------------------------- /src/assets/images/w-twitter.svg: -------------------------------------------------------------------------------- 1 | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M7.55016 21.7502C16.6045 21.7502 21.5583 14.2469 21.5583 7.74211C21.5583 7.53117 21.5536 7.31554 21.5442 7.1046C22.5079 6.40771 23.3395 5.5445 24 4.55554C23.1025 4.95484 22.1496 5.21563 21.1739 5.32898C22.2013 4.71315 22.9705 3.74572 23.3391 2.60601C22.3726 3.1788 21.3156 3.58286 20.2134 3.80085C19.4708 3.01181 18.489 2.48936 17.4197 2.3143C16.3504 2.13923 15.2532 2.32129 14.2977 2.83234C13.3423 3.34339 12.5818 4.15495 12.1338 5.14156C11.6859 6.12816 11.5754 7.23486 11.8195 8.29054C9.86249 8.19233 7.94794 7.68395 6.19998 6.79834C4.45203 5.91274 2.90969 4.66968 1.67297 3.14976C1.0444 4.23349 0.852057 5.51589 1.13503 6.73634C1.418 7.95678 2.15506 9.02369 3.19641 9.72023C2.41463 9.69541 1.64998 9.48492 0.965625 9.10617V9.1671C0.964925 10.3044 1.3581 11.4068 2.07831 12.287C2.79852 13.1672 3.80132 13.7708 4.91625 13.9952C4.19206 14.1934 3.43198 14.2222 2.69484 14.0796C3.00945 15.0577 3.62157 15.9131 4.44577 16.5266C5.26997 17.14 6.26512 17.4808 7.29234 17.5015C5.54842 18.8714 3.39417 19.6144 1.17656 19.6109C0.783287 19.6103 0.390399 19.5861 0 19.5387C2.25286 20.984 4.87353 21.7516 7.55016 21.7502Z" fill="white"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /src/features/auth/utils/signupValidation.ts: -------------------------------------------------------------------------------- 1 | import { t } from "i18next"; 2 | import { z } from "zod"; 3 | export const signupValidationSchema = z 4 | .object({ 5 | firstName: z 6 | .string() 7 | .min(3, { message: t("validationMessages.firstName") }), 8 | lastName: z.string().min(3, { message: t("validationMessages.lastName") }), 9 | email: z.string().email({ 10 | message: t("validationMessages.validEmail"), 11 | }), 12 | password: z 13 | .string() 14 | .min(8, { message: t("validationMessages.minPassword") }) 15 | .max(30, { message: t("validationMessages.maxPassword") }) 16 | .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, { 17 | message: t("validationMessages.passwordRequirement"), 18 | }), 19 | confirmPassword: z 20 | .string() 21 | .min(1, { message: t("validationMessages.confPassReq") }), 22 | terms: z.literal(true, { 23 | errorMap: () => ({ message: t("validationMessages.acceptTOC") }), 24 | }), 25 | }) 26 | .refine((data) => data.password === data.confirmPassword, { 27 | path: ["confirmPassword"], 28 | message: t("validationMessages.matchPassword"), 29 | }); 30 | 31 | export type SignupValidationSchema = z.infer<typeof signupValidationSchema>; 32 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import { Outlet } from "react-router-dom"; 3 | import { SocketProvider } from "./context/SocketContext"; 4 | import Navbar from "./features/landing/Components/Navbar"; 5 | import getUser from "./features/user/api/getUser"; 6 | import useAuthCheck from "./hooks/useAuthCheck"; 7 | import { addUser } from "./stores/userSlice"; 8 | 9 | const App = () => { 10 | const loggedIn = useAuthCheck(); 11 | 12 | if (!loggedIn) { 13 | return ( 14 | <> 15 | <Outlet /> 16 | </> 17 | ); 18 | } 19 | const { isLoading, userData } = fetchUserData(); 20 | 21 | return ( 22 | <SocketProvider> 23 | <div className="min-w-full overflow-x-hidden font-montserrat"> 24 | <Navbar 25 | user={userData} 26 | isLoggedIn={loggedIn} 27 | isLoading={isLoading ?? true} 28 | /> 29 | <Outlet /> 30 | </div> 31 | </SocketProvider> 32 | ); 33 | }; 34 | 35 | const fetchUserData = () => { 36 | const { isLoading, data } = getUser(); 37 | const dispatch = useDispatch(); 38 | 39 | if (!isLoading) { 40 | dispatch(addUser(data?.data)); 41 | } 42 | 43 | return { userData: data?.data, isLoading }; 44 | }; 45 | 46 | export default App; 47 | -------------------------------------------------------------------------------- /src/context/SocketContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from "react"; 2 | import socketio from "socket.io-client"; 3 | import { LocalStorage } from "../utils"; 4 | 5 | const getSocket = () => { 6 | const accessToken = LocalStorage.get("accessToken"); 7 | 8 | return socketio("http://localhost:8080", { 9 | withCredentials: true, 10 | auth: { accessToken }, 11 | }); 12 | }; 13 | 14 | const SocketContext = createContext<{ 15 | socket: ReturnType<typeof socketio> | null; 16 | }>({ 17 | socket: null, 18 | }); 19 | 20 | const useSocket = () => useContext(SocketContext); 21 | 22 | const SocketProvider = ({ children }: { children: React.ReactNode }) => { 23 | const [socket, setSocket] = useState<ReturnType<typeof socketio> | null>( 24 | null 25 | ); 26 | 27 | useEffect(() => { 28 | const newSocket = getSocket(); // Assuming getSocket is a function that creates a new socket 29 | setSocket(newSocket); 30 | 31 | return () => { 32 | if (newSocket) { 33 | newSocket.disconnect(); 34 | } 35 | }; 36 | }, []); 37 | 38 | return ( 39 | <SocketContext.Provider value={{ socket }}> 40 | {children} 41 | </SocketContext.Provider> 42 | ); 43 | }; 44 | export { SocketProvider, useSocket }; 45 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | theme: { 6 | minHeight: { 7 | "1/2": "1000px", 8 | }, 9 | }, 10 | extend: { 11 | colors: { 12 | "theme-color": "bg-gradient-to-r from-teal-200 to-teal-500", 13 | "orange-theme": "rgb(94 234 212)", 14 | "orange-light-theme": "#rgb(153 246 228)", 15 | "orange-dim": "rgb(204 251 241)", 16 | }, 17 | fontFamily: { 18 | // "font-family": ["Manrope", "sans-serif"], 19 | mooli: ["Mooli", "sans-serif"], 20 | montserrat: ["Montserrat", "sans-serif"], 21 | cursive: ["Allura", "cursive"], 22 | }, 23 | keyframes: { 24 | wiggle: { 25 | "0% ": { transform: "translate-" }, 26 | "100%": { transform: "rotate(3deg)" }, 27 | }, 28 | }, 29 | keyframes: { 30 | grow: { 31 | "0%": { 32 | transform: "translate(-100%)", 33 | }, 34 | }, 35 | }, 36 | animation: { 37 | wiggle: "wiggle 1s ease-in-out infinite", 38 | grow: "grow 3s linear infinite", 39 | }, 40 | }, 41 | }, 42 | plugins: [], 43 | }; 44 | -------------------------------------------------------------------------------- /src/features/comments/api/getComments.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { Comments, Pagination } from "../../../features/posts/types/postType"; 3 | import { axios } from "../../../services/apiClient"; 4 | import { ResponseType } from "../../../types/responseType"; 5 | 6 | const getComments = (postId: string, showComments: boolean) => { 7 | const queryClient = useQueryClient(); 8 | const limit = 5; 9 | const getComments = (pageParam: number) => { 10 | const params = { page: pageParam, limit }; 11 | return axios.get<ResponseType<Comments & Pagination>>( 12 | `/social-media/comments/post/${postId}`, 13 | { 14 | params: params, 15 | } 16 | ); 17 | }; 18 | const initialQueryKey = ["comments", postId]; 19 | return useInfiniteQuery({ 20 | queryKey: initialQueryKey, 21 | queryFn: ({ pageParam = 1 }) => getComments(pageParam), 22 | getNextPageParam: (lastComment, allComments) => { 23 | if ( 24 | allComments.length * limit < 25 | (lastComment?.data?.totalComments ?? 0) 26 | ) { 27 | return allComments.length + 1; 28 | } 29 | return null; 30 | }, 31 | enabled: showComments, 32 | staleTime: 1000 * 60 * 60 * 24, 33 | }); 34 | }; 35 | 36 | export default getComments; 37 | -------------------------------------------------------------------------------- /src/features/user/Components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Author } from "features/posts/types/postType"; 2 | import { useSelector } from "react-redux"; 3 | import { Link } from "react-router-dom"; 4 | import LoadImage from "../../../components/Elements/LoadImage"; 5 | import { RootState } from "../../../stores/store"; 6 | type AvatarProps = { 7 | url?: string; 8 | firstName?: string; 9 | className?: string; 10 | username?: string; 11 | }; 12 | const Avatar = ({ url, firstName, className, username }: AvatarProps) => { 13 | const user = useSelector<RootState, Author | undefined>( 14 | (store) => store.user.user 15 | ); 16 | let updatedProfilePicURL; 17 | if (user?.account.username === username) { 18 | updatedProfilePicURL = useSelector<RootState, string | undefined>( 19 | (store) => store.user.updatedProfilePicURL 20 | ); 21 | } 22 | return ( 23 | <Link to={`/user/${username}`}> 24 | <LoadImage 25 | src={ 26 | updatedProfilePicURL && user?.account.username === username 27 | ? updatedProfilePicURL 28 | : url 29 | } 30 | alt={`${firstName}'s Avatar`} 31 | className={`${ 32 | className ? className : "w-10 h-10 mr-2 border rounded-full" 33 | } `} 34 | /> 35 | </Link> 36 | ); 37 | }; 38 | 39 | export default Avatar; 40 | -------------------------------------------------------------------------------- /src/features/auth/Components/AuthFormEnhancements.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Link } from "react-router-dom"; 3 | 4 | type FormType = { formType: "signup" | "login" }; 5 | const AuthFormEnhancements = ({ formType }: FormType) => { 6 | const { t } = useTranslation(); 7 | return ( 8 | <> 9 | <div className="text-center"> 10 | <a 11 | className="inline-block text-sm text-teal-500 align-baseline hover:text-teal-800" 12 | href="#" 13 | > 14 | {t("authPage.forgot")} 15 | </a> 16 | </div> 17 | <div className="text-sm text-center"> 18 | {formType === "login" 19 | ? t("authPage.oldAccount") 20 | : t("authPage.newAccount")}{" "} 21 |   22 | <Link 23 | className="inline-block text-teal-500 capitalize align-baseline hover:text-teal-800" 24 | to={`/${formType}`} 25 | > 26 | {t(`landingPage.${formType}`)} ! 27 | </Link> 28 | <br /> 29 | <Link 30 | to="/" 31 | className="inline-block mt-4 text-teal-500 capitalize align-baseline hover:text-teal-800" 32 | > 33 | {t(`userPages.returnHome`)}{" "} 34 | </Link> 35 | </div> 36 | </> 37 | ); 38 | }; 39 | export default AuthFormEnhancements; 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /src/components/Elements/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from "@headlessui/react"; 2 | type ToggleProps = { 3 | enabled: boolean; 4 | setEnabled: React.Dispatch<React.SetStateAction<boolean>>; 5 | removePreviousSelected: () => void; 6 | }; 7 | const Toggle = ({ 8 | enabled, 9 | setEnabled, 10 | removePreviousSelected, 11 | }: ToggleProps) => { 12 | return ( 13 | <div onClick={removePreviousSelected} className="h-10 py-1"> 14 | <Switch 15 | checked={enabled} 16 | onChange={setEnabled} 17 | className={`${ 18 | enabled 19 | ? "bg-gradient-to-r from-teal-400 to-teal-500" 20 | : " bg-gradient-to-r from-slate-600 to-teal-800" 21 | } 22 | relative inline-flex h-[28px] w-[64px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75`} 23 | > 24 | <span className="sr-only">Use setting</span> 25 | <span 26 | aria-hidden="true" 27 | className={`${enabled ? "translate-x-9" : "translate-x-0"} 28 | pointer-events-none inline-block h-[24px] w-[24px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out`} 29 | /> 30 | </Switch> 31 | </div> 32 | ); 33 | }; 34 | 35 | export default Toggle; 36 | -------------------------------------------------------------------------------- /src/features/posts/Components/CreateTags.tsx: -------------------------------------------------------------------------------- 1 | import { faTimes } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { useDisclosure } from "../../../hooks/useDisclosure"; 4 | 5 | type CreateTagsProps = { 6 | tag: string; 7 | removeTag: () => void; 8 | }; 9 | const CreateTags = ({ tag, removeTag }: CreateTagsProps) => { 10 | const { 11 | isOpen: isHovered, 12 | open: openHover, 13 | close: closeHover, 14 | } = useDisclosure(false); 15 | 16 | return ( 17 | <span 18 | className={`border p-2 px-6 flex rounded-full relative bg-slate-200 `} 19 | onMouseEnter={openHover} 20 | onMouseLeave={closeHover} 21 | > 22 | <span 23 | className={`transition-all duration-300 text-sm ${ 24 | isHovered && "opacity-50" 25 | }`} 26 | > 27 | #{tag} 28 | </span> 29 | {isHovered && ( 30 | <button 31 | onClick={removeTag} 32 | className="absolute z-50 flex items-center justify-center w-6 h-6 rounded-full pointer-events-none right-1 hover:opacity-100 bg-slate-300" 33 | style={{ pointerEvents: isHovered ? "auto" : "none" }} 34 | > 35 | <FontAwesomeIcon className="" icon={faTimes} /> 36 | </button> 37 | )} 38 | </span> 39 | ); 40 | }; 41 | 42 | export default CreateTags; 43 | -------------------------------------------------------------------------------- /src/features/user/api/getFollowersList.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery } from "@tanstack/react-query"; 2 | import { useParams } from "react-router-dom"; 3 | import { axios } from "../../../services/apiClient"; 4 | 5 | const getFollowersList = (followers: boolean) => { 6 | const { username } = useParams(); 7 | const limit = 10; 8 | const getUserProfile = (pageParam: number) => { 9 | const params = { page: pageParam, limit }; 10 | if (followers) { 11 | return axios.get(`/social-media/follow/list/followers/${username}`, { 12 | params: params, 13 | }); 14 | } 15 | return axios.get(`/social-media/follow/list/following/${username}`, { 16 | params: params, 17 | }); 18 | }; 19 | return useInfiniteQuery({ 20 | queryKey: [followers ? "followers" : "following", username], 21 | queryFn: ({ pageParam = 1 }) => getUserProfile(pageParam), 22 | getNextPageParam: (lastFollower, allFollowers) => { 23 | if ( 24 | followers && 25 | allFollowers.length * limit < lastFollower.data.totalFollowers 26 | ) { 27 | return allFollowers.length + 1; 28 | } 29 | if ( 30 | !followers && 31 | allFollowers.length * limit < lastFollower.data.totalFollowing 32 | ) { 33 | return allFollowers.length + 1; 34 | } 35 | return null; 36 | }, 37 | }); 38 | }; 39 | 40 | export default getFollowersList; 41 | -------------------------------------------------------------------------------- /src/features/landing/Components/Title.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import AppStore from "../../../assets/images/app-store.svg"; 3 | import GooglePlay from "../../../assets/images/google-play.svg"; 4 | import GraphImage from "../../../assets/images/png/graph-visual.png"; 5 | import LoadImage from "../../../components/Elements/LoadImage"; 6 | import MailingList from "./MailingList"; 7 | const Title = () => { 8 | const { t } = useTranslation(); 9 | return ( 10 | <div className="flex flex-col justify-around w-screen mx-10 my-20 md:flex-row md:mx-28"> 11 | <section className="w-1/2 md:"> 12 | <h1 className="text-5xl font-bold leading-normal tracking-wide md:text-6xl"> 13 | {t("landingPage.tagline")} 14 | <div className="text-orange-theme"> PoloBolo</div> 15 | </h1> 16 | <h3 className="mt-10 mb-8 text-2xl leading-normal text-slate-500"> 17 | {t("landingPage.heroText")} 18 | </h3> 19 | <MailingList /> 20 | <div className="flex gap-x-3"> 21 | <LoadImage src={AppStore} /> 22 | <LoadImage src={GooglePlay} /> 23 | </div> 24 | </section> 25 | <div className="w-full md:w-1/2"> 26 | <LoadImage 27 | src={GraphImage} 28 | alt="graph-visual-png" 29 | className="md:w-2/3 lg:w-1/2" 30 | /> 31 | </div> 32 | </div> 33 | ); 34 | }; 35 | 36 | export default Title; 37 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> -------------------------------------------------------------------------------- /src/components/Elements/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | const sizes = { 4 | xs: "h-2 w-2", 5 | sm: "h-4 w-4", 6 | md: "h-8 w-8", 7 | lg: "h-16 w-16", 8 | xl: "h-24 w-24", 9 | }; 10 | 11 | const variants = { 12 | light: "text-white", 13 | primary: "text-teal-600", 14 | }; 15 | 16 | export type SpinnerProps = { 17 | size?: keyof typeof sizes; 18 | variant?: keyof typeof variants; 19 | className?: string; 20 | }; 21 | 22 | export const Spinner = ({ 23 | size = "md", 24 | variant = "primary", 25 | className = "", 26 | }: SpinnerProps) => { 27 | return ( 28 | <> 29 | <svg 30 | className={clsx( 31 | "animate-spin", 32 | sizes[size], 33 | variants[variant], 34 | className 35 | )} 36 | xmlns="http://www.w3.org/2000/svg" 37 | fill="none" 38 | viewBox="0 0 24 24" 39 | data-testid="loading" 40 | > 41 | <circle 42 | className="opacity-25" 43 | cx="12" 44 | cy="12" 45 | r="10" 46 | stroke="currentColor" 47 | strokeWidth="4" 48 | ></circle> 49 | <path 50 | className="opacity-75" 51 | fill="currentColor" 52 | d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 53 | ></path> 54 | </svg> 55 | <span className="sr-only">Loading</span> 56 | </> 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/Shimmer/Shimmer.tsx: -------------------------------------------------------------------------------- 1 | import ShimmerAvatar from "./ShimmerAvatar"; 2 | 3 | const Shimmer = () => { 4 | return ( 5 | <div className="w-full p-4 "> 6 | <div className="h-full overflow-hidden border-gray-200 rounded-lg "> 7 | <div className="p-6 "> 8 | <ShimmerAvatar /> 9 | <p className="w-full h-3 mb-3 leading-relaxed bg-gray-300 animate-pulse"></p> 10 | <p className="w-2/3 h-3 mb-3 leading-relaxed bg-gray-300 animate-pulse"></p> 11 | <p className="w-1/2 h-3 mb-3 leading-relaxed bg-gray-300 animate-pulse"></p> 12 | <div className="object-cover object-center h-16 mx-2 my-8 bg-gray-200 animate-pulse lg:h-48 md:h-36"></div> 13 | <div className="flex flex-wrap gap-2 mt-4 text-sm animate-pulse"> 14 | {new Array(4).fill(1).map((tag, index) => ( 15 | <span 16 | key={index + tag} 17 | className="w-16 h-4 px-2 py-1 mr-2 text-gray-700 bg-gray-200 " 18 | ></span> 19 | ))} 20 | </div> 21 | </div> 22 | <div className="flex flex-wrap items-center "> 23 | <a className="inline-flex items-center w-32 h-4 mt-2 bg-teal-100 animate-pulse md:mb-2 lg:mb-0"></a> 24 | <span className="inline-flex items-center w-16 h-4 px-2 py-1 pr-5 mt-2 ml-auto mr-3 text-sm leading-none bg-teal-100 animate-pulse"></span> 25 | </div> 26 | </div> 27 | </div> 28 | ); 29 | }; 30 | 31 | export default Shimmer; 32 | -------------------------------------------------------------------------------- /src/features/posts/Components/CreatePostTags.tsx: -------------------------------------------------------------------------------- 1 | import React, { KeyboardEvent } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { TOTAL_TAGS } from "../../../config/constants"; 4 | import CreateTags from "./CreateTags"; 5 | 6 | type CreatePostProps = { 7 | tags: string[]; 8 | removeTag: (tag: string) => void; 9 | handleTotalAllowedTags: () => void; 10 | tagRef: React.RefObject<HTMLInputElement>; 11 | onKeyDownTag: (e: KeyboardEvent) => void; 12 | }; 13 | const CreatePostTags = ({ 14 | tags, 15 | removeTag, 16 | handleTotalAllowedTags, 17 | tagRef, 18 | onKeyDownTag, 19 | }: CreatePostProps) => { 20 | const { t } = useTranslation(); 21 | return ( 22 | <div className="flex flex-col w-full h-auto gap-2 mt-6"> 23 | <div className="flex flex-wrap gap-2"> 24 | {tags.map((tag, index) => ( 25 | <CreateTags 26 | key={tag + index} 27 | tag={tag} 28 | removeTag={() => removeTag(tag)} 29 | /> 30 | ))} 31 | </div> 32 | <input 33 | name="tag" 34 | type="text" 35 | className="w-full p-2 mt-2 border shadow-lg md:mt-1 rounded-xl focus:outline-none" 36 | placeholder={t("posts.createTags")} 37 | ref={tagRef} 38 | onKeyDown={(e) => onKeyDownTag(e)} 39 | disabled={tags.length >= TOTAL_TAGS} 40 | onChange={handleTotalAllowedTags} 41 | /> 42 | </div> 43 | ); 44 | }; 45 | 46 | export default CreatePostTags; 47 | -------------------------------------------------------------------------------- /src/components/Form/SelectField.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import * as React from "react"; 3 | import { UseFormRegisterReturn } from "react-hook-form"; 4 | 5 | import { FieldWrapper, FieldWrapperPassThroughProps } from "./FieldWrapper"; 6 | 7 | type Option = { 8 | label: React.ReactNode; 9 | value: string | number | string[]; 10 | }; 11 | 12 | type SelectFieldProps = FieldWrapperPassThroughProps & { 13 | options: Option[]; 14 | className?: string; 15 | defaultValue?: string; 16 | placeholder?: string; 17 | registration: Partial<UseFormRegisterReturn>; 18 | }; 19 | 20 | export const SelectField = (props: SelectFieldProps) => { 21 | const { 22 | label, 23 | options, 24 | error, 25 | className, 26 | defaultValue, 27 | registration, 28 | placeholder, 29 | } = props; 30 | return ( 31 | <FieldWrapper label={label} error={error}> 32 | <select 33 | placeholder={placeholder} 34 | name="location" 35 | className={clsx( 36 | "mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-600 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md", 37 | className 38 | )} 39 | defaultValue={defaultValue} 40 | {...registration} 41 | > 42 | {options.map(({ label, value }) => ( 43 | <option key={label?.toString()} value={value}> 44 | {label} 45 | </option> 46 | ))} 47 | </select> 48 | </FieldWrapper> 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/features/posts/Components/DeletePost.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Button } from "../../../components/Elements/Button"; 3 | import { Dialog } from "../../../components/Elements/Dialog"; 4 | export type DeletePostDialogProps = { 5 | isOpen: boolean; 6 | closeModal: () => void; 7 | handleDelete: () => void; 8 | content: string; 9 | isLoading?: boolean; 10 | }; 11 | const DeletePost = ({ 12 | isOpen, 13 | closeModal, 14 | handleDelete, 15 | content, 16 | isLoading, 17 | }: DeletePostDialogProps) => { 18 | const { t } = useTranslation(); 19 | return ( 20 | <Dialog 21 | className="z-50 rounded-lg md:w-1/2 lg:w-1/3 deleteDialog " 22 | isOpen={isOpen} 23 | closeModal={closeModal} 24 | modalClassName="z-50 mx-10" 25 | > 26 | <h1 className="text-center">{content}</h1> 27 | <div className="flex justify-center w-full gap-4 mt-4 mb-0"> 28 | <Button 29 | variant="blend" 30 | onClick={() => { 31 | closeModal(); 32 | }} 33 | > 34 | {t("userPages.no")} 35 | </Button> 36 | <Button 37 | variant="danger" 38 | className="font-extrabold tracking-wider " 39 | isLoading={isLoading} 40 | onClick={() => { 41 | handleDelete(); 42 | }} 43 | > 44 | {t("userPages.yes")} 45 | </Button> 46 | </div> 47 | </Dialog> 48 | ); 49 | }; 50 | 51 | export default DeletePost; 52 | -------------------------------------------------------------------------------- /src/components/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import clsx from "clsx"; 3 | import * as React from "react"; 4 | import { 5 | useForm, 6 | UseFormReturn, 7 | SubmitHandler, 8 | UseFormProps, 9 | FieldValues, 10 | } from "react-hook-form"; 11 | import { ZodType, ZodTypeDef } from "zod"; 12 | 13 | type FormProps< 14 | TFormValues extends FieldValues = FieldValues, 15 | Schema extends ZodType<unknown, ZodTypeDef, unknown> = ZodType< 16 | unknown, 17 | ZodTypeDef, 18 | unknown 19 | > 20 | > = { 21 | className?: string; 22 | onSubmit: SubmitHandler<TFormValues>; 23 | children: (methods: UseFormReturn<TFormValues>) => React.ReactNode; 24 | options?: UseFormProps<TFormValues>; 25 | id?: string; 26 | schema?: Schema; 27 | }; 28 | 29 | export const Form = < 30 | TFormValues extends Record<string, unknown> = Record<string, unknown>, 31 | Schema extends ZodType<unknown, ZodTypeDef, unknown> = ZodType< 32 | unknown, 33 | ZodTypeDef, 34 | unknown 35 | > 36 | >({ 37 | onSubmit, 38 | children, 39 | className, 40 | options, 41 | id, 42 | schema, 43 | }: FormProps<TFormValues, Schema>) => { 44 | const methods = useForm<TFormValues>({ 45 | ...options, 46 | resolver: schema && zodResolver(schema), 47 | }); 48 | 49 | return ( 50 | <form 51 | className={clsx("space-y-6", className)} 52 | onSubmit={methods.handleSubmit(onSubmit)} 53 | id={id} 54 | > 55 | {children(methods)} 56 | </form> 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/features/user/api/postUserFollow.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { Author } from "features/posts/types/postType"; 3 | import { useTranslation } from "react-i18next"; 4 | import { useSelector } from "react-redux"; 5 | import { axios } from "../../../services/apiClient"; 6 | import { addNotification } from "../../../stores/notificationSlice"; 7 | import store, { RootState } from "../../../stores/store"; 8 | 9 | const postFollow = (toBeFollowedUserId: string | undefined) => { 10 | const queryClient = useQueryClient(); 11 | const user = useSelector<RootState, Author | undefined>( 12 | (store) => store.user.user 13 | ); 14 | const { t } = useTranslation(); 15 | const postFollowData = () => { 16 | return axios.post(`/social-media/follow/${toBeFollowedUserId}`); 17 | }; 18 | return useMutation({ 19 | mutationFn: postFollowData, 20 | onError: () => {}, 21 | onSuccess: (response) => { 22 | const { dispatch } = store; 23 | const { following } = response.data; 24 | queryClient.invalidateQueries(["following", user?.account.username]); 25 | queryClient.invalidateQueries(["user", user?.account.username]); 26 | dispatch( 27 | addNotification({ 28 | type: "success", 29 | title: t("notification.success"), 30 | message: following 31 | ? t("notificationMessages.followUser") 32 | : t("notificationMessages.unfollowUser"), 33 | }) 34 | ); 35 | }, 36 | }); 37 | }; 38 | 39 | export default postFollow; 40 | -------------------------------------------------------------------------------- /src/features/posts/api/createPost.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useParams } from "react-router-dom"; 4 | import { axios } from "../../../services/apiClient"; 5 | import { addNotification } from "../../../stores/notificationSlice"; 6 | import store from "../../../stores/store"; 7 | 8 | const createPost = (postId: string | undefined) => { 9 | const queryClient = useQueryClient(); 10 | const { username } = useParams(); 11 | const { t } = useTranslation(); 12 | const postData = (formData: any) => { 13 | const config = { 14 | headers: { 15 | "Content-Type": "multipart/form-data", 16 | }, 17 | }; 18 | if (postId) { 19 | return axios.patch(`/social-media/posts/${postId}`, formData, config); 20 | } 21 | return axios.post("/social-media/posts", formData, config); 22 | }; 23 | return useMutation({ 24 | mutationFn: postData, 25 | onError: () => {}, 26 | onSuccess: (response) => { 27 | if (username) { 28 | queryClient.invalidateQueries(["posts", username]); 29 | } else { 30 | queryClient.invalidateQueries(["posts"]); 31 | } 32 | const { dispatch } = store; 33 | dispatch( 34 | addNotification({ 35 | type: "success", 36 | title: t("notification.success"), 37 | message: postId 38 | ? t("notificationMessages.updatePost") 39 | : t("notificationMessages.createPost"), 40 | }) 41 | ); 42 | }, 43 | }); 44 | }; 45 | 46 | export default createPost; 47 | -------------------------------------------------------------------------------- /src/features/landing/Components/Highlights.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import Analytics from "../../../assets/images/png/analytics.png"; 4 | import AuditCards from "../../../assets/images/png/audit_cards.png"; 5 | import PostPerformance from "../../../assets/images/png/post-performance.png"; 6 | 7 | const Highlights = () => { 8 | const { t } = useTranslation(); 9 | const analytics = useMemo( 10 | () => [ 11 | { 12 | image: Analytics, 13 | text: t("landingPage.analytics"), 14 | }, 15 | { 16 | image: AuditCards, 17 | text: t("landingPage.audit"), 18 | }, 19 | { 20 | image: PostPerformance, 21 | text: t("landingPage.performance"), 22 | }, 23 | ], 24 | [t] 25 | ); 26 | return ( 27 | <div className="flex flex-col justify-center mx-28"> 28 | <h1 className="my-10 text-4xl font-bold tracking-wider text-center"> 29 | {t("landingPage.highlights")} 30 | </h1> 31 | <div className="flex flex-wrap justify-center lg:justify-between "> 32 | {analytics.map((analytic) => { 33 | return ( 34 | <div 35 | className="flex flex-col items-center justify-center" 36 | key={analytic.image} 37 | > 38 | <div className="my-5 w-72 "> 39 | <img src={analytic?.image} alt="Analytics" /> 40 | </div> 41 | <p className="my-2 text-xl font-bold">{analytic?.text}</p> 42 | </div> 43 | ); 44 | })} 45 | </div> 46 | </div> 47 | ); 48 | }; 49 | 50 | export default Highlights; 51 | -------------------------------------------------------------------------------- /src/stores/userSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Author } from "../features/posts/types/postType"; 3 | 4 | const initialState: { 5 | user: Author | undefined; 6 | isLoggedIn: boolean; 7 | isEmailVerified: boolean; 8 | isInitialLogin: boolean; 9 | updatedProfilePicURL: string | undefined; 10 | } = { 11 | user: undefined, 12 | isLoggedIn: false, 13 | isEmailVerified: false, 14 | isInitialLogin: true, 15 | updatedProfilePicURL: undefined, 16 | }; 17 | 18 | export const userSlice = createSlice({ 19 | name: "user", 20 | initialState, 21 | reducers: { 22 | addUser: (state, action: PayloadAction<Author | undefined>) => { 23 | state.user = action.payload; 24 | }, 25 | removeUser: (state) => { 26 | state.user = undefined; 27 | }, 28 | handleLoginUser: (state) => { 29 | state.isLoggedIn = true; 30 | }, 31 | handleLogoutUser: (state) => { 32 | state.isLoggedIn = false; 33 | }, 34 | handleEmailVerification: (state, action: PayloadAction<boolean>) => { 35 | state.isEmailVerified = action.payload; 36 | }, 37 | handleInitialLogin: (state, action: PayloadAction<boolean>) => { 38 | state.isInitialLogin = action.payload; 39 | }, 40 | handleUpdateProfilePic: (state, action: PayloadAction<string>) => { 41 | state.updatedProfilePicURL = action.payload; 42 | }, 43 | }, 44 | }); 45 | 46 | export const { 47 | addUser, 48 | removeUser, 49 | handleLoginUser, 50 | handleLogoutUser, 51 | handleEmailVerification, 52 | handleInitialLogin, 53 | handleUpdateProfilePic, 54 | } = userSlice.actions; 55 | export default userSlice.reducer; 56 | -------------------------------------------------------------------------------- /src/services/apiClient.ts: -------------------------------------------------------------------------------- 1 | import Axios from "axios"; 2 | import { addNotification } from "../stores/notificationSlice"; 3 | import store from "../stores/store"; 4 | import { LocalStorage } from "../utils/index"; 5 | 6 | export const axios = Axios.create({ 7 | baseURL: "http://localhost:8080/api/v1", 8 | withCredentials: true, 9 | }); 10 | 11 | export const refreshAccessToken = () => { 12 | return axios.post("/users/refresh-token"); 13 | }; 14 | 15 | axios.interceptors.request.use( 16 | function (config) { 17 | const accessToken = LocalStorage.get("accessToken"); 18 | config.headers.Authorization = `Bearer ${accessToken}`; 19 | return config; 20 | }, 21 | function (error) { 22 | return Promise.reject(error); 23 | } 24 | ); 25 | 26 | axios.interceptors.response.use( 27 | (response) => { 28 | return response.data; 29 | }, 30 | async (error) => { 31 | const originalRequest = error.config; 32 | if (error.response.status === 401 && !originalRequest._retry) { 33 | originalRequest._retry = true; 34 | LocalStorage.remove("accessToken"); 35 | const response = await refreshAccessToken(); 36 | const { accessToken } = response.data; 37 | axios.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`; 38 | LocalStorage.set("accessToken", accessToken); 39 | return axios(originalRequest); 40 | } 41 | const message = error.response?.data?.message || error.message; 42 | const { dispatch } = store; 43 | dispatch( 44 | addNotification({ 45 | type: "error", 46 | title: "Error", 47 | message, 48 | }) 49 | ); 50 | return Promise.reject(error); 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /src/features/user/Components/Follow.tsx: -------------------------------------------------------------------------------- 1 | import { Author } from "features/posts/types/postType"; 2 | import { useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { useSelector } from "react-redux"; 5 | import { RootState } from "stores/store"; 6 | import { Button } from "../../../components/Elements/Button"; 7 | import postFollow from "../api/postUserFollow"; 8 | 9 | type FollowProps = { 10 | toBeFollowedUserId: string; 11 | isFollowing: boolean; 12 | className?: string; 13 | }; 14 | const Follow = ({ 15 | toBeFollowedUserId, 16 | isFollowing, 17 | className, 18 | }: FollowProps) => { 19 | const { mutate, error, isLoading } = postFollow(toBeFollowedUserId); 20 | const [isFollowed, setIsFollowed] = useState(isFollowing); 21 | const user = useSelector<RootState, Author | undefined>( 22 | (store) => store.user.user 23 | ); 24 | const { t } = useTranslation(); 25 | return ( 26 | <> 27 | {user?.account._id !== toBeFollowedUserId && ( 28 | <Button 29 | variant={isFollowed ? "moretransparent" : "blend"} 30 | className={`${ 31 | className 32 | ? "self-center mt-0 py-0.5 rounded-3xl w-24 md:w-32 text-sm md:text-base" 33 | : " self-center w-32 mt-4" 34 | } border-none `} 35 | size={className ? "xs" : "md"} 36 | isLoading={isLoading} 37 | onClick={() => { 38 | setIsFollowed((prevState) => !prevState); 39 | mutate(); 40 | }} 41 | > 42 | {isFollowed ? t("userPages.unfollow") : t("userPages.follow")} 43 | </Button> 44 | )} 45 | </> 46 | ); 47 | }; 48 | 49 | export default Follow; 50 | -------------------------------------------------------------------------------- /src/features/user/api/updateUserProfile.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useLocation, useNavigate } from "react-router-dom"; 4 | import { axios } from "../../../services/apiClient"; 5 | import { addNotification } from "../../../stores/notificationSlice"; 6 | import store from "../../../stores/store"; 7 | import { addUser, removeUser } from "../../../stores/userSlice"; 8 | 9 | const updateProfile = (isOnboarding: boolean = false) => { 10 | const queryClient = useQueryClient(); 11 | const { pathname } = useLocation(); 12 | const navigate = useNavigate(); 13 | const { t } = useTranslation(); 14 | const username = pathname.split("/")[2]; 15 | const postData = (profileData: any) => { 16 | return axios.patch(`/social-media/profile`, profileData); 17 | }; 18 | 19 | return useMutation({ 20 | mutationFn: postData, 21 | onError: () => {}, 22 | onSuccess: (response) => { 23 | const user = response?.data; 24 | queryClient.invalidateQueries(["user", username]); 25 | const { dispatch } = store; 26 | dispatch(removeUser()); 27 | dispatch(addUser(user)); 28 | dispatch( 29 | addNotification({ 30 | type: "success", 31 | title: t("notification.success"), 32 | message: isOnboarding 33 | ? t("notificationMessages.createdProfile") 34 | : t("notificationMessages.updatedProfile"), 35 | }) 36 | ); 37 | if (isOnboarding) { 38 | navigate(`/home`); 39 | } else { 40 | navigate(`/user/${username}/about`); 41 | } 42 | }, 43 | }); 44 | }; 45 | 46 | export default updateProfile; 47 | -------------------------------------------------------------------------------- /src/context/LanguageContext.tsx: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { SetStateAction, createContext, useContext, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | export const languages = [ 5 | { key: "en", nativeName: "English" }, 6 | { key: "de", nativeName: "Deutsch" }, 7 | { key: "es", nativeName: "Spanish" }, 8 | { key: "hi", nativeName: "Hindi" }, 9 | { key: "ne", nativeName: "Nepali" }, 10 | ] as const; 11 | export type Language = (typeof languages)[number]; 12 | export type LanguageContext = { 13 | language: Language; 14 | setLanguage: React.Dispatch<SetStateAction<Language>>; 15 | changeLanguage: (language: Language) => void; 16 | }; 17 | export const LanguageContext = createContext<LanguageContext>({ 18 | language: 19 | languages.find((language) => language.key === i18n.language) ?? 20 | languages[0], 21 | setLanguage: () => {}, 22 | changeLanguage: () => {}, 23 | }); 24 | 25 | export const LanguageProvider = ({ 26 | children, 27 | }: { 28 | children: React.ReactNode; 29 | }) => { 30 | const { t, i18n } = useTranslation(); 31 | 32 | const [selectedLanguage, setLanguage] = useState<Language>( 33 | languages.find((language) => language.key === i18n.language) ?? languages[0] 34 | ); 35 | 36 | const changeLanguage = (language: Language) => 37 | i18n.changeLanguage(language.key); 38 | 39 | return ( 40 | <LanguageContext.Provider 41 | value={{ 42 | language: selectedLanguage, 43 | setLanguage: setLanguage, 44 | changeLanguage: changeLanguage, 45 | }} 46 | > 47 | {children} 48 | </LanguageContext.Provider> 49 | ); 50 | }; 51 | 52 | export const useLanguage = () => useContext(LanguageContext); 53 | -------------------------------------------------------------------------------- /src/components/Shared/UploadImage.tsx: -------------------------------------------------------------------------------- 1 | import { faTimes } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { useDisclosure } from "../../hooks/useDisclosure"; 4 | import { Button } from "../Elements/Button"; 5 | 6 | type UploadImageProps = { 7 | fileDataURL: string; 8 | index: number; 9 | removeFile: () => void; 10 | className: string; 11 | }; 12 | const UploadImage = ({ 13 | fileDataURL, 14 | index, 15 | removeFile, 16 | className, 17 | }: UploadImageProps) => { 18 | const { 19 | isOpen: isHovered, 20 | open: openHover, 21 | close: closeHover, 22 | } = useDisclosure(false); 23 | 24 | return ( 25 | <div 26 | className={`relative flex transition-all duration-300 cursor-pointer ${className} bg-black`} 27 | key={fileDataURL + index} 28 | onMouseEnter={openHover} 29 | onMouseLeave={closeHover} 30 | > 31 | <img 32 | src={fileDataURL} 33 | className={`object-contain w-full h-full transition-all duration-300 ${ 34 | isHovered && "opacity-50" 35 | }`} 36 | alt="file" 37 | /> 38 | {!fileDataURL.includes("http://") && isHovered && ( 39 | <Button 40 | variant="danger" 41 | size="xs" 42 | className="absolute z-50 w-8 h-8 translate-x-1/2 translate-y-1/2 rounded-full pointer-events-none right-1/2 bottom-1/2 hover:opacity-100 bg-gradient-to-r from-red-200 to-red-500" 43 | style={{ pointerEvents: isHovered ? "auto" : "none" }} 44 | onClick={removeFile} 45 | > 46 | <FontAwesomeIcon className="" icon={faTimes} /> 47 | </Button> 48 | )} 49 | </div> 50 | ); 51 | }; 52 | 53 | export default UploadImage; 54 | -------------------------------------------------------------------------------- /src/features/user/Components/AuthorProfile.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import Avatar from "./Avatar"; 3 | 4 | type AuthorProfileProps = { 5 | username: string; 6 | url: string; 7 | firstName: string; 8 | lastName: string; 9 | bio: string; 10 | className?: string; 11 | closeModal?: () => void; 12 | isChat?: boolean; 13 | createChat?: () => void; 14 | isGroupChat?: boolean; 15 | }; 16 | 17 | const AuthorProfile = ({ 18 | username, 19 | url, 20 | firstName, 21 | lastName, 22 | bio, 23 | className, 24 | closeModal, 25 | isChat, 26 | createChat, 27 | isGroupChat, 28 | }: AuthorProfileProps) => { 29 | const handleClick = () => { 30 | closeModal && closeModal(); 31 | createChat && createChat(); 32 | }; 33 | 34 | const commonContent = ( 35 | <div> 36 | <h2 className={`${className ? "text-sm" : "text-lg"}`}> 37 | {firstName} {lastName} 38 | </h2> 39 | <p className="text-gray-500">{bio}</p> 40 | </div> 41 | ); 42 | 43 | return ( 44 | <div 45 | className={`w-full cursor-pointer ${ 46 | isChat || isGroupChat ? "" : "hover:underline" 47 | }`} 48 | onClick={handleClick} 49 | > 50 | <div className={`flex items-center w-auto space-x-4 ${className}`}> 51 | <Avatar 52 | url={url} 53 | className="w-12 h-12 rounded-full" 54 | username={username} 55 | firstName={firstName} 56 | /> 57 | 58 | {isChat || isGroupChat ? ( 59 | commonContent 60 | ) : ( 61 | <Link to={`/user/${username}`} onClick={handleClick}> 62 | {commonContent} 63 | </Link> 64 | )} 65 | </div> 66 | </div> 67 | ); 68 | }; 69 | 70 | export default AuthorProfile; 71 | -------------------------------------------------------------------------------- /src/features/chat/api/addDeleteMembers.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { JOIN_CHAT_EVENT } from "../../../config/constants"; 5 | import { useSocket } from "../../../context/SocketContext"; 6 | import { Chat } from "../../../features/posts/types/postType"; 7 | import { axios } from "../../../services/apiClient"; 8 | import { addNotification } from "../../../stores/notificationSlice"; 9 | import store from "../../../stores/store"; 10 | import { ResponseType } from "../../../types/responseType"; 11 | 12 | const addChatMember = () => { 13 | const queryClient = useQueryClient(); 14 | const navigate = useNavigate(); 15 | const { t } = useTranslation(); 16 | const { socket } = useSocket(); 17 | const postAddChatMember = ({ 18 | chatId, 19 | participantId, 20 | }: { 21 | participantId: string; 22 | chatId: string; 23 | }) => { 24 | return axios.post<ResponseType<Chat>>( 25 | `/chat-app/chats/group/${chatId}/${participantId}` 26 | ); 27 | }; 28 | return useMutation({ 29 | mutationFn: postAddChatMember, 30 | onError: () => {}, 31 | onSuccess: (response) => { 32 | const chatId = response.data._id; 33 | setTimeout(() => { 34 | navigate(`/chats/${chatId}`); 35 | }, 500); 36 | socket?.emit(JOIN_CHAT_EVENT, chatId); 37 | const { dispatch } = store; 38 | dispatch( 39 | addNotification({ 40 | type: "success", 41 | title: t("notification.success"), 42 | message: t("notificationMessages.addMember"), 43 | }) 44 | ); 45 | 46 | queryClient.invalidateQueries(["chats"]); 47 | }, 48 | }); 49 | }; 50 | 51 | export default addChatMember; 52 | -------------------------------------------------------------------------------- /src/features/user/api/postImage.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useParams } from "react-router-dom"; 4 | import { axios } from "../../../services/apiClient"; 5 | import { addNotification } from "../../../stores/notificationSlice"; 6 | import store from "../../../stores/store"; 7 | import { handleUpdateProfilePic } from "../../../stores/userSlice"; 8 | 9 | const postImage = (coverImageExist: boolean | undefined) => { 10 | const queryClient = useQueryClient(); 11 | const { username } = useParams(); 12 | const { t } = useTranslation(); 13 | const postImage = (formData: any) => { 14 | const config = { 15 | headers: { 16 | "Content-Type": "multipart/form-data", 17 | }, 18 | }; 19 | if (coverImageExist) { 20 | return axios.patch("/social-media/profile/cover-image", formData, config); 21 | } 22 | return axios.patch("/users/avatar", formData, config); 23 | }; 24 | return useMutation({ 25 | mutationFn: postImage, 26 | onError: () => {}, 27 | onSuccess: (response) => { 28 | queryClient.invalidateQueries(["user", username]); 29 | const message = response?.message; 30 | const updatedPicURL = response?.data?.avatar?.url; 31 | const { dispatch } = store; 32 | if (!coverImageExist) { 33 | dispatch(handleUpdateProfilePic(updatedPicURL)); 34 | } 35 | dispatch( 36 | addNotification({ 37 | type: "success", 38 | title: t("notification.success"), 39 | message: coverImageExist 40 | ? t("notificationMessages.updateCoverImage") 41 | : t("notificationMessages.updateProfileImage"), 42 | }) 43 | ); 44 | }, 45 | }); 46 | }; 47 | 48 | export default postImage; 49 | -------------------------------------------------------------------------------- /src/features/chat/Components/ChatMembers.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Dialog } from "../../../components/Elements/Dialog"; 4 | import { Chat } from "../../posts/types/postType"; 5 | import AuthorProfile from "../../user/Components/AuthorProfile"; 6 | import { ResponseType } from "../../../types/responseType"; 7 | 8 | type ChatMembersProps = { 9 | chatInfoId: string; 10 | closeModal: () => void; 11 | }; 12 | const ChatMembers = ({ chatInfoId, closeModal }: ChatMembersProps) => { 13 | const queryClient = useQueryClient(); 14 | const { t } = useTranslation(); 15 | return ( 16 | <Dialog 17 | isOpen={!!chatInfoId} 18 | closeModal={closeModal} 19 | className="rounded-lg md:w-1/2 lg:w-1/3 deleteDialog " 20 | modalClassName=" mx-10" 21 | > 22 | <h3>{t("chatPage.members")} :</h3> 23 | {queryClient 24 | .getQueryData<ResponseType<Chat[]>>(["chats"]) 25 | ?.data.map((chat: Chat) => 26 | chat._id === chatInfoId 27 | ? chat.participants.map((participant) => ( 28 | <div 29 | key={participant?._id + participant?.username} 30 | className="my-2" 31 | > 32 | <AuthorProfile 33 | username={participant?.username} 34 | url={participant?.avatar.url} 35 | firstName={participant?.username} 36 | lastName={""} 37 | bio={""} 38 | isChat={false} 39 | isGroupChat={false} 40 | closeModal={() => {}} 41 | /> 42 | </div> 43 | )) 44 | : null 45 | )} 46 | </Dialog> 47 | ); 48 | }; 49 | 50 | export default ChatMembers; 51 | -------------------------------------------------------------------------------- /src/features/chat/Components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary } from "react-error-boundary"; 2 | import { Outlet, useNavigate, useParams } from "react-router-dom"; 3 | import CloseModal from "../../../components/Elements/CloseModal"; 4 | import { Dialog } from "../../../components/Elements/Dialog"; 5 | import { FallbackErrorBoundary } from "../../../components/Shared/FallbackErrorBoundary"; 6 | import useScreenSize from "../../../hooks/useScreenSize"; 7 | import ChatList from "./ChatList"; 8 | import ChatSection from "./ChatSection"; 9 | 10 | const Chat = () => { 11 | const { chatId } = useParams(); 12 | const isScreenSmall = useScreenSize(765); 13 | 14 | const navigate = useNavigate(); 15 | const closeModal = () => { 16 | navigate("."); 17 | }; 18 | 19 | return ( 20 | <div className="w-screen flex border-t-[0.1px] h-full md:h-[calc(100vh-97px)] overflow-auto "> 21 | <div className="w-full md:w-2/5 lg:w-1/3 border-r-[0.1px] h-full pt-4"> 22 | <ChatList /> 23 | </div> 24 | <ErrorBoundary 25 | FallbackComponent={FallbackErrorBoundary} 26 | onReset={() => { 27 | navigate("."); 28 | }} 29 | > 30 | <div className="hidden w-full h-full md:block md:w-3/5 lg:w-2/3"> 31 | {!chatId ? ( 32 | <h1 className="flex items-center justify-center w-full h-full"> 33 | No chat selected. 34 | </h1> 35 | ) : isScreenSmall ? ( 36 | <Dialog 37 | isOpen={!!chatId} 38 | closeModal={closeModal} 39 | className="z-10 flex flex-col justify-end px-0 py-0" 40 | > 41 | <CloseModal closeModal={closeModal} /> 42 | <ChatSection /> 43 | </Dialog> 44 | ) : ( 45 | <Outlet /> 46 | )} 47 | </div> 48 | </ErrorBoundary> 49 | </div> 50 | ); 51 | }; 52 | 53 | export default Chat; 54 | -------------------------------------------------------------------------------- /src/components/Shared/ErrorRouteElement.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Link, 3 | Navigate, 4 | isRouteErrorResponse, 5 | useRouteError, 6 | } from "react-router-dom"; 7 | import LoadImage from "../../components/Elements/LoadImage"; 8 | import useAuthCheck from "../../hooks/useAuthCheck"; 9 | import GradientText from "./GradientText"; 10 | 11 | const ErrorRouteElement = () => { 12 | const error = useRouteError(); 13 | const isLoggedIn = useAuthCheck(); 14 | if (!isLoggedIn) { 15 | return <Navigate to="/login" />; 16 | } 17 | 18 | return ( 19 | <div className="flex items-center w-screen h-screen bg-gray-50 font-montserrat"> 20 | <div className="container flex flex-col items-center justify-between px-5 text-gray-700 md:flex-row"> 21 | <div className="w-full mx-8 lg:w-1/2"> 22 | <GradientText content="404" /> 23 | <p className="mb-8 text-2xl font-light leading-normal md:text-3xl"> 24 | Sorry we couldn't find the page you're looking for 25 | </p> 26 | <div 27 | id="error-page" 28 | className="flex flex-col items-center justify-center gap-8" 29 | > 30 | <p className="text-slate-400"> 31 | <i> 32 | {isRouteErrorResponse(error) 33 | ? error.error?.message || error.statusText 34 | : "Unknown error message"} 35 | </i> 36 | </p> 37 | <Link to={"/"} className="underline"> 38 | Return Home 39 | </Link> 40 | </div> 41 | </div> 42 | <div className="w-full mx-5 my-12 lg:flex lg:justify-end lg:w-1/2"> 43 | <LoadImage 44 | src="https://user-images.githubusercontent.com/43953425/166269493-acd08ccb-4df3-4474-95c7-ad1034d3c070.svg" 45 | className="" 46 | alt="Page not found" 47 | /> 48 | </div> 49 | </div> 50 | </div> 51 | ); 52 | }; 53 | 54 | export default ErrorRouteElement; 55 | -------------------------------------------------------------------------------- /src/components/Shared/FallbackErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import { Button } from "../../components/Elements/Button"; 3 | import LoadImage from "../../components/Elements/LoadImage"; 4 | import GradientText from "./GradientText"; 5 | 6 | type FallbackErrorBoundaryProps = { 7 | error: Error; 8 | resetErrorBoundary: () => void; 9 | }; 10 | export function FallbackErrorBoundary({ 11 | error, 12 | resetErrorBoundary, 13 | }: FallbackErrorBoundaryProps) { 14 | return ( 15 | <div className="flex items-center w-screen h-full bg-gray-50"> 16 | <div className="container flex flex-col items-center justify-between px-5 text-gray-700 md:flex-row"> 17 | <div className="w-full mx-8 lg:w-1/2"> 18 | <GradientText content="404" /> 19 | <p className="mb-8 text-2xl font-light leading-normal md:text-3xl"> 20 | Sorry we couldn't find the page you're looking for 21 | </p> 22 | <p className="text-base">{error.message}</p> 23 | 24 | <Button 25 | onClick={resetErrorBoundary} 26 | className="inline px-5 py-3 my-2 font-medium leading-5 border-0 rounded-lg shadow-2xl duration-400 focus:outline-none" 27 | > 28 | Try again 29 | </Button> 30 | <Link to={"/"}> 31 | <Button 32 | onClick={resetErrorBoundary} 33 | className="inline px-5 py-3 font-medium leading-5 border-0 rounded-lg shadow-2xl duration-400 focus:outline-none" 34 | > 35 | Go home 36 | </Button> 37 | </Link> 38 | </div> 39 | <div className="w-full mx-5 my-12 lg:flex lg:justify-end lg:w-1/2"> 40 | <LoadImage 41 | src="https://user-images.githubusercontent.com/43953425/166269493-acd08ccb-4df3-4474-95c7-ad1034d3c070.svg" 42 | className="" 43 | alt="Page not found" 44 | /> 45 | </div> 46 | </div> 47 | </div> 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/features/comments/Components/CommentContent.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faAngleDoubleLeft, 3 | faAngleDoubleRight, 4 | } from "@fortawesome/free-solid-svg-icons"; 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 6 | import { useEffect, useState } from "react"; 7 | import { useDisclosure } from "../../../hooks/useDisclosure"; 8 | type CommentContentProps = { 9 | content: string; 10 | }; 11 | 12 | function getMaxLength(windowSize: number) { 13 | if (windowSize < 400) { 14 | return 50; 15 | } else if (windowSize < 720) { 16 | return 100; 17 | } else if (windowSize < 1024) { 18 | return 150; 19 | } 20 | return 200; 21 | } 22 | 23 | const CommentContent = ({ content }: CommentContentProps) => { 24 | const { isOpen: showMore, toggle: toggleShowMore } = useDisclosure(false); 25 | const [windowSize, setWindowSize] = useState(window.innerWidth); 26 | const updateWindowSize = () => { 27 | setWindowSize(window.innerWidth); 28 | }; 29 | 30 | useEffect(() => { 31 | window.addEventListener("resize", updateWindowSize); 32 | 33 | return () => window.removeEventListener("resize", updateWindowSize); 34 | }, []); 35 | const maxLength = getMaxLength(windowSize); 36 | const isToggleRequired = content.length > maxLength; 37 | 38 | return ( 39 | <div className="text-sm transition-all duration-500"> 40 | {showMore ? ( 41 | <span>{content}</span> 42 | ) : ( 43 | <span> 44 | {isToggleRequired ? `${content.substring(0, maxLength)}...` : content} 45 | </span> 46 | )} 47 |     48 | {isToggleRequired && ( 49 | <button 50 | className="text-sm italic text-teal-400 hover:animate-pulse" 51 | onClick={toggleShowMore} 52 | > 53 | {showMore ? ( 54 | <FontAwesomeIcon icon={faAngleDoubleLeft} /> 55 | ) : ( 56 | <FontAwesomeIcon icon={faAngleDoubleRight} /> 57 | )} 58 | </button> 59 | )} 60 | </div> 61 | ); 62 | }; 63 | 64 | export default CommentContent; 65 | -------------------------------------------------------------------------------- /src/features/chat/api/createOnetoOneChat.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { JOIN_CHAT_EVENT } from "../../../config/constants"; 5 | import { useSocket } from "../../../context/SocketContext"; 6 | import { Chat } from "../../../features/posts/types/postType"; 7 | import { axios } from "../../../services/apiClient"; 8 | import { addNotification } from "../../../stores/notificationSlice"; 9 | import store from "../../../stores/store"; 10 | import { ResponseType } from "../../../types/responseType"; 11 | export type CreateChatProps = { 12 | receiverIds: string[]; 13 | name: string; 14 | isGroup: boolean; 15 | }; 16 | const createChat = () => { 17 | const queryClient = useQueryClient(); 18 | const navigate = useNavigate(); 19 | const { socket } = useSocket(); 20 | const { t } = useTranslation(); 21 | const postCreateChat = ({ receiverIds, isGroup, name }: CreateChatProps) => { 22 | if (isGroup) { 23 | const groupChatData = { name: name, participants: receiverIds }; 24 | return axios.post<ResponseType<Chat>>( 25 | "/chat-app/chats/group", 26 | groupChatData 27 | ); 28 | } 29 | 30 | return axios.post<ResponseType<Chat>>( 31 | `/chat-app/chats/c/${receiverIds[0]}` 32 | ); 33 | }; 34 | return useMutation({ 35 | mutationFn: postCreateChat, 36 | onError: () => {}, 37 | onSuccess: (response) => { 38 | const chatId = response.data._id; 39 | setTimeout(() => { 40 | navigate(`/chats/${chatId}`); 41 | }, 500); 42 | socket?.emit(JOIN_CHAT_EVENT, chatId); 43 | const { dispatch } = store; 44 | dispatch( 45 | addNotification({ 46 | type: "success", 47 | title: t("notification.success"), 48 | message: t("notificationMessages.createChat"), 49 | }) 50 | ); 51 | 52 | queryClient.invalidateQueries(["chats"]); 53 | }, 54 | }); 55 | }; 56 | 57 | export default createChat; 58 | -------------------------------------------------------------------------------- /src/components/Shared/Carousel.tsx: -------------------------------------------------------------------------------- 1 | import { faAngleLeft, faAngleRight } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { Button } from "../../components/Elements/Button"; 4 | import { 5 | Avatar 6 | } from "../../features/posts/types/postType"; 7 | import LoadImage from "../Elements/LoadImage"; 8 | 9 | type CarouselProps = { 10 | images: Avatar[]; 11 | currentIndex: number; 12 | prevSlide: () => void; 13 | nextSlide: () => void; 14 | }; 15 | const Carousel = ({ 16 | images, 17 | currentIndex, 18 | prevSlide, 19 | nextSlide, 20 | }: CarouselProps) => { 21 | return ( 22 | <div className="relative self-start overflow-hidden lg:w-4/6"> 23 | <div className="carousel"> 24 | <div className="carousel-inner"> 25 | {images.map((image, index) => ( 26 | <div 27 | key={image._id} 28 | className={`carousel-slide h-[24rem] md:h-[36rem] lg:h-[54rem] transition-all duration-200 ${ 29 | index === currentIndex ? "block" : "hidden" 30 | }`} 31 | > 32 | <LoadImage 33 | src={image.url} 34 | alt={`Image ${index + 1}`} 35 | className="object-contain w-full h-full" 36 | /> 37 | </div> 38 | ))} 39 | </div> 40 | </div> 41 | 42 | <Button 43 | className="absolute left-0 w-10 h-10 p-2 text-2xl text-black transition-all transform -translate-y-1/2 border-0 rounded-full shadow-2xl top-1/2" 44 | onClick={prevSlide} 45 | size="xs" 46 | > 47 | <FontAwesomeIcon icon={faAngleLeft} className="text-xl" /> 48 | </Button> 49 | <Button 50 | className="absolute right-0 w-10 h-10 p-2 text-2xl text-black transition-all -translate-y-1/2 border-0 rounded-full top-1/2" 51 | onClick={nextSlide} 52 | size="xs" 53 | > 54 | <FontAwesomeIcon icon={faAngleRight} className="text-xl" /> 55 | </Button> 56 | </div> 57 | ); 58 | }; 59 | 60 | export default Carousel; 61 | -------------------------------------------------------------------------------- /src/features/posts/api/getPosts.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery } from "@tanstack/react-query"; 2 | import { ResponseType } from "types/responseType"; 3 | import { axios } from "../../../services/apiClient"; 4 | import { Pagination, Posts } from "../types/postType"; 5 | 6 | const getPosts = ( 7 | username: string | undefined, 8 | tag: string | undefined = undefined, 9 | bookmarks: boolean | undefined = undefined 10 | ) => { 11 | let limit: number; 12 | if (tag) { 13 | limit = 3; 14 | } else { 15 | limit = 10; 16 | } 17 | const getPosts = (pageParam: number) => { 18 | const params = { page: pageParam, limit }; 19 | if (tag) { 20 | return axios.get<ResponseType<Posts & Pagination>>( 21 | `/social-media/posts/get/t/${tag}`, 22 | { 23 | params: params, 24 | } 25 | ); 26 | } 27 | 28 | if (bookmarks) { 29 | return axios.get<ResponseType<Posts & Pagination>>( 30 | `/social-media/bookmarks`, 31 | { 32 | params: params, 33 | } 34 | ); 35 | } 36 | if (username) { 37 | return axios.get<ResponseType<Posts & Pagination>>( 38 | `/social-media/posts/get/u/${username}`, 39 | { 40 | params: params, 41 | } 42 | ); 43 | } 44 | 45 | return axios.get<ResponseType<Posts & Pagination>>("/social-media/posts", { 46 | params: params, 47 | }); 48 | }; 49 | const getQueryKey = () => { 50 | if (bookmarks) { 51 | return ["posts", username, "bookmarks"]; 52 | } else if (username) { 53 | return ["posts", username]; 54 | } else if (tag) { 55 | return ["posts", tag]; 56 | } 57 | return ["posts"]; 58 | }; 59 | return useInfiniteQuery({ 60 | queryKey: getQueryKey(), 61 | queryFn: ({ pageParam = 1 }) => getPosts(pageParam), 62 | getNextPageParam: (lastPost, allPosts) => { 63 | if (allPosts.length * limit < lastPost.data.totalPosts) { 64 | return allPosts.length + 1; 65 | } 66 | return null; 67 | }, 68 | staleTime: 1000 * 60 * 60 * 24, 69 | }); 70 | }; 71 | 72 | export default getPosts; 73 | -------------------------------------------------------------------------------- /src/features/chat/hooks/useSocketEvents.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { Chat } from "features/posts/types/postType"; 3 | import { useEffect, useState } from "react"; 4 | import { ResponseType } from "types/responseType"; 5 | import { 6 | CONNECTED_EVENT, 7 | DISCONNECT_EVENT, 8 | LEAVE_CHAT_EVENT, 9 | NEW_CHAT_EVENT, 10 | UPDATE_GROUP_NAME_EVENT, 11 | } from "../../../config/constants"; 12 | import { useSocket } from "../../../context/SocketContext"; 13 | import { deleteOneToOneChat } from "../api/deleteChat"; 14 | import { updateGroupChatNameHelper } from "../api/updateGroupChatName"; 15 | 16 | const useSocketEvents = () => { 17 | const queryClient = new QueryClient(); 18 | const [isConnected, setIsConnected] = useState(false); 19 | const [isDisconnected, setIsDisconnected] = useState(false); 20 | 21 | const handleConnect = () => setIsConnected(true); 22 | const handleDisconnect = () => setIsDisconnected(true); 23 | 24 | const { socket } = useSocket(); 25 | useEffect(() => { 26 | const handleNewChat = (chat: Chat) => { 27 | queryClient.setQueryData<ResponseType<Chat[]>>(["chats"], (oldChats) => { 28 | if (!oldChats) return oldChats; 29 | return { 30 | ...oldChats, 31 | data: [chat, ...oldChats.data], 32 | }; 33 | }); 34 | }; 35 | const handleDeleteChat = (chat: Chat) => { 36 | deleteOneToOneChat(queryClient, chat._id); 37 | }; 38 | 39 | const updateGroupName = (chat: Chat) => { 40 | updateGroupChatNameHelper(queryClient, chat._id, chat.name); 41 | }; 42 | 43 | socket?.on(CONNECTED_EVENT, handleConnect); 44 | socket?.on(DISCONNECT_EVENT, handleDisconnect); 45 | 46 | socket?.on(NEW_CHAT_EVENT, handleNewChat); 47 | socket?.on(LEAVE_CHAT_EVENT, handleDeleteChat); 48 | socket?.on(UPDATE_GROUP_NAME_EVENT, updateGroupName); 49 | 50 | return () => { 51 | socket?.off(CONNECTED_EVENT, handleConnect); 52 | socket?.off(DISCONNECT_EVENT, handleDisconnect); 53 | socket?.off(NEW_CHAT_EVENT, handleNewChat); 54 | socket?.off(LEAVE_CHAT_EVENT, handleDeleteChat); 55 | socket?.off(UPDATE_GROUP_NAME_EVENT, updateGroupName); 56 | }; 57 | }, [socket]); 58 | }; 59 | 60 | export default useSocketEvents; 61 | -------------------------------------------------------------------------------- /src/components/Elements/Button.tsx: -------------------------------------------------------------------------------- 1 | import cslx from "clsx"; 2 | import * as React from "react"; 3 | 4 | import { Spinner } from "./Spinner"; 5 | 6 | const variants = { 7 | primary: "bg-gradient-to-r from-teal-200 to-teal-500 text-slate-700", 8 | inverse: "bg-white text-teal-600", 9 | danger: "bg-gradient-to-r from-red-300 to-red-600 text-white border-none", 10 | blend: "bg-gradient-to-r from-teal-200 to-teal-500 border-none", 11 | light: "bg-gradient-to-t from-slate-300 to-teal-300 border-none", 12 | transparent: "bg-gradient-to-r from-teal-50 to-teal-200", 13 | moretransparent: "bg-gradient-to-r from-teal-50 to-teal-100", 14 | }; 15 | 16 | const sizes = { 17 | xs: "py-0 px-0", 18 | sm: "py-2 px-4 text-sm", 19 | md: "py-2 px-6 text-md", 20 | lg: "py-3 px-8 text-lg", 21 | }; 22 | 23 | type IconProps = 24 | | { startIcon: React.ReactElement; endIcon?: never } 25 | | { endIcon: React.ReactElement; startIcon?: never } 26 | | { endIcon?: undefined; startIcon?: undefined }; 27 | 28 | export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { 29 | variant?: keyof typeof variants; 30 | size?: keyof typeof sizes; 31 | isLoading?: boolean; 32 | } & IconProps; 33 | 34 | export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 35 | ( 36 | { 37 | type = "button", 38 | className = "", 39 | variant = "primary", 40 | size = "md", 41 | isLoading = false, 42 | startIcon, 43 | endIcon, 44 | ...props 45 | }, 46 | ref 47 | ) => { 48 | return ( 49 | <button 50 | ref={ref} 51 | type={type} 52 | className={cslx( 53 | "flex justify-center items-center border border-gray-300 disabled:opacity-70 disabled:cursor-not-allowed hover:opacity-80 focus:outline-none focus:shadow-outline", 54 | variants[variant], 55 | sizes[size], 56 | className 57 | )} 58 | {...props} 59 | > 60 | {isLoading ? ( 61 | <Spinner size={size ? size : "md"} className="text-current" /> 62 | ) : ( 63 | <span className="mx-2">{props.children}</span> 64 | )} 65 | {!isLoading && startIcon} 66 | 67 | {!isLoading && endIcon} 68 | </button> 69 | ); 70 | } 71 | ); 72 | 73 | Button.displayName = "Button"; 74 | -------------------------------------------------------------------------------- /src/features/landing/utils/features.ts: -------------------------------------------------------------------------------- 1 | import CategoryChoose from "../../../assets/images/category-choose.png"; 2 | import ConnectSocial from "../../../assets/images/connect-social.png"; 3 | import Deal from "../../../assets/images/deal.png"; 4 | import InfluencerCategory from "../../../assets/images/influencer-category.png"; 5 | import InfluencerOffer from "../../../assets/images/influencer-offer.png"; 6 | import ProfileAudit from "../../../assets/images/profile-audit.png"; 7 | import RecommendedInfluencer from "../../../assets/images/recommend-influencer.png"; 8 | 9 | export type Feature = { 10 | id: number; 11 | title: string; 12 | description: string; 13 | imageLink: string; 14 | }; 15 | 16 | export type FeaturesData = { 17 | brand: Feature[]; 18 | influencer: Feature[]; 19 | }; 20 | 21 | export const features: FeaturesData = { 22 | brand: [ 23 | { 24 | id: 1, 25 | title: "Choose Your Category", 26 | description: "Select the industry that best describes your business", 27 | imageLink: CategoryChoose, 28 | }, 29 | { 30 | id: 2, 31 | title: "Browse Recommended Influencers", 32 | description: 33 | "Quickly discover influencers that best align with your brand", 34 | imageLink: RecommendedInfluencer, 35 | }, 36 | { 37 | id: 3, 38 | title: "Get Simplified Analytics", 39 | description: 40 | "Easily understand which influencers will bring you the most value", 41 | imageLink: ProfileAudit, 42 | }, 43 | { 44 | id: 4, 45 | title: "Make a Deal", 46 | description: "Once you’ve found the right influencer, send an offer!", 47 | imageLink: Deal, 48 | }, 49 | ], 50 | 51 | influencer: [ 52 | { 53 | id: 1, 54 | title: "Choose Your Category", 55 | description: "Select the industries that best align with your content", 56 | imageLink: InfluencerCategory, 57 | }, 58 | { 59 | id: 2, 60 | title: "Connect Your Socials", 61 | description: "Quickly connect your social media accounts", 62 | imageLink: ConnectSocial, 63 | }, 64 | { 65 | id: 3, 66 | title: "Make a Deal", 67 | description: 68 | "Sit back and watch brands come to you. Accept, decline, or negotiate offers", 69 | imageLink: InfluencerOffer, 70 | }, 71 | ], 72 | }; 73 | -------------------------------------------------------------------------------- /src/features/posts/Components/CreatePostDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useSelector } from "react-redux"; 3 | import { RootState } from "stores/store"; 4 | import { useDisclosure } from "../../../hooks/useDisclosure"; 5 | import Avatar from "../../user/Components/Avatar"; 6 | import { Author } from "../types/postType"; 7 | import CreatePost from "./CreatePost"; 8 | 9 | const CreatePostDisplay = () => { 10 | const { isOpen, open: openModal, close: closeModal } = useDisclosure(false); 11 | const { t } = useTranslation(); 12 | const user = useSelector<RootState, Author | undefined>( 13 | (store) => store.user.user 14 | ); 15 | return ( 16 | <div className="w-11/12 p-4 bg-white border rounded-lg shadow-2xl drop- md:w-3/5 lg:w-1/2"> 17 | <div className="flex"> 18 | <div className="flex items-center justify-between w-full py-3 gap-x-1 lg:gap-x-0"> 19 | <div className="flex items-start justify-center w-2/12 "> 20 | <Avatar 21 | url={user?.account.avatar.url} 22 | firstName={user?.account.username} 23 | username={user?.account.username} 24 | /> 25 | </div> 26 | <div 27 | className="flex items-center w-10/12 transition-all duration-300 lg:w-11/12" 28 | onClick={() => { 29 | openModal(); 30 | }} 31 | > 32 | <input 33 | placeholder={`${t("posts.createPost")} 💬`} 34 | onChange={() => { 35 | openModal(); 36 | }} 37 | value="" 38 | className="self-center w-full px-3 py-2 text-sm leading-tight text-gray-700 transition-all duration-300 border rounded-full outline-none hover:border-teal-200" 39 | /> 40 | </div> 41 | </div> 42 | </div> 43 | <div className="flex justify-between w-full text-slate-600"> 44 | <button className="w-1/2 hover:bg-slate-200" onClick={openModal}> 45 | 📷 Photos 46 | </button> 47 | <button className="w-1/2 hover:bg-slate-200" onClick={openModal}> 48 | #️⃣ Tags 49 | </button> 50 | </div> 51 | <CreatePost 52 | isOpen={isOpen} 53 | openPostModal={openModal} 54 | closePostModal={closeModal} 55 | /> 56 | </div> 57 | ); 58 | }; 59 | 60 | export default CreatePostDisplay; 61 | -------------------------------------------------------------------------------- /src/components/Elements/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Transition, Dialog as UIDialog } from "@headlessui/react"; 2 | import * as React from "react"; 3 | import { Fragment } from "react"; 4 | 5 | type DialogProps = { 6 | isOpen: boolean; 7 | closeModal: () => void; 8 | children: React.ReactNode; 9 | initialFocus?: React.MutableRefObject<null>; 10 | className?: string; 11 | modalClassName?: string; 12 | }; 13 | 14 | export const DialogTitle = UIDialog.Title; 15 | 16 | export const DialogDescription = UIDialog.Description; 17 | 18 | export const Dialog = ({ 19 | isOpen, 20 | closeModal, 21 | children, 22 | className, 23 | modalClassName, 24 | }: DialogProps) => { 25 | return ( 26 | <Transition appear show={isOpen} as={Fragment}> 27 | <UIDialog 28 | as="div" 29 | className={`relative font-montserrat ${ 30 | className?.includes("delete") ? "z-50" : "z-10" 31 | }`} 32 | onClose={closeModal} 33 | > 34 | <Transition.Child 35 | as={Fragment} 36 | enter="ease-out duration-300" 37 | enterFrom="opacity-0" 38 | enterTo="opacity-100" 39 | leave="ease-in duration-200" 40 | leaveFrom="opacity-100" 41 | leaveTo="opacity-0" 42 | > 43 | <div className="fixed inset-0 bg-black bg-opacity-25" /> 44 | </Transition.Child> 45 | 46 | <div className="fixed inset-0 overflow-y-auto"> 47 | <div 48 | className={`flex items-center justify-center min-h-full text-center md:p-1 lg:p-10 ${modalClassName} `} 49 | > 50 | <Transition.Child 51 | as={Fragment} 52 | enter="ease-out duration-300" 53 | enterFrom="opacity-0 scale-95" 54 | enterTo="opacity-100 scale-100" 55 | leave="ease-in duration-200" 56 | leaveFrom="opacity-100 scale-100" 57 | leaveTo="opacity-0 scale-95" 58 | > 59 | <UIDialog.Panel 60 | className={`w-screen min-h-screen p-6 m-0 text-left align-middle transition-all transform bg-white shadow-xl lg:rounded md:w-full md:mt-2 md:min-h-full ${ 61 | className ? className : "overflow-hidden" 62 | }`} 63 | > 64 | {children} 65 | </UIDialog.Panel> 66 | </Transition.Child> 67 | </div> 68 | </div> 69 | </UIDialog> 70 | </Transition> 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/features/chat/api/deleteChat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryClient, 3 | useMutation, 4 | useQueryClient, 5 | } from "@tanstack/react-query"; 6 | import { useTranslation } from "react-i18next"; 7 | import { useNavigate } from "react-router-dom"; 8 | import { Chat, ChatMessage } from "../../../features/posts/types/postType"; 9 | import { axios } from "../../../services/apiClient"; 10 | import { addNotification } from "../../../stores/notificationSlice"; 11 | import store from "../../../stores/store"; 12 | import { ResponseType } from "../../../types/responseType"; 13 | 14 | export const deleteOneToOneChat = ( 15 | queryClient: QueryClient, 16 | chatId: string 17 | ) => { 18 | queryClient.setQueryData<ResponseType<Chat[]>>(["chats"], (oldChats) => { 19 | if (!oldChats) return oldChats; 20 | return { 21 | ...oldChats, 22 | data: oldChats.data.filter((chat) => chat._id !== chatId), 23 | }; 24 | }); 25 | }; 26 | 27 | const deleteChat = () => { 28 | const queryClient = useQueryClient(); 29 | const { t } = useTranslation(); 30 | const deleteChatData = ({ 31 | chatId, 32 | isGroup, 33 | isLeave, 34 | }: { 35 | chatId: string; 36 | isGroup: boolean; 37 | isLeave?: boolean; 38 | }) => { 39 | if (isGroup) { 40 | if (isLeave) { 41 | return axios.delete<ResponseType<ChatMessage>>( 42 | `/chat-app/chats/leave/group/${chatId}` 43 | ); 44 | } 45 | return axios.delete<ResponseType<{}>>(`/chat-app/chats/group/${chatId}`); 46 | } 47 | return axios.delete<ResponseType<{}>>(`/chat-app/chats/remove/${chatId}`); 48 | }; 49 | return useMutation({ 50 | mutationFn: deleteChatData, 51 | onMutate: (variables) => { 52 | const previousChats = queryClient.getQueryData(["chats"]); 53 | 54 | deleteOneToOneChat(queryClient, variables.chatId); 55 | return { previousChats }; 56 | }, 57 | onError: (err, newPost, context) => { 58 | if (!context) return; 59 | queryClient.setQueryData(["chats"], context.previousChats); 60 | }, 61 | onSuccess: (response) => { 62 | queryClient.invalidateQueries(["chats"]); 63 | const { dispatch } = store; 64 | const navigate = useNavigate(); 65 | navigate("."); 66 | dispatch( 67 | addNotification({ 68 | type: "success", 69 | title: t("notification.success"), 70 | message: t("notificationMessages.deleteChat"), 71 | }) 72 | ); 73 | }, 74 | }); 75 | }; 76 | 77 | export default deleteChat; 78 | -------------------------------------------------------------------------------- /src/features/user/Components/UploadProfileImage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button } from "../../../components/Elements/Button"; 3 | import CloseModal from "../../../components/Elements/CloseModal"; 4 | import { Dialog } from "../../../components/Elements/Dialog"; 5 | import DragAndDrop from "../../../components/Shared/DragDropPhotos"; 6 | import { convertToBlob } from "../../../utils/convertToBlob"; 7 | import postImage from "../api/postImage"; 8 | 9 | type UploadProfileImageProps = { 10 | isImageUploadOpen: boolean; 11 | closeModal: () => void; 12 | title: string; 13 | coverImageExist?: boolean; 14 | }; 15 | const UploadProfileImage = ({ 16 | isImageUploadOpen, 17 | closeModal, 18 | title, 19 | coverImageExist, 20 | }: UploadProfileImageProps) => { 21 | const [fileDataURLs, setFileDataURLs] = useState<string[]>([]); 22 | const { 23 | mutate, 24 | error: postError, 25 | isLoading: isPostImageLoading, 26 | } = postImage(coverImageExist); 27 | 28 | const handleSubmit = (e: any) => { 29 | e.preventDefault(); 30 | const formData = new FormData(); 31 | const blob = convertToBlob(fileDataURLs[0]); 32 | if (coverImageExist) { 33 | formData.append(`coverImage`, blob, `file.png`); 34 | } else { 35 | formData.append(`avatar`, blob, `file.png`); 36 | } 37 | mutate(formData); 38 | closeModal(); 39 | setFileDataURLs([]); 40 | }; 41 | return ( 42 | <> 43 | <Dialog 44 | isOpen={isImageUploadOpen} 45 | closeModal={closeModal} 46 | className="deleteDialog md:w-3/5 lg:w-2/5 md:rounded-lg" 47 | > 48 | <form 49 | className="flex flex-col" 50 | onSubmit={(e) => { 51 | handleSubmit(e); 52 | }} 53 | > 54 | <h1 className="font-bold text-center">{title}</h1> 55 | <CloseModal closeModal={closeModal} variant="transparent" /> 56 | <DragAndDrop 57 | fileDataURLs={fileDataURLs} 58 | setFileDataURLs={setFileDataURLs} 59 | TOTAL_UPLOADABLE_IMAGES={1} 60 | /> 61 | <Button 62 | variant={fileDataURLs.length < 1 ? "transparent" : "blend"} 63 | className={`${ 64 | fileDataURLs.length < 1 ? "cursor-not-allowed" : "" 65 | } self-center mt-4 mb-2 border-none`} 66 | type="submit" 67 | > 68 | ☁️ Upload 69 | </Button> 70 | </form> 71 | </Dialog> 72 | </> 73 | ); 74 | }; 75 | 76 | export default UploadProfileImage; 77 | -------------------------------------------------------------------------------- /src/assets/images/react.svg: -------------------------------------------------------------------------------- 1 | <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.5C0 3.96447 0 2.1967 1.09835 1.09835C2.1967 0 3.96447 0 7.5 0H22.5C26.0355 0 27.8033 0 28.9017 1.09835C30 2.1967 30 3.96447 30 7.5V22.5C30 26.0355 30 27.8033 28.9017 28.9017C27.8033 30 26.0355 30 22.5 30H7.5C3.96447 30 2.1967 30 1.09835 28.9017C0 27.8033 0 26.0355 0 22.5V7.5ZM7.5 1.875H22.5C24.3208 1.875 25.4977 1.87898 26.3649 1.99557C27.1782 2.10491 27.4331 2.28144 27.5758 2.42417C27.7186 2.56691 27.8951 2.82181 28.0044 3.63507C28.121 4.50227 28.125 5.67923 28.125 7.5V22.5C28.125 24.3208 28.121 25.4977 28.0044 26.3649C27.8951 27.1782 27.7186 27.4331 27.5758 27.5758C27.4331 27.7186 27.1782 27.8951 26.3649 28.0044C25.4977 28.121 24.3208 28.125 22.5 28.125H7.5C5.67923 28.125 4.50227 28.121 3.63507 28.0044C2.82181 27.8951 2.56691 27.7186 2.42417 27.5758C2.28144 27.4331 2.10491 27.1782 1.99557 26.3649C1.87898 25.4977 1.875 24.3208 1.875 22.5V7.5C1.875 5.67923 1.87898 4.50227 1.99557 3.63507C2.10491 2.82181 2.28144 2.56691 2.42417 2.42417C2.56691 2.28144 2.82181 2.10491 3.63507 1.99557C4.50227 1.87898 5.67923 1.875 7.5 1.875Z" fill="url(#paint0_linear_293_800)"/> 3 | <path fill-rule="evenodd" clip-rule="evenodd" d="M15.029 8.10261V8.0769L6.37512 21.9231C6.37512 21.9231 8.5521 19.1564 9.34988 18.3366C10.1477 17.5169 10.5127 17.2351 10.9725 16.9149C11.4322 16.5947 12.7303 15.7749 13.4064 15.7749C14.0825 15.7749 14.398 16.2616 14.4881 16.4666C14.5782 16.6715 14.6412 17.0045 14.6909 17.4656C14.8078 18.549 14.8549 19.3013 14.9073 20.1399C14.9398 20.6595 14.9744 21.2123 15.029 21.8974V21.9231L23.6828 8.07699C23.6828 8.07699 21.5058 10.8437 20.7081 11.6634C19.9103 12.4832 19.5452 12.765 19.0855 13.0852C18.6257 13.4054 17.3276 14.2251 16.6516 14.2251C15.9755 14.2251 15.66 13.7384 15.5698 13.5335C15.4797 13.3285 15.4168 12.9955 15.367 12.5344C15.2501 11.451 15.2031 10.6987 15.1506 9.8602C15.1181 9.34054 15.0835 8.78778 15.029 8.10261Z" fill="url(#paint1_linear_293_800)"/> 4 | <defs> 5 | <linearGradient id="paint0_linear_293_800" x1="-7.64" y1="25.95" x2="38.9542" y2="21.1923" gradientUnits="userSpaceOnUse"> 6 | <stop stop-color="#F63747"/> 7 | <stop stop-color="#EB2868"/> 8 | <stop offset="1" stop-color="#FA591B"/> 9 | </linearGradient> 10 | <linearGradient id="paint1_linear_293_800" x1="1.96743" y1="20.0538" x2="28.6936" y2="16.6426" gradientUnits="userSpaceOnUse"> 11 | <stop stop-color="#F63747"/> 12 | <stop stop-color="#EB2868"/> 13 | <stop offset="1" stop-color="#FA591B"/> 14 | </linearGradient> 15 | </defs> 16 | </svg> 17 | -------------------------------------------------------------------------------- /src/assets/images/nfluence-logo.svg: -------------------------------------------------------------------------------- 1 | <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.5C0 3.96447 0 2.1967 1.09835 1.09835C2.1967 0 3.96447 0 7.5 0H22.5C26.0355 0 27.8033 0 28.9017 1.09835C30 2.1967 30 3.96447 30 7.5V22.5C30 26.0355 30 27.8033 28.9017 28.9017C27.8033 30 26.0355 30 22.5 30H7.5C3.96447 30 2.1967 30 1.09835 28.9017C0 27.8033 0 26.0355 0 22.5V7.5ZM7.5 1.875H22.5C24.3208 1.875 25.4977 1.87898 26.3649 1.99557C27.1782 2.10491 27.4331 2.28144 27.5758 2.42417C27.7186 2.56691 27.8951 2.82181 28.0044 3.63507C28.121 4.50227 28.125 5.67923 28.125 7.5V22.5C28.125 24.3208 28.121 25.4977 28.0044 26.3649C27.8951 27.1782 27.7186 27.4331 27.5758 27.5758C27.4331 27.7186 27.1782 27.8951 26.3649 28.0044C25.4977 28.121 24.3208 28.125 22.5 28.125H7.5C5.67923 28.125 4.50227 28.121 3.63507 28.0044C2.82181 27.8951 2.56691 27.7186 2.42417 27.5758C2.28144 27.4331 2.10491 27.1782 1.99557 26.3649C1.87898 25.4977 1.875 24.3208 1.875 22.5V7.5C1.875 5.67923 1.87898 4.50227 1.99557 3.63507C2.10491 2.82181 2.28144 2.56691 2.42417 2.42417C2.56691 2.28144 2.82181 2.10491 3.63507 1.99557C4.50227 1.87898 5.67923 1.875 7.5 1.875Z" fill="url(#paint0_linear_293_800)"/> 3 | <path fill-rule="evenodd" clip-rule="evenodd" d="M15.029 8.10261V8.0769L6.37512 21.9231C6.37512 21.9231 8.5521 19.1564 9.34988 18.3366C10.1477 17.5169 10.5127 17.2351 10.9725 16.9149C11.4322 16.5947 12.7303 15.7749 13.4064 15.7749C14.0825 15.7749 14.398 16.2616 14.4881 16.4666C14.5782 16.6715 14.6412 17.0045 14.6909 17.4656C14.8078 18.549 14.8549 19.3013 14.9073 20.1399C14.9398 20.6595 14.9744 21.2123 15.029 21.8974V21.9231L23.6828 8.07699C23.6828 8.07699 21.5058 10.8437 20.7081 11.6634C19.9103 12.4832 19.5452 12.765 19.0855 13.0852C18.6257 13.4054 17.3276 14.2251 16.6516 14.2251C15.9755 14.2251 15.66 13.7384 15.5698 13.5335C15.4797 13.3285 15.4168 12.9955 15.367 12.5344C15.2501 11.451 15.2031 10.6987 15.1506 9.8602C15.1181 9.34054 15.0835 8.78778 15.029 8.10261Z" fill="url(#paint1_linear_293_800)"/> 4 | <defs> 5 | <linearGradient id="paint0_linear_293_800" x1="-7.64" y1="25.95" x2="38.9542" y2="21.1923" gradientUnits="userSpaceOnUse"> 6 | <stop stop-color="#F63747"/> 7 | <stop stop-color="#EB2868"/> 8 | <stop offset="1" stop-color="#FA591B"/> 9 | </linearGradient> 10 | <linearGradient id="paint1_linear_293_800" x1="1.96743" y1="20.0538" x2="28.6936" y2="16.6426" gradientUnits="userSpaceOnUse"> 11 | <stop stop-color="#F63747"/> 12 | <stop stop-color="#EB2868"/> 13 | <stop offset="1" stop-color="#FA591B"/> 14 | </linearGradient> 15 | </defs> 16 | </svg> 17 | -------------------------------------------------------------------------------- /src/features/chat/Components/EditGroupPostName.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useEffect } from "react"; 3 | import { SubmitHandler, useForm } from "react-hook-form"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Button } from "../../../components/Elements/Button"; 6 | import { Dialog } from "../../../components/Elements/Dialog"; 7 | import InputField from "../../../components/Form/InputField"; 8 | import { 9 | CommentValidationSchema, 10 | commentValidationSchema, 11 | } from "../../posts/Components/PostEngagements"; 12 | import updateGroupChatName from "../api/updateGroupChatName"; 13 | 14 | type EditGroupPostName = { 15 | chatId: string; 16 | closeModal: () => void; 17 | groupName: string; 18 | }; 19 | 20 | const EditGroupPostName = ({ 21 | chatId, 22 | closeModal, 23 | groupName, 24 | }: EditGroupPostName) => { 25 | const { 26 | control, 27 | trigger, 28 | setValue, 29 | handleSubmit, 30 | getValues, 31 | formState: { errors, isValid }, 32 | } = useForm<CommentValidationSchema>({ 33 | resolver: zodResolver(commentValidationSchema), 34 | }); 35 | const handleInputChange = async (field: keyof CommentValidationSchema) => { 36 | await trigger(field); 37 | }; 38 | const { mutate, error } = updateGroupChatName(); 39 | 40 | useEffect(() => { 41 | setValue("content", groupName); 42 | }, []); 43 | const onSubmit: SubmitHandler<CommentValidationSchema> = (data) => {}; 44 | const { t } = useTranslation(); 45 | return ( 46 | <> 47 | <Dialog 48 | isOpen={!!chatId} 49 | closeModal={closeModal} 50 | className="rounded-lg md:w-1/2 lg:w-1/3 deleteDialog " 51 | modalClassName=" mx-10" 52 | > 53 | <form onSubmit={handleSubmit(onSubmit)}> 54 | <InputField<CommentValidationSchema> 55 | name="content" 56 | control={control} 57 | errors={errors || error} 58 | placeholder="" 59 | label="" 60 | type="text" 61 | onKeyDown={handleInputChange} 62 | defaultValue={groupName} 63 | ></InputField> 64 | 65 | <Button 66 | type="submit" 67 | variant="blend" 68 | onClick={() => { 69 | mutate({ chatId, groupName: getValues("content") }); 70 | closeModal(); 71 | }} 72 | > 73 | {t("chatPage.updateGroupName")} 74 | </Button> 75 | </form> 76 | </Dialog> 77 | </> 78 | ); 79 | }; 80 | 81 | export default EditGroupPostName; 82 | -------------------------------------------------------------------------------- /src/features/chat/api/updateGroupChatName.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryClient, 3 | useMutation, 4 | useQueryClient, 5 | } from "@tanstack/react-query"; 6 | import { useTranslation } from "react-i18next"; 7 | import { UPDATE_GROUP_NAME_EVENT } from "../../../config/constants"; 8 | import { useSocket } from "../../../context/SocketContext"; 9 | import { Chat } from "../../../features/posts/types/postType"; 10 | import { axios } from "../../../services/apiClient"; 11 | import { addNotification } from "../../../stores/notificationSlice"; 12 | import store from "../../../stores/store"; 13 | import { ResponseType } from "../../../types/responseType"; 14 | 15 | type EditGroupPostName = { 16 | chatId: string; 17 | groupName: string; 18 | }; 19 | 20 | export const updateGroupChatNameHelper = ( 21 | queryClient: QueryClient, 22 | chatId: string, 23 | updatedName: string 24 | ) => { 25 | queryClient.setQueryData<ResponseType<Chat[]>>(["chats"], (oldChats) => { 26 | if (!oldChats) return oldChats; 27 | return { 28 | ...oldChats, 29 | data: oldChats.data.map((chat) => { 30 | if (chat._id === chatId) { 31 | return { 32 | ...chat, 33 | name: updatedName, 34 | }; 35 | } 36 | return chat; 37 | }), 38 | }; 39 | }); 40 | }; 41 | const updateGroupChatName = () => { 42 | const queryClient = useQueryClient(); 43 | const { t } = useTranslation(); 44 | const { socket } = useSocket(); 45 | const updateName = ({ chatId, groupName }: EditGroupPostName) => { 46 | return axios.patch<ResponseType<Chat>>(`/chat-app/chats/group/${chatId}`, { 47 | name: groupName, 48 | }); 49 | }; 50 | return useMutation({ 51 | mutationFn: updateName, 52 | onMutate: async ({ chatId, groupName }) => { 53 | await queryClient.cancelQueries({ 54 | queryKey: ["chats"], 55 | }); 56 | 57 | const previousChats = queryClient.getQueryData(["chats"]); 58 | updateGroupChatNameHelper(queryClient, chatId, groupName); 59 | return { previousChats }; 60 | }, 61 | onError: (err, newChat, context) => { 62 | if (!context) return; 63 | queryClient.setQueryData(["chats"], context.previousChats); 64 | }, 65 | onSuccess: (response, variables) => { 66 | queryClient.invalidateQueries(["chats"]); 67 | socket?.emit(UPDATE_GROUP_NAME_EVENT, variables.chatId); 68 | const { dispatch } = store; 69 | dispatch( 70 | addNotification({ 71 | type: "success", 72 | title: t("notification.success"), 73 | message: t("notificationMessages.updateGroupName"), 74 | }) 75 | ); 76 | }, 77 | }); 78 | }; 79 | 80 | export default updateGroupChatName; 81 | -------------------------------------------------------------------------------- /src/features/chat/Components/AvailableUserOption.tsx: -------------------------------------------------------------------------------- 1 | import { Combobox } from "@headlessui/react"; 2 | import { CheckIcon } from "@heroicons/react/outline"; 3 | import { ChatUser } from "../../posts/types/postType"; 4 | import AuthorProfile from "../../user/Components/AuthorProfile"; 5 | import { CreateChatProps } from "../api/createOnetoOneChat"; 6 | 7 | type AvailableUserOptionProps = { 8 | user: ChatUser; 9 | isGroupChatEnabled: boolean; 10 | handleClear: () => void; 11 | isChat: boolean; 12 | addParticipant?: (participantId: string) => void; 13 | mutate: ({ receiverIds, name, isGroup }: CreateChatProps) => void; 14 | }; 15 | const AvailableUserOption = ({ 16 | user, 17 | isGroupChatEnabled, 18 | handleClear, 19 | isChat, 20 | addParticipant, 21 | mutate, 22 | }: AvailableUserOptionProps) => { 23 | return ( 24 | <Combobox.Option 25 | key={user?._id} 26 | className={({ active }) => 27 | `relative cursor-default select-none py-2 pl-10 pr-4 ${ 28 | active ? "bg-teal-600 text-white" : "text-gray-900" 29 | }` 30 | } 31 | value={user} 32 | > 33 | {({ selected, active }) => ( 34 | <> 35 | { 36 | <div 37 | onClick={!isGroupChatEnabled ? handleClear : () => {}} 38 | className="flex justify-between " 39 | > 40 | <AuthorProfile 41 | username={user?.username} 42 | url={user?.avatar.url} 43 | firstName={user?.username} 44 | lastName={""} 45 | bio={""} 46 | isChat={isChat} 47 | isGroupChat={isGroupChatEnabled || !!addParticipant} 48 | closeModal={!isGroupChatEnabled ? handleClear : () => {}} 49 | createChat={ 50 | isChat && !isGroupChatEnabled 51 | ? () => 52 | mutate({ 53 | isGroup: false, 54 | receiverIds: [user._id], 55 | name: user.username, 56 | }) 57 | : addParticipant 58 | ? () => addParticipant(user._id) 59 | : () => {} 60 | } 61 | /> 62 | </div> 63 | } 64 | {selected ? ( 65 | <span 66 | className={`absolute justify-center inset-y-0 right-2 flex items-center pl-3 ${ 67 | active ? "text-white" : "text-teal-600" 68 | }`} 69 | > 70 | <CheckIcon className="w-5 h-5" aria-hidden="true" /> 71 | </span> 72 | ) : null} 73 | </> 74 | )} 75 | </Combobox.Option> 76 | ); 77 | }; 78 | 79 | export default AvailableUserOption; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nfluence-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "lint-fix": "eslint . --fix", 11 | "preview": "vite preview", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "24": "link:@heroicons/react/24", 16 | "@fortawesome/fontawesome-svg-core": "^6.4.2", 17 | "@fortawesome/free-brands-svg-icons": "^6.4.2", 18 | "@fortawesome/free-regular-svg-icons": "^6.4.2", 19 | "@fortawesome/free-solid-svg-icons": "^6.4.2", 20 | "@fortawesome/react-fontawesome": "^0.2.0", 21 | "@headlessui/react": "0.0.0-insiders.d4a94cb", 22 | "@heroicons/react": "^1.0.1", 23 | "@hookform/resolvers": "^3.3.1", 24 | "@reduxjs/toolkit": "^1.9.5", 25 | "@tanstack/react-query": "^4.35.3", 26 | "@tanstack/react-query-devtools": "^4.35.3", 27 | "@types/react-router-dom": "^5.3.3", 28 | "axios": "^1.3.4", 29 | "classnames": "^2.3.2", 30 | "clsx": "^1.1.1", 31 | "dayjs": "^1.10.6", 32 | "framer-motion": "^10.16.4", 33 | "history": "^5.3.0", 34 | "i18next": "^23.7.6", 35 | "i18next-browser-languagedetector": "^7.2.0", 36 | "moment": "^2.29.4", 37 | "nanoid": "^5.0.1", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "react-error-boundary": "^4.0.11", 41 | "react-helmet-async": "^1.1.2", 42 | "react-hook-form": "^7.46.1", 43 | "react-i18next": "^13.5.0", 44 | "react-query-auth": "^2.2.0", 45 | "react-redux": "^8.1.2", 46 | "react-router-dom": "^6.15.0", 47 | "socket.io-client": "^4.7.2", 48 | "zod": "^3.22.2" 49 | }, 50 | "devDependencies": { 51 | "@babel/core": "^7.23.5", 52 | "@babel/preset-env": "^7.23.5", 53 | "@babel/preset-typescript": "^7.23.3", 54 | "@testing-library/jest-dom": "^6.1.5", 55 | "@testing-library/react": "^14.0.0", 56 | "@types/jest": "^29.5.10", 57 | "@types/react": "^18.2.15", 58 | "@types/react-dom": "^18.2.7", 59 | "@typescript-eslint/eslint-plugin": "^6.0.0", 60 | "@typescript-eslint/parser": "^6.0.0", 61 | "@vitejs/plugin-react": "^4.0.3", 62 | "autoprefixer": "^10.4.15", 63 | "babel-jest": "^29.7.0", 64 | "eslint": "^8.45.0", 65 | "eslint-plugin-react-hooks": "^4.6.0", 66 | "eslint-plugin-react-refresh": "^0.4.3", 67 | "eslint-plugin-unused-imports": "^3.0.0", 68 | "identity-obj-proxy": "^3.0.0", 69 | "jest": "^29.7.0", 70 | "jest-environment-jsdom": "^29.7.0", 71 | "postcss": "^8.4.29", 72 | "tailwindcss": "^3.3.3", 73 | "ts-jest": "^29.1.1", 74 | "ts-node": "^10.9.1", 75 | "typescript": "^4.9.3", 76 | "vite": "^4.4.5" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/features/auth/api/loginUser.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { onboardingThresholdSeconds as thresholdSeconds } from "../../../config/constants"; 5 | import { axios } from "../../../services/apiClient"; 6 | import { addNotification } from "../../../stores/notificationSlice"; 7 | import store from "../../../stores/store"; 8 | import { 9 | handleEmailVerification, 10 | handleInitialLogin, 11 | handleLoginUser, 12 | } from "../../../stores/userSlice"; 13 | import { LocalStorage } from "../../../utils/index"; 14 | import { LoginValidationSchema } from "../utils/loginValidation"; 15 | const isUserInitialLogin = (createdAt: string, updatedAt: string) => { 16 | let createdAtDate = new Date(createdAt).valueOf(); 17 | let updatedAtDate = new Date(updatedAt).valueOf(); 18 | const timeDifference = (updatedAtDate - createdAtDate) / 1000; 19 | const isInitialLogin = timeDifference <= thresholdSeconds; 20 | return isInitialLogin; 21 | }; 22 | const useLoginUser = () => { 23 | const navigate = useNavigate(); 24 | const { t } = useTranslation(); 25 | const queryClient = useQueryClient(); 26 | const loginUser = (loginData: LoginValidationSchema) => { 27 | return axios.post("/users/login", loginData); 28 | }; 29 | return useMutation({ 30 | mutationFn: loginUser, 31 | onSuccess: (response) => { 32 | const { accessToken } = response.data; 33 | let { isEmailVerified, createdAt, updatedAt } = response.data.user; 34 | LocalStorage.set("accessToken", accessToken); 35 | queryClient.setQueryData(["auth-user"], response.data.user); 36 | const isInitialLogin = isUserInitialLogin(createdAt, updatedAt); 37 | 38 | if (accessToken) { 39 | const { dispatch } = store; 40 | dispatch(handleLoginUser()); 41 | dispatch(handleEmailVerification(isEmailVerified)); 42 | dispatch(handleInitialLogin(isInitialLogin)); 43 | 44 | if (isInitialLogin) { 45 | navigate("/onboarding"); 46 | dispatch( 47 | addNotification({ 48 | type: "success", 49 | title: t("notification.success"), 50 | message: t("notificationMessages.updateProfile"), 51 | }) 52 | ); 53 | } else { 54 | navigate("/home"); 55 | } 56 | if (!isEmailVerified) { 57 | setTimeout(() => { 58 | dispatch( 59 | addNotification({ 60 | type: "info", 61 | title: t("notification.caution"), 62 | message: t("notificationMessages.verifyEmail"), 63 | }) 64 | ); 65 | }, 1000); 66 | } 67 | } 68 | }, 69 | }); 70 | }; 71 | 72 | export default useLoginUser; 73 | -------------------------------------------------------------------------------- /src/features/posts/api/postLike.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InfiniteData, 3 | useMutation, 4 | useQueryClient, 5 | } from "@tanstack/react-query"; 6 | import { useContext } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | import { axios } from "../../../services/apiClient"; 9 | import { addNotification } from "../../../stores/notificationSlice"; 10 | import store from "../../../stores/store"; 11 | import { ResponseType } from "../../../types/responseType"; 12 | import { PostRefetchContext } from "../context/PostContext"; 13 | import { Pagination, Posts } from "../types/postType"; 14 | const postLike = (postId: string, isLiked: boolean) => { 15 | const { refetch, page } = useContext(PostRefetchContext); 16 | const postUserLike = () => { 17 | return axios.post(`/social-media/like/post/${postId}`); 18 | }; 19 | const queryClient = useQueryClient(); 20 | const { t } = useTranslation(); 21 | return useMutation({ 22 | mutationFn: postUserLike, 23 | onMutate: async (pageParam) => { 24 | await queryClient.cancelQueries({ queryKey: ["posts"] }); 25 | 26 | const previousLikes = queryClient.getQueryData(["posts"]); 27 | queryClient.setQueryData<InfiniteData<ResponseType<Posts & Pagination>>>( 28 | ["posts"], 29 | (oldPosts) => { 30 | if (!oldPosts) return oldPosts; 31 | const newPosts = oldPosts?.pages?.map( 32 | (page: ResponseType<Posts & Pagination>) => { 33 | return { 34 | ...page, 35 | data: { 36 | ...page.data, 37 | posts: page.data.posts.map((post) => { 38 | if (post._id === postId) { 39 | return { 40 | ...post, 41 | likes: isLiked ? post.likes - 1 : post.likes + 1, 42 | isLiked: !isLiked, 43 | }; 44 | } 45 | return post; 46 | }), 47 | }, 48 | }; 49 | } 50 | ); 51 | return { 52 | ...oldPosts, 53 | pages: newPosts, 54 | }; 55 | } 56 | ); 57 | return { previousLikes }; 58 | }, 59 | onError: (err, newPost, context) => { 60 | if (!context) return; 61 | queryClient.setQueryData(["posts"], context.previousLikes); 62 | }, 63 | onSuccess: (response) => { 64 | const { message } = response; 65 | const { dispatch } = store; 66 | 67 | dispatch( 68 | addNotification({ 69 | type: "success", 70 | title: t("notification.success"), 71 | message: t("notificationMessages.likePost"), 72 | }) 73 | ); 74 | refetch({ 75 | refetchPage: (_: number, index: number) => index === page - 1, 76 | }); 77 | }, 78 | }); 79 | }; 80 | 81 | export default postLike; 82 | -------------------------------------------------------------------------------- /src/components/Shimmer/ShimmerProfile.tsx: -------------------------------------------------------------------------------- 1 | const ShimmerProfile = () => { 2 | return ( 3 | <div className="flex items-center justify-center w-full"> 4 | <div className="w-11/12 p-4 bg-white rounded-lg shadow-md"> 5 | <div className="relative mb-4"> 6 | <div className="h-48 rounded-t-lg animate-pulse bg-theme-color bg-gradient-to-r from-teal-100 to-teal-300"> 7 | <div className="w-full h-48 shimmer-cover-image animate-pulse"></div> 8 | </div> 9 | <div className="absolute bottom-0 w-20 h-20 transform -translate-x-1/2 translate-y-1/2 border-4 border-white rounded-full left-1/2"> 10 | <div className="w-full h-full rounded-full bg-slate-200 animate-pulse"></div> 11 | </div> 12 | </div> 13 | <div className="flex flex-col items-center justify-center mt-20 mb-10"> 14 | <div className="w-32 h-4 mb-4 bg-slate-200 shimmer-info animate-pulse"></div> 15 | <div className="w-64 h-4 mb-4 bg-slate-200 shimmer-info animate-pulse"></div> 16 | <div className="w-32 h-4 mb-4 bg-slate-200 shimmer-info animate-pulse"></div> 17 | </div> 18 | <div className="flex flex-col md:flex-row"> 19 | <div className="flex flex-row flex-wrap w-full gap-4 md:flex-col md:w-2/12 md:border-r-slate-100 md:border-r "> 20 | <div className="w-32 h-8 mb-4 bg-gradient-to-r from-teal-100 to-teal-200 shimmer-links animate-pulse"></div> 21 | <div className="w-32 h-8 mb-4 bg-gradient-to-r from-teal-100 to-teal-200 shimmer-links animate-pulse"></div> 22 | <div className="w-32 h-8 mb-4 bg-gradient-to-r from-teal-100 to-teal-200 shimmer-links animate-pulse"></div> 23 | </div> 24 | <div className="w-full md:w-10/12"> 25 | <div className="py-3 md:px-9"> 26 | <div className="flex justify-between mt-4 mb-5"> 27 | <div className="flex items-end w-24 h-12 rounded-lg bg-slate-200 animate-pulse"> 28 | <div className="w-16 h-2 mx-auto my-2 mt-2 bg-slate-300"></div> 29 | </div> 30 | <div className="flex items-end w-24 h-12 rounded-lg bg-slate-200 animate-pulse"> 31 | <div className="w-16 h-2 mx-auto my-2 mt-2 bg-slate-300"></div> 32 | </div> 33 | </div> 34 | <div className="flex flex-col flex-wrap gap-10 fmt-4"> 35 | <div className="w-64 h-6 rounded-lg bg-slate-200 animate-pulse"></div> 36 | <div className="w-64 h-6 rounded-lg bg-slate-200 animate-pulse"></div> 37 | <div className="w-64 h-6 rounded-lg bg-slate-200 animate-pulse"></div> 38 | <div className="w-64 h-6 rounded-lg bg-slate-200 animate-pulse"></div> 39 | </div> 40 | </div> 41 | </div> 42 | </div> 43 | </div> 44 | </div> 45 | ); 46 | }; 47 | 48 | export default ShimmerProfile; 49 | -------------------------------------------------------------------------------- /src/features/comments/api/postLikeComment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InfiniteData, 3 | useMutation, 4 | useQueryClient, 5 | } from "@tanstack/react-query"; 6 | import { useContext } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | import { axios } from "../../../services/apiClient"; 9 | import { addNotification } from "../../../stores/notificationSlice"; 10 | import store from "../../../stores/store"; 11 | import { ResponseType } from "../../../types/responseType"; 12 | import { CommentRefetchContext } from "../../posts/context/CommentContext"; 13 | import { PostRefetchContext } from "../../posts/context/PostContext"; 14 | import { Comments, Pagination } from "../../posts/types/postType"; 15 | 16 | const postLikeComment = (commentId: string, isLiked: boolean) => { 17 | const { postId } = useContext(PostRefetchContext); 18 | const { refetch, page } = useContext(CommentRefetchContext); 19 | 20 | const postUserLikeComment = () => { 21 | return axios.post(`/social-media/like/comment/${commentId}`); 22 | }; 23 | const queryClient = useQueryClient(); 24 | const { t } = useTranslation(); 25 | return useMutation({ 26 | mutationFn: postUserLikeComment, 27 | onMutate: async (pageParam) => { 28 | await queryClient.cancelQueries({ queryKey: ["comments", postId] }); 29 | 30 | const previousLikes = queryClient.getQueryData(["comments", postId]); 31 | queryClient.setQueryData< 32 | InfiniteData<ResponseType<Comments & Pagination>> 33 | >(["comments", postId], (oldComments) => { 34 | if (!oldComments) return oldComments; 35 | const newComments = oldComments?.pages?.map( 36 | (page: ResponseType<Comments & Pagination>) => { 37 | return { 38 | ...page, 39 | data: { 40 | ...page.data, 41 | comments: page.data.comments.map((comment) => { 42 | if (comment._id === commentId) { 43 | return { 44 | ...comment, 45 | likes: isLiked ? comment.likes - 1 : comment.likes + 1, 46 | isLiked: !isLiked, 47 | }; 48 | } 49 | return comment; 50 | }), 51 | }, 52 | }; 53 | } 54 | ); 55 | return { 56 | ...oldComments, 57 | pages: newComments, 58 | }; 59 | }); 60 | return { previousLikes }; 61 | }, 62 | onError: (err, newPost, context) => { 63 | if (!context) return; 64 | queryClient.setQueryData(["comments", postId], context.previousLikes); 65 | }, 66 | onSuccess: (response) => { 67 | const { dispatch } = store; 68 | 69 | dispatch( 70 | addNotification({ 71 | type: "success", 72 | title: t("notification.success"), 73 | message: t("notificationMessages.likeComment"), 74 | }) 75 | ); 76 | refetch({ 77 | refetchPage: (_: number, index: number) => index === page - 1, 78 | }); 79 | }, 80 | }); 81 | }; 82 | 83 | export default postLikeComment; 84 | -------------------------------------------------------------------------------- /src/assets/images/w-instagram.svg: -------------------------------------------------------------------------------- 1 | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <g clip-path="url(#clip0_268_8235)"> 3 | <path d="M12 2.16094C15.2063 2.16094 15.5859 2.175 16.8469 2.23125C18.0188 2.28281 18.6516 2.47969 19.0734 2.64375C19.6313 2.85938 20.0344 3.12188 20.4516 3.53906C20.8734 3.96094 21.1313 4.35938 21.3469 4.91719C21.5109 5.33906 21.7078 5.97656 21.7594 7.14375C21.8156 8.40937 21.8297 8.78906 21.8297 11.9906C21.8297 15.1969 21.8156 15.5766 21.7594 16.8375C21.7078 18.0094 21.5109 18.6422 21.3469 19.0641C21.1313 19.6219 20.8687 20.025 20.4516 20.4422C20.0297 20.8641 19.6313 21.1219 19.0734 21.3375C18.6516 21.5016 18.0141 21.6984 16.8469 21.75C15.5813 21.8062 15.2016 21.8203 12 21.8203C8.79375 21.8203 8.41406 21.8062 7.15313 21.75C5.98125 21.6984 5.34844 21.5016 4.92656 21.3375C4.36875 21.1219 3.96563 20.8594 3.54844 20.4422C3.12656 20.0203 2.86875 19.6219 2.65313 19.0641C2.48906 18.6422 2.29219 18.0047 2.24063 16.8375C2.18438 15.5719 2.17031 15.1922 2.17031 11.9906C2.17031 8.78438 2.18438 8.40469 2.24063 7.14375C2.29219 5.97187 2.48906 5.33906 2.65313 4.91719C2.86875 4.35938 3.13125 3.95625 3.54844 3.53906C3.97031 3.11719 4.36875 2.85938 4.92656 2.64375C5.34844 2.47969 5.98594 2.28281 7.15313 2.23125C8.41406 2.175 8.79375 2.16094 12 2.16094ZM12 0C8.74219 0 8.33438 0.0140625 7.05469 0.0703125C5.77969 0.126563 4.90313 0.332812 4.14375 0.628125C3.35156 0.9375 2.68125 1.34531 2.01563 2.01562C1.34531 2.68125 0.9375 3.35156 0.628125 4.13906C0.332812 4.90313 0.126563 5.775 0.0703125 7.05C0.0140625 8.33437 0 8.74219 0 12C0 15.2578 0.0140625 15.6656 0.0703125 16.9453C0.126563 18.2203 0.332812 19.0969 0.628125 19.8563C0.9375 20.6484 1.34531 21.3188 2.01563 21.9844C2.68125 22.65 3.35156 23.0625 4.13906 23.3672C4.90313 23.6625 5.775 23.8687 7.05 23.925C8.32969 23.9812 8.7375 23.9953 11.9953 23.9953C15.2531 23.9953 15.6609 23.9812 16.9406 23.925C18.2156 23.8687 19.0922 23.6625 19.8516 23.3672C20.6391 23.0625 21.3094 22.65 21.975 21.9844C22.6406 21.3188 23.0531 20.6484 23.3578 19.8609C23.6531 19.0969 23.8594 18.225 23.9156 16.95C23.9719 15.6703 23.9859 15.2625 23.9859 12.0047C23.9859 8.74688 23.9719 8.33906 23.9156 7.05938C23.8594 5.78438 23.6531 4.90781 23.3578 4.14844C23.0625 3.35156 22.6547 2.68125 21.9844 2.01562C21.3188 1.35 20.6484 0.9375 19.8609 0.632812C19.0969 0.3375 18.225 0.13125 16.95 0.075C15.6656 0.0140625 15.2578 0 12 0Z" fill="white"/> 4 | <path d="M12 5.83594C8.59688 5.83594 5.83594 8.59688 5.83594 12C5.83594 15.4031 8.59688 18.1641 12 18.1641C15.4031 18.1641 18.1641 15.4031 18.1641 12C18.1641 8.59688 15.4031 5.83594 12 5.83594ZM12 15.9984C9.79219 15.9984 8.00156 14.2078 8.00156 12C8.00156 9.79219 9.79219 8.00156 12 8.00156C14.2078 8.00156 15.9984 9.79219 15.9984 12C15.9984 14.2078 14.2078 15.9984 12 15.9984Z" fill="white"/> 5 | <path d="M19.8469 5.59238C19.8469 6.38926 19.2 7.03145 18.4078 7.03145C17.6109 7.03145 16.9688 6.38457 16.9688 5.59238C16.9688 4.79551 17.6156 4.15332 18.4078 4.15332C19.2 4.15332 19.8469 4.8002 19.8469 5.59238Z" fill="white"/> 6 | </g> 7 | <defs> 8 | <clipPath id="clip0_268_8235"> 9 | <rect width="24" height="24" fill="white"/> 10 | </clipPath> 11 | </defs> 12 | </svg> 13 | -------------------------------------------------------------------------------- /src/components/Shared/AvailableLanguages.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from "@headlessui/react"; 2 | import { CheckIcon, ChevronDownIcon } from "@heroicons/react/outline"; 3 | import { Fragment } from "react"; 4 | import { languages, useLanguage } from "../../context/LanguageContext"; 5 | 6 | export default function AvailableLanguages() { 7 | const { language, setLanguage, changeLanguage } = useLanguage(); 8 | return ( 9 | <div className="text-xs"> 10 | <Listbox 11 | value={language} 12 | onChange={(language) => { 13 | setLanguage(language); 14 | changeLanguage(language); 15 | }} 16 | > 17 | <div className="relative mt-1"> 18 | <Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white rounded-lg shadow-md cursor-default focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm"> 19 | <span className="block text-xs truncate"> 20 | {language.nativeName} 21 | </span> 22 | <span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> 23 | <ChevronDownIcon 24 | className="w-5 h-5 text-gray-400" 25 | aria-hidden="true" 26 | /> 27 | </span> 28 | </Listbox.Button> 29 | <Transition 30 | as={Fragment} 31 | leave="transition ease-in duration-100" 32 | leaveFrom="opacity-100" 33 | leaveTo="opacity-0" 34 | > 35 | <Listbox.Options className="w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black/5 focus:outline-none sm:text-sm"> 36 | {languages.map((lng, languageIdx) => ( 37 | <Listbox.Option 38 | key={`${languageIdx}${lng}`} 39 | className={({ active }) => 40 | `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ 41 | active ? "bg-teal-100 text-teal-900" : "text-gray-900" 42 | }` 43 | } 44 | value={lng} 45 | > 46 | {({ selected }) => ( 47 | <> 48 | <span 49 | className={`block truncate text-xs ${ 50 | lng ? "font-medium" : "font-normal" 51 | }`} 52 | > 53 | {lng.nativeName} 54 | </span> 55 | {selected ? ( 56 | <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-teal-600"> 57 | <CheckIcon className="w-5 h-5" aria-hidden="true" /> 58 | </span> 59 | ) : null} 60 | </> 61 | )} 62 | </Listbox.Option> 63 | ))} 64 | </Listbox.Options> 65 | </Transition> 66 | </div> 67 | </Listbox> 68 | </div> 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/Notifications/Notification.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@headlessui/react"; 2 | import { 3 | CheckCircleIcon, 4 | ExclamationCircleIcon, 5 | InformationCircleIcon, 6 | XCircleIcon, 7 | } from "@heroicons/react/outline"; 8 | import { XIcon } from "@heroicons/react/solid"; 9 | import { Fragment, useEffect } from "react"; 10 | import { Notification } from "stores/notificationSlice"; 11 | 12 | const icons = { 13 | info: ( 14 | <InformationCircleIcon 15 | className="w-6 h-6 text-teal-900" 16 | aria-hidden="true" 17 | /> 18 | ), 19 | success: ( 20 | <CheckCircleIcon className="w-6 h-6 text-green-500" aria-hidden="true" /> 21 | ), 22 | warning: ( 23 | <ExclamationCircleIcon 24 | className="w-6 h-6 text-yellow-300" 25 | aria-hidden="true" 26 | /> 27 | ), 28 | error: <XCircleIcon className="w-6 h-6 text-red-500" aria-hidden="true" />, 29 | }; 30 | 31 | export type NotificationProps = { 32 | notification: Notification; 33 | onDismiss: () => void; 34 | }; 35 | 36 | const notificationStyles = { 37 | success: "bg-green-500 text-white", 38 | info: "bg-blue-500 text-white", 39 | warning: "bg-yellow-500 text-black", 40 | error: "bg-red-500 text-white", 41 | }; 42 | 43 | export const SingleNotification = ({ 44 | notification: { id, type, title, message }, 45 | onDismiss, 46 | }: NotificationProps) => { 47 | useEffect(() => { 48 | let interval = setTimeout(() => { 49 | onDismiss(); 50 | }, 3000); 51 | return () => clearInterval(interval); 52 | }, []); 53 | return ( 54 | <div className="flex flex-col items-center w-full space-y-4 font-sans sm:items-end"> 55 | <Transition 56 | show={true} 57 | as={Fragment} 58 | enter="transition-opacity duration-500" 59 | enterFrom="opacity-0" 60 | enterTo="opacity-100" 61 | leave="transition-opacity duration-500" 62 | leaveFrom="opacity-100" 63 | leaveTo="opacity-0" 64 | > 65 | <div className="w-full max-w-sm overflow-hidden bg-white rounded-lg shadow-lg pointer-events-auto ring-1 ring-black ring-opacity-5"> 66 | <div className="p-4" role="alert" aria-label={title}> 67 | <div className="flex items-start"> 68 | <div className="flex-shrink-0">{icons[type]}</div> 69 | <div className="flex-1 w-0 pt-0 ml-3 5"> 70 | <p className="text-sm font-medium text-gray-900">{title}</p> 71 | <p className="mt-1 text-sm text-gray-500">{message}</p> 72 | </div> 73 | <div className="flex flex-shrink-0 ml-4"> 74 | <button 75 | className="inline-flex text-gray-400 bg-white rounded-md hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" 76 | onClick={() => onDismiss()} 77 | > 78 | <span className="sr-only">Close</span> 79 | <XIcon className="w-5 h-5" aria-hidden="true"></XIcon> 80 | </button> 81 | </div> 82 | </div> 83 | </div> 84 | <div 85 | className={`w-full h-1 overflow-hidden duration-1000 animate-grow whitespace-nowrap ${notificationStyles[type]}`} 86 | ></div> 87 | </div> 88 | </Transition> 89 | </div> 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/Elements/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Transition } from "@headlessui/react"; 2 | import { XIcon } from "@heroicons/react/outline"; 3 | import clsx from "clsx"; 4 | import * as React from "react"; 5 | 6 | const sizes = { 7 | sm: "max-w-md", 8 | md: "max-w-xl", 9 | lg: "max-w-3xl", 10 | xl: "max-w-7xl", 11 | full: "max-w-full", 12 | }; 13 | 14 | export type DrawerProps = { 15 | isOpen: boolean; 16 | onClose: () => void; 17 | title: string; 18 | children: React.ReactNode; 19 | renderFooter: () => React.ReactNode; 20 | size?: keyof typeof sizes; 21 | }; 22 | 23 | export const Drawer = ({ 24 | title, 25 | children, 26 | isOpen, 27 | onClose, 28 | renderFooter, 29 | size = "md", 30 | }: DrawerProps) => { 31 | return ( 32 | <Transition.Root show={isOpen} as={React.Fragment}> 33 | <Dialog 34 | as="div" 35 | static 36 | className="fixed inset-0 z-40 overflow-hidden" 37 | open={isOpen} 38 | onClose={onClose} 39 | > 40 | <div className="absolute inset-0 overflow-hidden"> 41 | <Dialog.Overlay className="absolute inset-0" /> 42 | <div className="fixed inset-y-0 right-0 flex max-w-full pl-10"> 43 | <Transition.Child 44 | as={React.Fragment} 45 | enter="transform transition ease-in-out duration-300 sm:duration-300" 46 | enterFrom="translate-x-full" 47 | enterTo="translate-x-0" 48 | leave="transform transition ease-in-out duration-300 sm:duration-300" 49 | leaveFrom="translate-x-0" 50 | leaveTo="translate-x-full" 51 | > 52 | <div className={clsx("w-screen", sizes[size])}> 53 | <div className="flex flex-col h-full bg-white divide-y divide-gray-200 shadow-xl"> 54 | <div className="flex flex-col flex-1 min-h-0 py-6 overflow-y-scroll"> 55 | <div className="px-4 sm:px-6"> 56 | <div className="flex items-start justify-between"> 57 | <Dialog.Title className="text-lg font-medium text-gray-900"> 58 | {title} 59 | </Dialog.Title> 60 | <div className="flex items-center ml-3 h-7"> 61 | <button 62 | className="text-gray-400 bg-white rounded-md hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" 63 | onClick={onClose} 64 | > 65 | <span className="sr-only">Close panel </span> 66 | <XIcon className="w-6 h-6" aria-hidden="true" /> 67 | </button> 68 | </div> 69 | </div> 70 | </div> 71 | <div className="relative flex-1 px-4 mt-6 sm:px-6"> 72 | {children} 73 | </div> 74 | </div> 75 | <div className="flex justify-end flex-shrink-0 px-4 py-4 space-x-2"> 76 | {renderFooter()} 77 | </div> 78 | </div> 79 | </div> 80 | </Transition.Child> 81 | </div> 82 | </div> 83 | </Dialog> 84 | </Transition.Root> 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/features/chat/api/postMessage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryClient, 3 | useMutation, 4 | useQueryClient, 5 | } from "@tanstack/react-query"; 6 | import { nanoid } from "nanoid"; 7 | import { useSelector } from "react-redux"; 8 | import { ResponseType } from "types/responseType"; 9 | import { 10 | Author, 11 | Chat, 12 | ChatMessage, 13 | } from "../../../features/posts/types/postType"; 14 | import { axios } from "../../../services/apiClient"; 15 | import { RootState } from "../../../stores/store"; 16 | 17 | export const updateChatListLastMessage = ( 18 | chatId: string | undefined, 19 | content: string, 20 | queryClient: QueryClient 21 | ) => { 22 | queryClient.setQueryData<ResponseType<Chat[]>>(["chats"], (oldChatsList) => { 23 | if (!oldChatsList) return oldChatsList; 24 | 25 | return { 26 | ...oldChatsList, 27 | data: oldChatsList.data.map((chat) => { 28 | if (chat._id === chatId) { 29 | return { 30 | ...chat, 31 | lastMessage: { 32 | ...chat.lastMessage, 33 | content: content, 34 | }, 35 | }; 36 | } 37 | return chat; 38 | }), 39 | }; 40 | }); 41 | }; 42 | 43 | const postMessage = (chatId: string | undefined) => { 44 | const queryClient = useQueryClient(); 45 | const user = useSelector<RootState, Author | undefined>( 46 | (store) => store.user.user 47 | ); 48 | const postChatData = (formData: any) => { 49 | const config = { 50 | headers: { 51 | "Content-Type": "multipart/form-data", 52 | }, 53 | }; 54 | return axios.post(`/chat-app/messages/${chatId}`, formData, config); 55 | }; 56 | return useMutation({ 57 | mutationFn: postChatData, 58 | onMutate: async (formData) => { 59 | await queryClient.cancelQueries({ 60 | queryKey: ["chats", "messages", chatId], 61 | }); 62 | 63 | const previousMessages = queryClient.getQueryData([ 64 | "chats", 65 | "messages", 66 | chatId, 67 | ]); 68 | 69 | queryClient.setQueryData<ResponseType<ChatMessage[]>>( 70 | ["chats", "messages", chatId], 71 | (oldChats) => { 72 | if (!oldChats) return oldChats; 73 | let existingChatMessage = oldChats.data.find( 74 | (message) => message.sender._id === user?.account._id 75 | ); 76 | if (!existingChatMessage) return { ...oldChats }; 77 | let newChatMessage = { 78 | ...existingChatMessage, 79 | _id: nanoid(), 80 | content: formData.get("content"), 81 | }; 82 | return { 83 | ...oldChats, 84 | data: [newChatMessage, ...oldChats.data], 85 | }; 86 | } 87 | ); 88 | updateChatListLastMessage(chatId, formData.get("content"), queryClient); 89 | return { previousMessages }; 90 | }, 91 | onError: (err, newChat, context) => { 92 | if (!context) return; 93 | queryClient.setQueryData( 94 | ["chats", "messages", chatId], 95 | context.previousMessages 96 | ); 97 | }, 98 | onSuccess: (response) => { 99 | queryClient.invalidateQueries(["chats", "messages", chatId]); 100 | }, 101 | }); 102 | }; 103 | 104 | export default postMessage; 105 | -------------------------------------------------------------------------------- /src/features/posts/types/postType.ts: -------------------------------------------------------------------------------- 1 | export interface Avatar { 2 | _id: string; 3 | localPath: string; 4 | url: string; 5 | } 6 | 7 | export interface Account { 8 | _id: string; 9 | avatar: Avatar; 10 | email: string; 11 | username: string; 12 | } 13 | 14 | export type ChatUser = Account & { 15 | __v: number; 16 | createdAt: string; 17 | updatedAt: string; 18 | isEmailVerified: boolean; 19 | loginType: string; 20 | role: string; 21 | }; 22 | 23 | export type AuthorInfo = { 24 | _id: string; 25 | firstName: string; 26 | lastName: string; 27 | countryCode: string; 28 | createdAt: string; 29 | dob: string; 30 | bio: string; 31 | location: string; 32 | owner: string; 33 | phoneNumber: string; 34 | updatedAt: string; 35 | }; 36 | export type Author = AuthorInfo & { 37 | account: Account; 38 | coverImage: CoverImage; 39 | }; 40 | 41 | export interface CoverImage { 42 | _id: string; 43 | localPath: string; 44 | url: string; 45 | } 46 | 47 | export interface Comment { 48 | _id: string; 49 | author: Author; 50 | content: string; 51 | postId: string; 52 | __v: number; 53 | createdAt: string; 54 | updatedAt: string; 55 | likes: number; 56 | isLiked: boolean; 57 | } 58 | 59 | export type Post = { 60 | _id: string; 61 | author: Author; 62 | comments: number; 63 | content: string; 64 | createdAt: string; 65 | images: Array<{ 66 | _id: string; 67 | localPath: string; 68 | url: string; 69 | }>; 70 | isBookmarked: boolean; 71 | isLiked: boolean; 72 | likes: number; 73 | tags: string[]; 74 | updatedAt: string; 75 | }; 76 | 77 | export interface Pagination { 78 | totalPosts: number; 79 | limit: number; 80 | page: number; 81 | totalPages: number; 82 | serialNumberStartFrom: number; 83 | hasPrevPage: boolean; 84 | hasNextPage: boolean; 85 | prevPage: number | null; 86 | nextPage: number | null; 87 | totalComments?: number; 88 | } 89 | 90 | export interface PostCardProps { 91 | post: Post; 92 | } 93 | export interface PostCardProp { 94 | post: Post; 95 | } 96 | 97 | export interface Posts { 98 | posts: Post[]; 99 | bookmarkedPosts?: Post[]; 100 | } 101 | 102 | export interface Comment { 103 | _id: string; 104 | content: string; 105 | postId: string; 106 | author: Author; 107 | __v: number; 108 | createdAt: string; 109 | updatedAt: string; 110 | likes: number; 111 | isLiked: boolean; 112 | } 113 | export interface Comments { 114 | comments: Comment[]; 115 | } 116 | 117 | export type UserProfile = Author & { 118 | __v: number; 119 | followersCount: number; 120 | followingCount: number; 121 | isFollowing: boolean; 122 | }; 123 | 124 | export type FollowerProfile = Account & { 125 | profile: AuthorInfo & { 126 | coverImage: CoverImage; 127 | __v: number; 128 | }; 129 | isFollowing: boolean; 130 | }; 131 | 132 | export interface Chat { 133 | _id: string; 134 | name: string; 135 | isGroupChat: boolean; 136 | participants: ChatUser[]; 137 | admin: string; 138 | createdAt: string; 139 | updatedAt: string; 140 | __v: number; 141 | lastMessage: ChatMessage; 142 | } 143 | 144 | export interface ChatMessage { 145 | _id: string; 146 | sender: Account; 147 | content: string; 148 | attachments: any[]; 149 | chat: string; 150 | createdAt: string; 151 | updatedAt: string; 152 | __v: number; 153 | } 154 | -------------------------------------------------------------------------------- /src/features/chat/Components/ChatAuthorProfile.tsx: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import LoadImage from "../../../components/Elements/LoadImage"; 3 | import Avatar from "../../user/Components/Avatar"; 4 | 5 | type ChatAuthorProfileProps = { 6 | username: string; 7 | url: string; 8 | lastMessage: string; 9 | sentTime: string; 10 | isChatSection?: boolean; 11 | isUserSender?: boolean; 12 | isGroupChat?: boolean; 13 | }; 14 | 15 | const getTimeAgoMessage = (timestamp: string) => { 16 | const now = moment(); 17 | const messageTime = moment(timestamp); 18 | if (messageTime.isSame(now, "day")) { 19 | return `${messageTime.format("h:mm A")}`; 20 | } else if (messageTime.isSame(now, "year")) { 21 | return messageTime.format("MMM D, h:mm A"); 22 | } 23 | return messageTime.format("MMM D, YYYY, h:mm A"); 24 | }; 25 | const ChatAuthorProfile = ({ 26 | username, 27 | url, 28 | lastMessage, 29 | isChatSection, 30 | isUserSender, 31 | sentTime, 32 | isGroupChat, 33 | }: ChatAuthorProfileProps) => { 34 | return ( 35 | <> 36 | <div 37 | className={`flex ${ 38 | isChatSection && isUserSender && "flex-row-reverse " 39 | } 40 | ${ 41 | isChatSection 42 | ? "items-end space-x-2 w-full" 43 | : "items-center w-11/12" 44 | } `} 45 | > 46 | {isChatSection ? ( 47 | <Avatar 48 | url={url} 49 | className={`${ 50 | isChatSection ? " w-8 h-8 " : " w-12 h-12 mr-4 " 51 | } rounded-full`} 52 | username={username} 53 | firstName={username} 54 | /> 55 | ) : ( 56 | <LoadImage 57 | src={url} 58 | alt={`${username}'s Avatar`} 59 | className={`${ 60 | isChatSection ? " w-8 h-8 " : " w-12 h-12 mr-4 rounded-full" 61 | } `} 62 | /> 63 | )} 64 | <div 65 | className={`${isChatSection ? " flex flex-wrap " : null} 66 | ${isUserSender ? "place-content-end" : null} 67 | w-full`} 68 | > 69 | {!isChatSection && ( 70 | <h2 className={`text-lg font-semibold`}>{username}</h2> 71 | )} 72 | <div 73 | className={` flex ${ 74 | isChatSection ? "flex-col" : " justify-between flex-row font-bold" 75 | } ${isUserSender ? " items-end " : null} `} 76 | > 77 | <p 78 | className={`${isChatSection && isUserSender ? "mx-2" : null} 79 | ${ 80 | isChatSection 81 | ? `${ 82 | isUserSender 83 | ? "bg-gradient-to-tr from-teal-300 to-teal-500 text-white rounded-bl-full" 84 | : "bg-gradient-to-b from-slate-100 to-slate-300 text-black rounded-br-full" 85 | } px-4 py-2 rounded-t-full w-auto` 86 | : "text-gray-500 " 87 | } 88 | break-all 89 | 90 | `} 91 | > 92 | {lastMessage} 93 | </p> 94 | <span className="mr-2 text-xs ">{getTimeAgoMessage(sentTime)}</span> 95 | </div> 96 | </div> 97 | </div> 98 | </> 99 | ); 100 | }; 101 | 102 | export default ChatAuthorProfile; 103 | -------------------------------------------------------------------------------- /src/components/Elements/ConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ExclamationIcon, 3 | InformationCircleIcon, 4 | } from "@heroicons/react/outline"; 5 | import * as React from "react"; 6 | 7 | import { Button } from "components/Elements/Button"; 8 | import { Dialog, DialogTitle } from "components/Elements/Dialog"; 9 | import { useDisclosure } from "hooks/useDisclosure"; 10 | 11 | export type ConfirmationDialogProps = { 12 | triggerButton: React.ReactElement; 13 | confirmButton: React.ReactElement; 14 | title: string; 15 | body?: string; 16 | cancelButtonText?: string; 17 | icon?: "danger" | "info"; 18 | isDone?: boolean; 19 | }; 20 | 21 | export const ConfirmationDialog = ({ 22 | triggerButton, 23 | confirmButton, 24 | title, 25 | body = "", 26 | cancelButtonText = "Cancel", 27 | icon = "danger", 28 | isDone = false, 29 | }: ConfirmationDialogProps) => { 30 | const { close, open, isOpen } = useDisclosure(); 31 | 32 | const cancelButtonRef = React.useRef(null); 33 | 34 | React.useEffect(() => { 35 | if (isDone) { 36 | close(); 37 | } 38 | }, [isDone, close]); 39 | 40 | const trigger = React.cloneElement(triggerButton, { 41 | onClick: open, 42 | }); 43 | 44 | return ( 45 | <> 46 | {trigger} 47 | <Dialog isOpen={isOpen} closeModal={close} initialFocus={cancelButtonRef}> 48 | <div className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> 49 | <div className="sm:flex sm:items-start"> 50 | {icon === "danger" && ( 51 | <div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-red-100 rounded-full sm:mx-0 sm:h-10 sm:w-10"> 52 | <ExclamationIcon 53 | className="w-6 h-6 text-red-600" 54 | aria-hidden="true" 55 | /> 56 | </div> 57 | )} 58 | {icon === "info" && ( 59 | <div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-red-100 rounded-full sm:mx-0 sm:h-10 sm:w-10"> 60 | <InformationCircleIcon 61 | className="w-6 h-6 text-blue-600" 62 | aria-hidden="true" 63 | /> 64 | </div> 65 | )} 66 | 67 | <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> 68 | <DialogTitle 69 | as="h3" 70 | className="text-lg font-medium leading-6 text-gray-900" 71 | > 72 | {title} 73 | </DialogTitle> 74 | </div> 75 | {body && ( 76 | <div className="mt-2"> 77 | <p className="text-sm text-gray-500">{body}</p> 78 | </div> 79 | )} 80 | </div> 81 | <div className="flex justify-end mt-4 space-x-2"> 82 | <Button 83 | type="button" 84 | variant="inverse" 85 | className="inline-flex justify-center w-full border rounded-md focuse:ring-1 focus:ring-offset-1 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm" 86 | onClick={close} 87 | ref={cancelButtonRef} 88 | > 89 | {cancelButtonText} 90 | </Button> 91 | {confirmButton} 92 | </div> 93 | </div> 94 | </Dialog> 95 | </> 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/features/comments/Components/CreateComment.tsx: -------------------------------------------------------------------------------- 1 | import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useEffect, useRef } from "react"; 5 | import { SubmitHandler, useForm } from "react-hook-form"; 6 | import { useTranslation } from "react-i18next"; 7 | import { Button } from "../../../components/Elements/Button"; 8 | import TextArea from "../../../components/Form/TextArea"; 9 | import { 10 | CommentValidationSchema, 11 | commentValidationSchema, 12 | } from "../../posts/Components/PostEngagements"; 13 | import { Comment } from "../../posts/types/postType"; 14 | import postComment from "../api/postComments"; 15 | 16 | type CreateCommentProps = { 17 | showComments: boolean; 18 | handleShowComments: () => void; 19 | handleRefetch: () => void; 20 | postId: string; 21 | comment?: Comment; 22 | closeModal?: () => void; 23 | }; 24 | const CreateComment = ({ 25 | showComments, 26 | handleShowComments, 27 | handleRefetch, 28 | postId, 29 | comment, 30 | closeModal, 31 | }: CreateCommentProps) => { 32 | const commentRef = useRef<HTMLTextAreaElement>(null); 33 | const { t } = useTranslation(); 34 | const { 35 | control, 36 | register, 37 | trigger, 38 | handleSubmit, 39 | setValue, 40 | formState: { errors, isValid }, 41 | } = useForm<CommentValidationSchema>({ 42 | resolver: zodResolver(commentValidationSchema), 43 | }); 44 | const handleInputChange = async (field: keyof CommentValidationSchema) => { 45 | !showComments && handleShowComments(); 46 | await trigger(field); 47 | }; 48 | const { mutate, error, isLoading } = postComment(postId, comment?._id); 49 | const onSubmit: SubmitHandler<CommentValidationSchema> = (data) => { 50 | mutate(data); 51 | setValue("content", ""); 52 | if (comment && closeModal) { 53 | closeModal(); 54 | } 55 | }; 56 | useEffect(() => { 57 | if (comment?.content) { 58 | setValue("content", comment.content); 59 | } 60 | }, []); 61 | return ( 62 | <form 63 | className={`${ 64 | comment ? "w-full" : "w-10/12" 65 | } h-16 transition-all duration-300 lg:w-11/12`} 66 | onSubmit={handleSubmit(onSubmit)} 67 | > 68 | <TextArea<CommentValidationSchema> 69 | ref={commentRef} 70 | name="content" 71 | control={control} 72 | errors={errors || error} 73 | label="" 74 | type="comment" 75 | placeholder={`${t("comments.createComment")} 💭`} 76 | defaultValue={comment ? comment.content : ""} 77 | onKeyDown={handleInputChange} 78 | className="border shadow-lg rounded-2xl" 79 | > 80 | <Button 81 | type="submit" 82 | size="xs" 83 | variant="inverse" 84 | isLoading={isLoading} 85 | className="absolute border-none rounded-full cursor-pointer top-2 h-11/12 right-2 bg-gradient-to-l from-white to-transparent " 86 | onClick={(e) => { 87 | handleShowComments(); 88 | handleRefetch(); 89 | }} 90 | > 91 | <FontAwesomeIcon 92 | icon={faPaperPlane} 93 | className="text-base text-slate-500" 94 | /> 95 | </Button> 96 | </TextArea> 97 | </form> 98 | ); 99 | }; 100 | 101 | export default CreateComment; 102 | -------------------------------------------------------------------------------- /src/features/user/Components/UserProfileImage.tsx: -------------------------------------------------------------------------------- 1 | import { faCamera } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { useState } from "react"; 4 | import LoadImage from "../../../components/Elements/LoadImage"; 5 | import { useDisclosure } from "../../../hooks/useDisclosure"; 6 | import UploadProfileImage from "./UploadProfileImage"; 7 | 8 | type UserProfileImage = { 9 | isUserAdmin: boolean; 10 | url: string | undefined; 11 | isCover: boolean; 12 | coverTitle?: string; 13 | }; 14 | const coverImageClassname = 15 | "relative object-cover w-full h-56 transition-all rounded-t-lg duration-800 md:h-48 lg:h-72 bg-theme-color bg-gradient-to-r from-teal-200 to-teal-500"; 16 | const profileImageClassname = 17 | "absolute bottom-0 w-40 h-40 mb-2 transition-all duration-300 transform -translate-x-1/2 translate-y-1/2 border-4 rounded-full cursor-pointer border-slate-200 lg:translate-y-3/4 lg:w-40 lg:h-40 lg:mb-16 left-1/2 "; 18 | 19 | const UserProfileImage = ({ 20 | isUserAdmin, 21 | url, 22 | isCover, 23 | coverTitle, 24 | }: UserProfileImage) => { 25 | const { 26 | isOpen: isHovered, 27 | open: openHover, 28 | close: closeHover, 29 | } = useDisclosure(false); 30 | const { 31 | isOpen: isImageUploadOpen, 32 | open: openImageUpload, 33 | close: closeImageUpload, 34 | } = useDisclosure(false); 35 | return ( 36 | <> 37 | <div 38 | className={isCover ? coverImageClassname : profileImageClassname} 39 | onMouseEnter={openHover} 40 | onMouseLeave={closeHover} 41 | onClick={() => (isUserAdmin ? openImageUpload() : closeImageUpload())} 42 | > 43 | {isCover && url?.includes("placeholder") ? ( 44 | <h3 className="absolute text-5xl text-white lowercase -translate-x-1/2 -translate-y-1/2 md:uppercase left-1/2 font-cursive top-1/2"> 45 | {isCover ? coverTitle ?? "" : ""} 46 | </h3> 47 | ) : ( 48 | <LoadImage 49 | src={url} 50 | alt="Avatar" 51 | className={ 52 | isCover 53 | ? "absolute object-cover w-full h-full -translate-x-1/2 -translate-y-1/2 cursor-pointer md:uppercase left-1/2 top-1/2" 54 | : "object-cover w-full h-full rounded-full" 55 | } 56 | /> 57 | )} 58 | {isHovered && isUserAdmin && ( 59 | <div 60 | className={ 61 | isCover 62 | ? "absolute w-full h-full cursor-pointer bg-gradient-to-t from-transparent to-gray-300" 63 | : "h-full -translate-y-full rounded-full cursor-pointer bg-gradient-to-b from-transparent to-slate-600" 64 | } 65 | > 66 | <FontAwesomeIcon 67 | icon={faCamera} 68 | className={`${ 69 | isCover 70 | ? " text-5xl text-slate-800 " 71 | : " text-2xl text-slate-100 " 72 | } absolute translate-x-1/2 translate-y-1/2 bottom-1/2 right-1/2 `} 73 | /> 74 | </div> 75 | )} 76 | </div> 77 | {isImageUploadOpen && ( 78 | <UploadProfileImage 79 | isImageUploadOpen={isImageUploadOpen} 80 | closeModal={closeImageUpload} 81 | title={ 82 | isCover 83 | ? "Upload your cover image 📷 " 84 | : "Upload your profile picture 📷" 85 | } 86 | coverImageExist={isCover} 87 | /> 88 | )} 89 | </> 90 | ); 91 | }; 92 | 93 | export default UserProfileImage; 94 | -------------------------------------------------------------------------------- /src/features/auth/Components/Login.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { useEffect } from "react"; 3 | import { SubmitHandler, useForm } from "react-hook-form"; 4 | import { useTranslation } from "react-i18next"; 5 | import { useSelector } from "react-redux"; 6 | import { useNavigate } from "react-router-dom"; 7 | import { RootState } from "stores/store"; 8 | import { Button } from "../../../components/Elements/Button"; 9 | import InputField from "../../../components/Form/InputField"; 10 | import useAuthCheck from "../../../hooks/useAuthCheck"; 11 | import useLoginUser from "../api/loginUser"; 12 | import { 13 | LoginValidationSchema, 14 | loginValidationSchema, 15 | } from "../utils/loginValidation"; 16 | import AuthFormEnhancements from "./AuthFormEnhancements"; 17 | 18 | const Login = () => { 19 | const navigate = useNavigate(); 20 | const isLoggedIn = useAuthCheck(); 21 | const { t } = useTranslation(); 22 | const isInitialLogin = useSelector<RootState, boolean>( 23 | (store) => store.user.isInitialLogin 24 | ); 25 | useEffect(() => { 26 | if (isLoggedIn) { 27 | if (isInitialLogin) { 28 | navigate("/onboarding"); 29 | } else { 30 | navigate("/home"); 31 | } 32 | } 33 | }, [isLoggedIn]); 34 | const { 35 | control, 36 | trigger, 37 | handleSubmit, 38 | formState: { errors, isValid }, 39 | } = useForm<LoginValidationSchema>({ 40 | resolver: zodResolver(loginValidationSchema), 41 | }); 42 | const handleInputChange = async (field: keyof LoginValidationSchema) => { 43 | await trigger(field); 44 | }; 45 | 46 | const { mutate, error, isLoading } = useLoginUser(); 47 | const onSubmit: SubmitHandler<LoginValidationSchema> = (data) => { 48 | mutate(data); 49 | }; 50 | 51 | return ( 52 | <div className="w-full max-w-xl mx-auto"> 53 | <div className="flex justify-center my-12"> 54 | <div className="w-full p-5 bg-white rounded-lg shadow-xl lg:w-11/12"> 55 | <h3 className="pt-4 text-2xl font-bold text-center "> 56 | {t("authPage.welcome")} 57 | </h3> 58 | <form 59 | className="px-8 pt-6 pb-8 mb-4" 60 | onSubmit={handleSubmit(onSubmit)} 61 | > 62 | <InputField<LoginValidationSchema> 63 | name="email" 64 | control={control} 65 | errors={errors || error} 66 | label={t("authPage.email")} 67 | type="text" 68 | onKeyDown={handleInputChange} 69 | className="w-full" 70 | /> 71 | <InputField<LoginValidationSchema> 72 | name="password" 73 | control={control} 74 | errors={errors || error} 75 | label={t("authPage.password")} 76 | type="password" 77 | onKeyDown={handleInputChange} 78 | /> 79 | <div className="mb-6 text-center"> 80 | <Button 81 | className="w-full px-4 py-2 font-bold rounded-full text-slate-700 hover:animate-pulse bg-gradient-to-r from-teal-200 to-teal-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" 82 | type="submit" 83 | isLoading={isLoading} 84 | > 85 | {t("landingPage.login")} 86 | </Button> 87 | </div> 88 | <hr className="mb-6 border-t" /> 89 | <AuthFormEnhancements formType="signup" /> 90 | </form> 91 | </div> 92 | </div> 93 | </div> 94 | ); 95 | }; 96 | 97 | export default Login; 98 | -------------------------------------------------------------------------------- /src/components/Form/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, forwardRef } from "react"; 2 | import { Controller } from "react-hook-form"; 3 | export const inputFieldStyle = ` 4 | w-full px-3 py-2 text-sm leading-tight transition-all duration-300 hover:border-teal-200 outline-none text-gray-700 border 5 | `; 6 | 7 | interface TextAreaProps<SchemaType> { 8 | name: string & keyof SchemaType; 9 | control: any; 10 | errors: any; 11 | label: string; 12 | placeholder?: string; 13 | disabled?: boolean; 14 | onChange?: (e: any) => void; 15 | onKeyUp?: (e: any) => void; 16 | type: string; 17 | onKeyDown: (field: keyof SchemaType) => void; 18 | className?: string; 19 | children?: React.ReactNode; 20 | defaultValue: string; 21 | isNotIncreasePostHeight?: boolean; 22 | } 23 | function calcHeight(value: string) { 24 | let numberOfLineBreaks = (value.match(/\n/g) || []).length; 25 | if (numberOfLineBreaks > 7) { 26 | return 200; 27 | } 28 | let newHeight = 27 + numberOfLineBreaks * 20 + 12 + 2; 29 | return newHeight; 30 | } 31 | const TextArea = forwardRef( 32 | <SchemaType extends Record<string, any>>( 33 | { 34 | name, 35 | control, 36 | errors, 37 | label = "", 38 | type = "text", 39 | placeholder, 40 | className, 41 | onKeyDown, 42 | children, 43 | onChange, 44 | onKeyUp, 45 | disabled, 46 | defaultValue, 47 | isNotIncreasePostHeight, 48 | }: TextAreaProps<SchemaType>, 49 | ref: ForwardedRef<HTMLTextAreaElement> 50 | ) => { 51 | return ( 52 | <div 53 | className={`z-50 mb-4 transition-all duration-300 md:mr-2 ${ 54 | isNotIncreasePostHeight ? "w-full" : "w-11/12" 55 | }`} 56 | > 57 | <label 58 | className="block mb-2 text-sm font-bold text-gray-700" 59 | htmlFor={name} 60 | > 61 | {label} 62 | </label> 63 | <div className={`relative flex items-center justify-center `}> 64 | <Controller 65 | name={name} 66 | control={control} 67 | render={({ field }) => ( 68 | <textarea 69 | {...field} 70 | id={name} 71 | ref={ref} 72 | placeholder={placeholder ? placeholder : label} 73 | defaultValue={defaultValue} 74 | className={`${inputFieldStyle} resize-none h-10 overflow-auto pr-10 ${ 75 | errors[name] && "border-red-500 hover:border-red-500 " 76 | } ${className}`} 77 | onKeyDown={() => onKeyDown(name)} 78 | onKeyUp={ 79 | !isNotIncreasePostHeight 80 | ? () => { 81 | if (ref && "current" in ref && ref?.current) { 82 | ref.current.style.height = 83 | calcHeight(ref?.current.value) + "px"; 84 | } 85 | } 86 | : () => {} 87 | } 88 | ></textarea> 89 | )} 90 | /> 91 | {children} 92 | </div> 93 | {errors[name] && ( 94 | <p className="mt-2 text-xs italic text-red-500"> 95 | {errors[name]?.message} 96 | </p> 97 | )} 98 | </div> 99 | ); 100 | } 101 | ); 102 | 103 | export default TextArea as <SchemaType>( 104 | props: TextAreaProps<SchemaType> | { ref: ForwardedRef<HTMLTextAreaElement> } 105 | ) => JSX.Element; 106 | -------------------------------------------------------------------------------- /src/features/user/Components/UserProfileAbout.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useParams } from "react-router-dom"; 3 | import { useDisclosure } from "../../../hooks/useDisclosure"; 4 | import { formatDateStringToBirthday } from "../../../utils/helpers"; 5 | import getUserByUsername from "../api/getUserByUsername"; 6 | import UserList from "./UserList"; 7 | 8 | const UserProfileAbout = () => { 9 | const { username } = useParams(); 10 | const { error, data, isLoading } = getUserByUsername(username); 11 | const { 12 | isOpen: isFollowersOpen, 13 | open: openFollowersModal, 14 | close: closeFollowersModal, 15 | } = useDisclosure(false); 16 | const { 17 | isOpen: isFollowingOpen, 18 | open: openFollowingModal, 19 | close: closeFollowingModal, 20 | } = useDisclosure(false); 21 | const { t } = useTranslation(); 22 | const user = data?.data; 23 | return ( 24 | <div className="flex flex-col lg:w-3/4 "> 25 | <div className="py-3 md:px-9 "> 26 | <div className="flex justify-between mt-4 mb-5"> 27 | <div 28 | onClick={() => 29 | user && user?.followersCount > 0 && openFollowersModal() 30 | } 31 | className={ 32 | user && user?.followersCount > 0 33 | ? "cursor-pointer" 34 | : "cursor-default" 35 | } 36 | > 37 | <p className="text-lg font-semibold">{user?.followersCount}</p> 38 | <p className="text-gray-500">{t("userPages.followers")} 🚀</p> 39 | </div> 40 | <div 41 | onClick={() => 42 | user && user?.followingCount > 0 && openFollowingModal() 43 | } 44 | className={ 45 | user && user?.followingCount > 0 46 | ? "cursor-pointer" 47 | : "cursor-default" 48 | } 49 | > 50 | <p className="text-lg font-semibold">{user?.followingCount}</p> 51 | <p className="text-gray-500">{t("userPages.following")} 👥</p> 52 | </div> 53 | </div> 54 | <div className="flex flex-col flex-wrap gap-10 fmt-4"> 55 | <p> 56 | <strong>Username:</strong> {user?.account?.username} 📛 57 | </p> 58 | <p> 59 | <strong>{t("userPages.dob")}:</strong>{" "} 60 | {(user?.dob && formatDateStringToBirthday(user?.dob)) || 61 | "Not provided"}{" "} 62 | 🎂 63 | </p> 64 | <p> 65 | <strong>{t("userPages.location")}:</strong>{" "} 66 | {user?.location || t("userPages.notProvided")} 🌍 67 | </p> 68 | <p> 69 | <strong>{t("authPage.email")}:</strong>{" "} 70 | <a href={`mailto:${user?.account?.email}`}> 71 | {user?.account?.email}{" "} 72 | </a> 73 | 📧 74 | </p> 75 | <p> 76 | <strong>{t("userPages.phoneNumber")}:</strong>{" "} 77 | {user?.phoneNumber || t("userPages.notProvided")} 📞 78 | </p> 79 | </div> 80 | </div> 81 | {isFollowersOpen && ( 82 | <UserList 83 | isOpen={isFollowersOpen} 84 | closeModal={closeFollowersModal} 85 | followers={true} 86 | /> 87 | )} 88 | 89 | {isFollowingOpen && ( 90 | <UserList 91 | isOpen={isFollowingOpen} 92 | closeModal={closeFollowingModal} 93 | followers={false} 94 | /> 95 | )} 96 | </div> 97 | ); 98 | }; 99 | 100 | export default UserProfileAbout; 101 | -------------------------------------------------------------------------------- /src/features/posts/Components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { useParams } from "react-router-dom"; 3 | import { RootState } from "stores/store"; 4 | import Shimmer from "../../../components/Shimmer/Shimmer"; 5 | import ShimmerPosts from "../../../components/Shimmer/ShimmerPosts"; 6 | import useInfiniteScroll from "../../../hooks/useInfiniteScroll"; 7 | import usePreviousPage from "../../user/hooks/usePreviousPage"; 8 | import getPosts from "../api/getPosts"; 9 | import { PostRefetchContext } from "../context/PostContext"; 10 | import { Author } from "../types/postType"; 11 | import CreatePostDisplay from "./CreatePostDisplay"; 12 | import PostCard from "./Post"; 13 | 14 | type PostsProps = { 15 | tag?: string; 16 | bookmarks?: boolean; 17 | }; 18 | const InfiniteShimmerPosts = () => ( 19 | <div className="flex flex-col items-center justify-center gap-3 mt-10 overflow-hidden "> 20 | {new Array(6).fill(1).map((value, index) => ( 21 | <div 22 | key={value + index} 23 | className="w-11/12 p-4 bg-white rounded-lg shadow-2xl drop- md:w-3/5 lg:w-1/2 gap-y-10" 24 | > 25 | <Shimmer /> 26 | </div> 27 | ))} 28 | </div> 29 | ); 30 | 31 | const Posts = ({ tag, bookmarks }: PostsProps) => { 32 | const { username } = useParams(); 33 | const { isLoading, error, data, fetchNextPage, isFetchingNextPage, refetch } = 34 | getPosts(username, tag, bookmarks); 35 | const user = useSelector<RootState, Author | undefined>( 36 | (store) => store.user.user 37 | ); 38 | usePreviousPage(); 39 | useInfiniteScroll(fetchNextPage); 40 | if (isLoading) { 41 | return <ShimmerPosts />; 42 | } 43 | 44 | if ( 45 | data?.pages[0]?.data?.posts?.length === 0 || 46 | data?.pages[0]?.data?.bookmarkedPosts?.length === 0 47 | ) { 48 | return ( 49 | <div className="flex flex-col items-center justify-center "> 50 | <div className="flex flex-col items-center justify-center w-full gap-4 gap-y-6 lg:gap-y-10"> 51 | {bookmarks ? ( 52 | <h1> 53 | No bookmarked posts😞. 54 | <br /> Bookmark your first Post📑. 55 | </h1> 56 | ) : ( 57 | <> 58 | <CreatePostDisplay /> 59 | <h1> 60 | {username === user?.account.username 61 | ? "👋 Create your first Post!" 62 | : "No Posts Available. 😞"} 63 | </h1> 64 | </> 65 | )} 66 | </div> 67 | </div> 68 | ); 69 | } 70 | 71 | return ( 72 | <> 73 | <div className="flex flex-col items-center justify-center "> 74 | <div className="flex flex-col items-center justify-center w-full gap-4 gap-y-6 lg:gap-y-10"> 75 | {(!username || username === user?.account.username) && 76 | !tag && 77 | !bookmarks && <CreatePostDisplay />} 78 | {data?.pages.map((page) => { 79 | const postsData = bookmarks 80 | ? page?.data?.bookmarkedPosts 81 | : page?.data?.posts; 82 | return postsData?.map((post) => ( 83 | <PostRefetchContext.Provider 84 | value={{ 85 | refetch, 86 | page: page.data.page, 87 | postId: post._id, 88 | }} 89 | key={post._id} 90 | > 91 | <PostCard post={post} /> 92 | </PostRefetchContext.Provider> 93 | )); 94 | })} 95 | </div> 96 | </div> 97 | {isFetchingNextPage ? <InfiniteShimmerPosts /> : null} 98 | </> 99 | ); 100 | }; 101 | 102 | export default Posts; 103 | --------------------------------------------------------------------------------