--------------------------------------------------------------------------------
/frontend/src/components/shared/Preloader.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingOutlined } from '@ant-design/icons';
2 | import React from 'react';
3 | import logo from '~/images/logo.svg';
4 |
5 | const Preloader = () => (
6 |
7 |

8 |
Nothing brings people together like good food.
9 |
10 |
11 | );
12 |
13 | export default Preloader;
14 |
--------------------------------------------------------------------------------
/server/src/schemas/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Bookmark } from './BookmarkSchema';
2 | export { default as Chat } from './ChatSchema';
3 | export { default as Comment } from './CommentSchema';
4 | export { default as Follow } from './FollowSchema';
5 | export { default as Like } from './LikeSchema';
6 | export { default as Message } from './MessageSchema';
7 | export { default as NewsFeed } from './NewsFeedSchema';
8 | export { default as Notification } from './NotificationSchema';
9 | export { default as Post } from './PostSchema';
10 | export { default as User } from './UserSchema';
11 |
12 |
--------------------------------------------------------------------------------
/frontend/src/components/hoc/withAuth.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux"
2 | import { IRootReducer } from "~/types/types"
3 |
4 | interface IInjectedProps {
5 | isAuth: boolean;
6 | }
7 |
8 | const withAuth = (Component: React.ComponentType
) => {
9 | return (props: Pick
>) => {
10 | const isAuth = useSelector((state: IRootReducer) => !!state.auth.id && !!state.auth.username);
11 |
12 | return
13 | }
14 | };
15 |
16 | export default withAuth;
17 |
18 |
--------------------------------------------------------------------------------
/frontend/src/components/hoc/withTheme.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { IRootReducer } from "~/types/types";
3 |
4 | interface IInjectedProps {
5 | theme: string;
6 | [prop: string]: any;
7 | }
8 |
9 | const withTheme =
(Component: React.ComponentType
) => {
10 | return (props: Pick
>) => {
11 | const theme = useSelector((state: IRootReducer) => state.settings.theme);
12 |
13 | return
14 | }
15 | };
16 |
17 | export default withTheme;
18 |
19 |
--------------------------------------------------------------------------------
/frontend/src/styles/components/grid.css:
--------------------------------------------------------------------------------
1 | .grid-img {
2 | width: 100%;
3 | height: 100%;
4 | object-fit: cover;
5 |
6 | &:hover {
7 | cursor: pointer;
8 | }
9 | }
10 |
11 | .custom-grid {
12 | display: grid;
13 | width: 100%;
14 | height: 100%;
15 | grid-gap: 2px;
16 | overflow:hidden;
17 | }
18 |
19 | .custom-grid-cols-2 {
20 | grid-template-columns: repeat(2, 1fr);
21 | }
22 | .custom-grid-cols-3 {
23 | grid-template-columns: repeat(3, 1fr);
24 | }
25 |
26 | .custom-grid-rows-2 {
27 | grid-template-columns: 1fr;
28 | grid-template-rows: repeat(2, 50%);
29 | }
--------------------------------------------------------------------------------
/server/src/utils/storage.utils.ts:
--------------------------------------------------------------------------------
1 | const { Storage } = require('@google-cloud/storage');
2 | const Multer = require('multer');
3 | const config = require('../config/config');
4 |
5 | export default class CloudStorage {
6 | public bucket;
7 |
8 | public initialize() {
9 | const storage = new Storage(config.gCloudStorage);
10 |
11 | this.bucket = storage.bucket(process.env.FIREBASE_STORAGE_BUCKET_URL);
12 | }
13 | }
14 |
15 | export const multer = Multer({
16 | storage: Multer.memoryStorage(),
17 | limits: {
18 | fileSize: 2 * 1024 * 1024 // no larger than 2mb
19 | }
20 | });
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/routes/createRouter.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config/config';
2 | import { Router } from 'express';
3 | import glob from 'glob';
4 |
5 | const routers = glob
6 | .sync(`**/*.${config.server.env === 'dev' ? 'ts' : 'js'}`, { cwd: `${__dirname}/` })
7 | .map((filename: string) => require(`./${filename}`))
8 | .filter((router: any) => {
9 | return router.default && Object.getPrototypeOf(router.default) === Router
10 | })
11 | .reduce((rootRouter: Router, router: any) => {
12 | return rootRouter.use(router.default)
13 | }, Router({ mergeParams: true }));
14 |
15 | export default routers;
--------------------------------------------------------------------------------
/frontend/src/components/main/index.ts:
--------------------------------------------------------------------------------
1 | export { default as BookmarkButton } from './BookmarkButton';
2 | export * from './Chats';
3 | export { default as Comments } from './Comments';
4 | export { default as FollowButton } from './FollowButton';
5 | export { default as LikeButton } from './LikeButton';
6 | export { default as Messages } from './Messages';
7 | export * from './Modals';
8 | export { default as Notification } from './Notification';
9 | export * from './Options';
10 | export { default as PostItem } from './PostItem';
11 | export { default as SuggestedPeople } from './SuggestedPeople';
12 | export { default as UserCard } from './UserCard';
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Badge.tsx:
--------------------------------------------------------------------------------
1 | interface IProps {
2 | children?: React.ReactNode,
3 | count: number;
4 | }
5 |
6 | const Badge: React.FC = ({ children, count = 0 }) => {
7 | return (
8 |
9 | {count > 0 && (
10 |
11 | {count}
12 |
13 | )}
14 | {children && children}
15 |
16 | );
17 | };
18 |
19 | export default Badge;
20 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Avatar } from './Avatar';
2 | export { default as Badge } from './Badge';
3 | export { default as Boundary } from './Boundary';
4 | export { default as ImageGrid } from './ImageGrid';
5 | export { default as Loader } from './Loader';
6 | export * from './Loaders';
7 | export { default as NavBar } from './NavBar';
8 | export { default as NavBarMobile } from './NavBarMobile';
9 | export { default as Preloader } from './Preloader';
10 | export { default as SearchInput } from './SearchInput';
11 | export { default as SocialLogin } from './SocialLogin';
12 | export { default as ThemeToggler } from './ThemeToggler';
13 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/settingsReducer.ts:
--------------------------------------------------------------------------------
1 | import { SET_THEME } from "~/constants/actionType";
2 | import { ISettingsState } from "~/types/types";
3 | import { TSettingsActionType } from "../action/settingsActions";
4 |
5 | const initState: ISettingsState = {
6 | theme: 'light',
7 | // ... more settings
8 | }
9 |
10 | const settingsReducer = (state = initState, action: TSettingsActionType) => {
11 | switch (action.type) {
12 | case SET_THEME:
13 | return {
14 | ...state,
15 | theme: action.payload
16 | }
17 | default:
18 | return state;
19 | }
20 | }
21 |
22 | export default settingsReducer;
23 |
--------------------------------------------------------------------------------
/server/src/schemas/FollowSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IUser } from "./UserSchema";
3 |
4 | export interface IFollow extends Document {
5 | user: IUser['_id'];
6 | target: IUser['_id'];
7 | }
8 |
9 | const FollowSchema = new Schema({
10 | user: {
11 | type: Schema.Types.ObjectId,
12 | ref: 'User',
13 | required: true
14 | },
15 | target: {
16 | type: Schema.Types.ObjectId,
17 | ref: 'User',
18 | default: []
19 | },
20 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } });
21 |
22 | export default model('Follow', FollowSchema);
23 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Modals/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ComposeMessageModal } from './ComposeMessageModal';
2 | export { default as CreatePostModal } from './CreatePostModal';
3 | export { default as CropProfileModal } from './CropProfileModal';
4 | export { default as DeleteCommentModal } from './DeleteCommentModal';
5 | export { default as DeletePostModal } from './DeletePostModal';
6 | export { default as EditPostModal } from './EditPostModal';
7 | export { default as ImageLightbox } from './ImageLightbox';
8 | export { default as LogoutModal } from './LogoutModal';
9 | export { default as PostLikesModal } from './PostLikesModal';
10 | export { default as PostModals } from './PostModals';
11 |
12 |
--------------------------------------------------------------------------------
/frontend/src/constants/routes.ts:
--------------------------------------------------------------------------------
1 | export const LOGIN = '/login';
2 | export const REGISTER = '/register';
3 | export const HOME = '/';
4 | export const POST = '/post/:post_id';
5 | export const PROFILE = '/user/:username';
6 | export const PROFILE_INFO = '/user/:username/info';
7 | export const PROFILE_EDIT_INFO = '/user/:username/edit';
8 | export const PROFILE_FOLLOWERS = '/user/:username/followers';
9 | export const PROFILE_FOLLOWING = '/user/:username/following';
10 | export const PROFILE_BOOKMARKS = '/user/:username/bookmarks';
11 | export const SEARCH = '/search';
12 | export const CHAT = '/chat/:username';
13 | export const SUGGESTED_PEOPLE = '/suggested';
14 |
15 | export const SOCIAL_AUTH_FAILED = '/auth/:provider/failed';
--------------------------------------------------------------------------------
/server/src/schemas/ChatSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IMessage } from "./MessageSchema";
3 | import { IUser } from "./UserSchema";
4 |
5 | export interface IChat extends Document {
6 | participants: Array;
7 | lastmessage: IMessage['_id'];
8 | }
9 |
10 | const ChatSchema = new Schema({
11 | participants: [{
12 | type: Schema.Types.ObjectId,
13 | ref: 'User'
14 | }],
15 | lastmessage: {
16 | type: Schema.Types.ObjectId,
17 | ref: 'Message'
18 | }
19 |
20 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } });
21 |
22 | export default model('Chat', ChatSchema);
23 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.paths.json",
3 | "compilerOptions": {
4 | "target": "es5",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "baseUrl": "./",
11 | "allowJs": true,
12 | "skipLibCheck": true,
13 | "esModuleInterop": true,
14 | "allowSyntheticDefaultImports": true,
15 | "strict": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "resolveJsonModule": true,
21 | "isolatedModules": true,
22 | "noEmit": true,
23 | "jsx": "react-jsx"
24 | },
25 | "include": [
26 | "src"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/routers/PublicRoute.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect, Route } from "react-router-dom";
2 | import withAuth from "~/components/hoc/withAuth";
3 | import { HOME } from "~/constants/routes";
4 |
5 | interface IProps {
6 | component: React.ComponentType;
7 | path: string;
8 | isAuth: boolean;
9 | [propName: string]: any;
10 | }
11 |
12 | const PublicRoute: React.FC = ({ isAuth, component: Component, path, ...rest }) => {
13 | return (
14 | {
17 | return isAuth ? :
18 | }}
19 | />
20 | );
21 | };
22 |
23 | export default withAuth(PublicRoute);
24 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Boundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 |
3 | class Boundary extends Component {
4 | static getDerivedStateFromError(error: any) {
5 | return { hasError: true };
6 | }
7 |
8 | state = {
9 | hasError: false
10 | };
11 |
12 |
13 | componentDidCatch(error: any, errorInfo: any) {
14 | console.log(error);
15 | }
16 |
17 | render() {
18 | if (this.state.hasError) {
19 | return (
20 |
21 |
:( Something went wrong.
22 |
23 | );
24 | }
25 |
26 | return this.props.children;
27 | }
28 | }
29 |
30 | export default Boundary;
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/routers/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect, Route } from "react-router-dom";
2 | import withAuth from "~/components/hoc/withAuth";
3 | import { LOGIN } from "~/constants/routes";
4 |
5 | interface IProps {
6 | component: React.ComponentType;
7 | path: string;
8 | isAuth: boolean;
9 | [propName: string]: any;
10 | }
11 |
12 | const ProtectedRoute: React.FC = ({ isAuth, component: Component, path, ...rest }) => {
13 | return (
14 | {
17 | return isAuth ? :
18 | }}
19 | />
20 | );
21 | }
22 |
23 | export default withAuth(ProtectedRoute);
24 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Chats/Chats.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { IRootReducer } from "~/types/types";
3 | import ChatBox from "./ChatBox";
4 | import MinimizedChats from "./MinimizedChats";
5 |
6 | const Chats = () => {
7 | const { chats, user } = useSelector((state: IRootReducer) => ({
8 | chats: state.chats,
9 | user: state.auth
10 | }));
11 |
12 | return (
13 |
14 | {chats.items.map(chat => chats.active === chat.id && (
15 |
16 | ))}
17 |
18 |
19 | )
20 | };
21 |
22 | export default Chats;
23 |
--------------------------------------------------------------------------------
/frontend/src/styles/elements/button.css:
--------------------------------------------------------------------------------
1 | .button {
2 | @extend button;
3 | }
4 |
5 | .button--stretch {
6 | @extend button;
7 | width: 100%;
8 | }
9 | .button--muted {
10 | @extend button;
11 | @apply bg-gray-100;
12 | @apply text-gray-600;
13 | @apply rounded-md;
14 |
15 | &:hover {
16 | @apply bg-gray-300;
17 | @apply text-gray-700;
18 | }
19 |
20 | &:active {
21 | @apply bg-gray-300;
22 | }
23 |
24 | &:focus {
25 | @apply bg-gray-100;
26 | }
27 | }
28 |
29 | .button--danger {
30 | @extend button;
31 | @apply bg-red-500;
32 |
33 | &:hover {
34 | @apply bg-red-700;
35 | }
36 |
37 | &:active,
38 | &:focus {
39 | @apply bg-red-700;
40 | }
41 | }
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import 'react-image-lightbox/style.css';
4 | import { Provider } from 'react-redux';
5 | import store from '~/redux/store/store';
6 | import '~/styles/app.css';
7 | import App from './App';
8 | import reportWebVitals from './reportWebVitals';
9 |
10 | ReactDOM.render(
11 |
12 |
13 |
14 |
15 | ,
16 | document.getElementById('root')
17 | );
18 |
19 | // If you want to start measuring performance in your app, pass a function
20 | // to log results (for example: reportWebVitals(console.log))
21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
22 | reportWebVitals();
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "foodie",
3 | "version": "1.0.0",
4 | "description": "A social media website for food lovers and cooks.",
5 | "main": "index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": ""
9 | },
10 | "engines": {
11 | "node": "12.18.1"
12 | },
13 | "scripts": {
14 | "start-client": "cd frontend && npm start",
15 | "start-server": "cd server && npm start",
16 | "start": "concurrently \"npm run start-server\" \"npm run start-client\"",
17 | "init-project": "concurrently \"cd frontend && npm install\" \"cd server && npm install\""
18 | },
19 | "keywords": [],
20 | "author": "Julius Guevarra ",
21 | "license": "MIT",
22 | "devDependencies": {
23 | "concurrently": "^5.3.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server/src/db/db.ts:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | import config from '@/config/config';
3 | const mongoUri = config.mongodb.uri || 'mongodb://localhost:27017';
4 | const dbName = config.mongodb.dbName || 'foodie';
5 |
6 | if (config.server.env === 'dev') {
7 | mongoose.set("debug", true);
8 | }
9 |
10 | const options = {
11 | useNewUrlParser: true,
12 | useUnifiedTopology: true,
13 | useCreateIndex: true,
14 | useFindAndModify: false,
15 | serverSelectionTimeoutMS: 5000,
16 | dbName
17 | };
18 |
19 | export default async function () {
20 | try {
21 | await mongoose.connect(mongoUri, options);
22 | console.log(`MongoDB connected as ${mongoUri}`);
23 | } catch (e) {
24 | console.log('Error connecting to mongoose: ', e);
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/server/src/schemas/LikeSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IPost } from "./PostSchema";
3 | import { IUser } from "./UserSchema";
4 |
5 | export interface ILike extends Document {
6 | target: IPost['_id'];
7 | user: IUser['_id'];
8 | }
9 |
10 | const LikeSchema = new Schema({
11 | type: {
12 | type: String,
13 | required: true,
14 | enum: ['Post', 'Comment']
15 | },
16 | target: {
17 | type: Schema.Types.ObjectId,
18 | refPath: 'type',
19 | required: true
20 | },
21 | user: {
22 | type: Schema.Types.ObjectId,
23 | ref: 'User',
24 | required: true
25 | }
26 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } });
27 |
28 | export default model('Like', LikeSchema);
29 |
--------------------------------------------------------------------------------
/server/src/schemas/NewsFeedSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IPost } from "./PostSchema";
3 | import { IUser } from "./UserSchema";
4 |
5 | export interface INewsFeed extends Document {
6 | follower: IUser['_id'];
7 | post: IPost['_id'];
8 | post_owner: IUser['_id'];
9 | }
10 |
11 | const NewsFeedSchema = new Schema({
12 | follower: {
13 | type: Schema.Types.ObjectId,
14 | required: true,
15 | ref: 'User'
16 | },
17 | post: {
18 | type: Schema.Types.ObjectId,
19 | required: true,
20 | ref: 'Post'
21 | },
22 | post_owner: {
23 | type: Schema.Types.ObjectId,
24 | required: true,
25 | ref: 'User'
26 | },
27 | createdAt: Date
28 | });
29 |
30 | export default model('NewsFeed', NewsFeedSchema);
31 |
--------------------------------------------------------------------------------
/frontend/src/pages/search/Users.tsx:
--------------------------------------------------------------------------------
1 | import UserCard from "~/components/main/UserCard";
2 | import useDocumentTitle from "~/hooks/useDocumentTitle";
3 | import { IProfile } from "~/types/types";
4 |
5 | interface IProps {
6 | users: IProfile[];
7 | }
8 |
9 | const Users: React.FC = ({ users }) => {
10 | useDocumentTitle(`Search Users | Foodie`);
11 |
12 | return (
13 |
14 | {users.map((user) => (
15 |
19 |
20 |
21 | ))}
22 |
23 | );
24 | };
25 |
26 | export default Users;
27 |
--------------------------------------------------------------------------------
/frontend/src/redux/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { takeLatest } from 'redux-saga/effects';
2 | import {
3 | CHECK_SESSION,
4 | CREATE_POST_START,
5 | GET_FEED_START,
6 | GET_USER_START,
7 | LOGIN_START,
8 | LOGOUT_START,
9 | REGISTER_START
10 | } from '~/constants/actionType';
11 | import authSaga from './authSaga';
12 | import newsFeedSaga from './newsFeedSaga';
13 | import profileSaga from './profileSaga';
14 |
15 | function* rootSaga() {
16 | yield takeLatest([
17 | LOGIN_START,
18 | LOGOUT_START,
19 | REGISTER_START,
20 | CHECK_SESSION,
21 | ], authSaga);
22 |
23 | yield takeLatest([
24 | GET_FEED_START,
25 | CREATE_POST_START
26 | ], newsFeedSaga);
27 |
28 | yield takeLatest([
29 | GET_USER_START
30 | ], profileSaga)
31 | }
32 |
33 | export default rootSaga;
--------------------------------------------------------------------------------
/frontend/src/pages/error/PageNotFound.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { useDocumentTitle } from '~/hooks';
4 |
5 | const PageNotFound: React.FC = () => {
6 | useDocumentTitle('Page Not Found');
7 |
8 | return (
9 |
10 |
Uh oh, you seemed lost.
11 |
The page you're trying to visit doesn't exist.
12 |
13 |
17 | Go to News Feed
18 |
19 |
20 | );
21 | };
22 |
23 | export default PageNotFound;
24 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/helperReducer.ts:
--------------------------------------------------------------------------------
1 | import { SET_TARGET_COMMENT, SET_TARGET_POST } from "~/constants/actionType";
2 | import { IHelperState } from "~/types/types";
3 | import { helperActionType } from "../action/helperActions";
4 |
5 | const initState: IHelperState = {
6 | targetComment: null,
7 | targetPost: null
8 | }
9 |
10 | const helperReducer = (state = initState, action: helperActionType) => {
11 | switch (action.type) {
12 | case SET_TARGET_COMMENT:
13 | return {
14 | ...state,
15 | targetComment: action.payload
16 | }
17 | case SET_TARGET_POST:
18 | return {
19 | ...state,
20 | targetPost: action.payload
21 | }
22 | default:
23 | return state;
24 | }
25 | }
26 |
27 | export default helperReducer;
--------------------------------------------------------------------------------
/frontend/src/pages/redirects/SocialAuthFailed.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { useDocumentTitle } from "~/hooks";
3 |
4 | const SocialAuthFailed = () => {
5 | useDocumentTitle('Authentication Failed');
6 |
7 | return (
8 |
9 |
Failed to authenticate
10 |
11 |
Possible cause(s):
12 |
13 | - Same email/username has been already linked to other social login eg: Google
14 |
15 |
16 |
Back to Login
17 |
18 | );
19 | };
20 |
21 | export default SocialAuthFailed;
22 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/rootReducer.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import authReducer from './authReducer';
3 | import chatReducer from './chatReducer';
4 | import errorReducer from './errorReducer';
5 | import helperReducer from './helperReducer';
6 | import loadingReducer from './loadingReducer';
7 | import modalReducer from './modalReducer';
8 | import newsFeedReducer from './newsFeedReducer';
9 | import profileReducer from './profileReducer';
10 | import settingsReducer from './settingsReducer';
11 |
12 | const rootReducer = combineReducers({
13 | auth: authReducer,
14 | error: errorReducer,
15 | loading: loadingReducer,
16 | newsFeed: newsFeedReducer,
17 | profile: profileReducer,
18 | chats: chatReducer,
19 | helper: helperReducer,
20 | modal: modalReducer,
21 | settings: settingsReducer,
22 | });
23 |
24 | export default rootReducer;
25 |
--------------------------------------------------------------------------------
/frontend/src/pages/chat/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { RouteComponentProps } from "react-router-dom";
3 | import { ChatBox } from "~/components/main";
4 | import { PageNotFound } from "~/pages";
5 | import { IRootReducer } from "~/types/types";
6 |
7 | const Chat: React.FC> = ({ match }) => {
8 | const { username } = match.params;
9 | const { target, user } = useSelector((state: IRootReducer) => ({
10 | target: state.chats.items.find(chat => chat.username === username),
11 | user: state.auth
12 | }));
13 |
14 | return !target ? : (
15 |
16 |
20 |
21 | );
22 | };
23 |
24 | export default Chat;
25 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import placeholder from '~/images/avatar_placeholder.png';
2 |
3 | interface IProps {
4 | url?: string;
5 | size?: string;
6 | className?: string;
7 | }
8 |
9 | const Avatar: React.FC = ({ url, size, className }) => {
10 | return (
11 |
22 | )
23 | };
24 |
25 | Avatar.defaultProps = {
26 | size: 'md'
27 | }
28 |
29 | export default Avatar;
30 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": [
5 | "es2017",
6 | "esnext.asynciterable"
7 | ],
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": [
11 | "./src/*"
12 | ]
13 | },
14 | "typeRoots": [
15 | "./node_modules/@types",
16 | "./src/types"
17 | ],
18 | "allowSyntheticDefaultImports": true,
19 | "experimentalDecorators": true,
20 | "emitDecoratorMetadata": true,
21 | "forceConsistentCasingInFileNames": true,
22 | "moduleResolution": "node",
23 | "module": "commonjs",
24 | "pretty": true,
25 | "sourceMap": true,
26 | "outDir": "./build",
27 | "allowJs": true,
28 | "noEmit": false,
29 | "esModuleInterop": true,
30 | "resolveJsonModule": true
31 | },
32 | "include": [
33 | "./src/**/*",
34 | ".env"
35 | ],
36 | "exclude": [
37 | "node_modules",
38 | ]
39 | }
--------------------------------------------------------------------------------
/frontend/src/helpers/utils.tsx:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import relativeTime from 'dayjs/plugin/relativeTime';
3 |
4 | dayjs.extend(relativeTime);
5 |
6 | export const displayTime = (createdAt: string | Date, showTime = false) => {
7 | const now = dayjs();
8 | const created = dayjs(createdAt);
9 | const oneDay = 24 * 60 * 60 * 1000;
10 | const twelveHours = 12 * 60 * 60 * 1000;
11 | const timeDisplay = !showTime ? '' : ` | ${dayjs(createdAt).format('hh:mm a')}`;
12 |
13 | if (now.diff(created) < twelveHours) {
14 | return dayjs(createdAt).fromNow();
15 | } else if (now.diff(created) < oneDay) {
16 | return `${dayjs(createdAt).fromNow()} ${timeDisplay}`;
17 | } else if (now.diff(createdAt, 'year') >= 1) {
18 | return `${dayjs(createdAt).format('MMM. DD, YYYY')} ${timeDisplay}`;
19 | } else {
20 | return `${dayjs(createdAt).format('MMM. DD')} ${timeDisplay}`;
21 | }
22 | }
--------------------------------------------------------------------------------
/server/src/middlewares/middlewares.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction } from 'express';
2 | import { isValidObjectId } from 'mongoose';
3 | import { ErrorHandler } from './error.middleware';
4 |
5 | function isAuthenticated(req: any, res: any, next: NextFunction) {
6 | if (req.isAuthenticated()) {
7 | console.log('CHECK MIDDLEWARE: IS AUTH: ', req.isAuthenticated());
8 | return next();
9 | }
10 |
11 | return next(new ErrorHandler(401));
12 | }
13 |
14 | function validateObjectID(...ObjectIDs) {
15 | return function (req: any, res: any, next: NextFunction) {
16 | ObjectIDs.forEach((id) => {
17 | if (!isValidObjectId(req.params[id])) {
18 | return next(new ErrorHandler(400, `ObjectID ${id} supplied is not valid`));
19 | } else {
20 | next();
21 | }
22 | });
23 | }
24 | }
25 |
26 | export { isAuthenticated, validateObjectID };
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/styles/components/modal.css:
--------------------------------------------------------------------------------
1 | .modal {
2 | position: absolute;
3 | background: var(--modalBackground);
4 | top: 50%;
5 | left: 50%;
6 | transform: translate(-50%, -50%);
7 | border: none;
8 | outline: none;
9 | overflow: hidden;
10 | @apply rounded-lg shadow-xl;
11 | @apply w-11/12;
12 | @apply laptop:w-auto;
13 | }
14 |
15 | .modal-overlay {
16 | position: fixed;
17 | top: 0;
18 | left: 0;
19 | right: 0;
20 | bottom: 0;
21 | z-index: 999;
22 | background-color: var(--modalOverlayBackground);
23 | }
24 |
25 |
26 | .ril-next-button,
27 | .ril-prev-button {
28 | border-radius: 15px;
29 | }
30 | .ril-next-button {
31 | border-top-right-radius: 0;
32 | border-bottom-right-radius: 0
33 | }
34 |
35 | .ril-prev-button {
36 | border-top-left-radius: 0;
37 | border-bottom-left-radius: 0
38 | }
39 |
40 | .ril-toolbar {
41 | background: transparent;
42 | }
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/authReducer.ts:
--------------------------------------------------------------------------------
1 | import { LOGIN_SUCCESS, LOGOUT_SUCCESS, REGISTER_SUCCESS, UPDATE_AUTH_PICTURE } from '~/constants/actionType';
2 | import { TAuthActionType } from '~/redux/action/authActions';
3 | import { IUser } from '~/types/types';
4 |
5 | const initState: IUser = {
6 | id: '',
7 | username: '',
8 | fullname: '',
9 | profilePicture: {}
10 | }
11 |
12 | const authReducer = (state = initState, action: TAuthActionType) => {
13 | switch (action.type) {
14 | case LOGIN_SUCCESS:
15 | return action.payload;
16 | case LOGOUT_SUCCESS:
17 | return initState;
18 | case REGISTER_SUCCESS:
19 | return action.payload;
20 | case UPDATE_AUTH_PICTURE:
21 | return {
22 | ...state,
23 | profilePicture: action.payload
24 | }
25 | default:
26 | return state;
27 | }
28 | };
29 |
30 | export default authReducer;
31 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Modals/PostModals.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingOutlined } from "@ant-design/icons";
2 | import { lazy, Suspense } from "react";
3 | import { IPost } from "~/types/types";
4 |
5 | const EditPostModal = lazy(() => import('./EditPostModal'));
6 | const PostLikesModal = lazy(() => import('./PostLikesModal'));
7 | const DeletePostModal = lazy(() => import('./DeletePostModal'));
8 |
9 | interface IProps {
10 | deleteSuccessCallback: (postID: string) => void;
11 | updateSuccessCallback: (post: IPost) => void;
12 | }
13 |
14 | const PostModals: React.FC = (props) => {
15 | return (
16 | }>
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export default PostModals;
25 |
--------------------------------------------------------------------------------
/frontend/src/redux/action/errorActions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CLEAR_AUTH_ERR_MSG,
3 | SET_AUTH_ERR_MSG,
4 | SET_NEWSFEED_ERR_MSG,
5 | SET_PROFILE_ERR_MSG
6 | } from "~/constants/actionType";
7 | import { IError } from "~/types/types";
8 |
9 | export const setAuthErrorMessage = (error: IError | null) => ({
10 | type: SET_AUTH_ERR_MSG,
11 | payload: error
12 | });
13 |
14 | export const setProfileErrorMessage = (error: IError | null) => ({
15 | type: SET_PROFILE_ERR_MSG,
16 | payload: error
17 | });
18 |
19 | export const setNewsFeedErrorMessage = (error: IError | null) => ({
20 | type: SET_NEWSFEED_ERR_MSG,
21 | payload: error
22 | });
23 |
24 | export const clearAuthErrorMessage = () => ({
25 | type: CLEAR_AUTH_ERR_MSG
26 | });
27 |
28 | export type ErrorActionType =
29 | | ReturnType
30 | | ReturnType
31 | | ReturnType
32 | | ReturnType;
33 |
--------------------------------------------------------------------------------
/frontend/src/redux/action/loadingActions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SET_AUTH_LOADING,
3 | SET_CREATE_POST_LOADING,
4 | SET_GET_FEED_LOADING,
5 | SET_GET_USER_LOADING
6 | } from "~/constants/actionType";
7 |
8 | export const isAuthenticating = (bool: boolean = true) => ({
9 | type: SET_AUTH_LOADING,
10 | payload: bool
11 | });
12 |
13 |
14 | export const isCreatingPost = (bool: boolean = true) => ({
15 | type: SET_CREATE_POST_LOADING,
16 | payload: bool
17 | });
18 |
19 | export const isGettingUser = (bool: boolean = true) => ({
20 | type: SET_GET_USER_LOADING,
21 | payload: bool
22 | });
23 |
24 | export const isGettingFeed = (bool: boolean = true) => ({
25 | type: SET_GET_FEED_LOADING,
26 | payload: bool
27 | });
28 |
29 | export type TLoadingActionType =
30 | | ReturnType
31 | | ReturnType
32 | | ReturnType
33 | | ReturnType;
--------------------------------------------------------------------------------
/server/src/schemas/MessageSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IUser } from "./UserSchema";
3 |
4 | export interface IMessage extends Document {
5 | from: IUser['_id'];
6 | to: IUser['_id'];
7 | text: string;
8 | seen: boolean;
9 | createdAt: string | number;
10 | }
11 |
12 | const MessageSchema = new Schema({
13 | from: {
14 | type: Schema.Types.ObjectId,
15 | ref: 'User',
16 | required: true
17 | },
18 | to: {
19 | type: Schema.Types.ObjectId,
20 | ref: 'User',
21 | required: true
22 | },
23 | text: {
24 | type: String,
25 | required: true
26 | },
27 | seen: {
28 | type: Boolean,
29 | default: false
30 | },
31 | createdAt: {
32 | type: Date,
33 | required: true
34 | }
35 |
36 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } });
37 |
38 | export default model('Message', MessageSchema);
39 |
--------------------------------------------------------------------------------
/frontend/src/pages/profile/Tabs/Bio.tsx:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 |
3 | interface IProps {
4 | bio: string;
5 | dateJoined: string | Date;
6 | }
7 |
8 | const Bio: React.FC = ({ bio, dateJoined }) => {
9 | return (
10 |
26 | );
27 | };
28 |
29 | export default Bio;
30 |
--------------------------------------------------------------------------------
/server/src/schemas/BookmarkSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IPost } from "./PostSchema";
3 | import { IUser } from "./UserSchema";
4 |
5 | export interface IBookmark extends Document {
6 | _post_id: IPost['_id'];
7 | _author_id: IUser['_id'];
8 | createdAt: string | Date;
9 | }
10 |
11 | const BookmarkSchema = new Schema({
12 | _post_id: {
13 | type: Schema.Types.ObjectId,
14 | ref: 'Post',
15 | required: true
16 | },
17 | _author_id: {
18 | type: Schema.Types.ObjectId,
19 | ref: 'User',
20 | required: true
21 | },
22 | createdAt: {
23 | type: Date,
24 | required: true
25 | }
26 |
27 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } });
28 |
29 | BookmarkSchema.virtual('post', {
30 | ref: 'Post',
31 | localField: '_post_id',
32 | foreignField: '_id',
33 | justOne: true
34 | });
35 |
36 | export default model('Bookmark', BookmarkSchema);
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Julius Guevarra
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/frontend/src/redux/action/profileActions.ts:
--------------------------------------------------------------------------------
1 | import { GET_USER_START, GET_USER_SUCCESS, UPDATE_COVER_PHOTO, UPDATE_PROFILE_INFO, UPDATE_PROFILE_PICTURE } from "~/constants/actionType";
2 | import { IProfile } from "~/types/types";
3 |
4 | export const getUserStart = (username: string) => ({
5 | type: GET_USER_START,
6 | payload: username
7 | });
8 |
9 | export const getUserSuccess = (user: IProfile) => ({
10 | type: GET_USER_SUCCESS,
11 | payload: user
12 | });
13 |
14 | export const updateProfileInfo = (user: IProfile) => ({
15 | type: UPDATE_PROFILE_INFO,
16 | payload: user
17 | });
18 |
19 | export const updateProfilePicture = (image: Object) => ({
20 | type: UPDATE_PROFILE_PICTURE,
21 | payload: image
22 | });
23 |
24 | export const updateCoverPhoto = (image: Object) => ({
25 | type: UPDATE_COVER_PHOTO,
26 | payload: image
27 | });
28 |
29 | export type TProfileActionTypes =
30 | | ReturnType
31 | | ReturnType
32 | | ReturnType
33 | | ReturnType
34 | | ReturnType;
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/modalReducer.ts:
--------------------------------------------------------------------------------
1 | import { HIDE_MODAL, SHOW_MODAL } from "~/constants/actionType";
2 | import { EModalType, IModalState } from "~/types/types";
3 | import { modalActionType } from "../action/modalActions";
4 |
5 | const initState: IModalState = {
6 | isOpenDeleteComment: false,
7 | isOpenDeletePost: false,
8 | isOpenEditPost: false,
9 | isOpenPostLikes: false
10 | }
11 |
12 | const modalMapType = {
13 | [EModalType.DELETE_COMMENT]: 'isOpenDeleteComment',
14 | [EModalType.DELETE_POST]: 'isOpenDeletePost',
15 | [EModalType.EDIT_POST]: 'isOpenEditPost',
16 | [EModalType.POST_LIKES]: 'isOpenPostLikes',
17 | }
18 |
19 | const modalReducer = (state = initState, action: modalActionType) => {
20 | switch (action.type) {
21 | case SHOW_MODAL:
22 | return {
23 | ...state,
24 | [modalMapType[action.payload]]: true
25 | }
26 | case HIDE_MODAL:
27 | return {
28 | ...state,
29 | [modalMapType[action.payload]]: false
30 | }
31 | default:
32 | return state;
33 | }
34 | }
35 |
36 | export default modalReducer;
--------------------------------------------------------------------------------
/server/src/helpers/utils.ts:
--------------------------------------------------------------------------------
1 | import { IUser } from "@/schemas/UserSchema";
2 | import Filter from 'bad-words';
3 |
4 | interface IResponseStatus {
5 | status_code: number;
6 | success: Boolean;
7 | data: any;
8 | error: any;
9 | timestamp: string | Date;
10 | }
11 |
12 | const initStatus: IResponseStatus = {
13 | status_code: 404,
14 | success: false,
15 | data: null,
16 | error: null,
17 | timestamp: null
18 | };
19 |
20 | const sessionizeUser = (user: Partial) => ({
21 | id: user._id,
22 | username: user.username,
23 | fullname: user.fullname,
24 | profilePicture: user.profilePicture
25 | })
26 |
27 | const makeResponseJson = (data: any, success = true) => {
28 | return {
29 | ...initStatus,
30 | status_code: 200,
31 | success,
32 | data,
33 | timestamp: new Date()
34 | };
35 | }
36 |
37 | const newBadWords = [
38 | 'gago', 'puta', 'animal', 'porn', 'amputa', 'tangina', 'pota', 'puta', 'putangina',
39 | 'libog', 'eut', 'iyot', 'iyutan', 'eutan', 'umiyot', 'karat', 'pornhub', 'ptngina', 'tngina'
40 | ];
41 |
42 | const filterWords = new Filter();
43 | filterWords.addWords(...newBadWords);
44 |
45 | export { sessionizeUser, makeResponseJson, filterWords };
46 |
47 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/errorReducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CLEAR_AUTH_ERR_MSG,
3 | SET_AUTH_ERR_MSG,
4 | SET_NEWSFEED_ERR_MSG,
5 | SET_PROFILE_ERR_MSG
6 | } from "~/constants/actionType";
7 | import { IErrorState } from "~/types/types";
8 | import { ErrorActionType } from "../action/errorActions";
9 |
10 | const initState: IErrorState = {
11 | authError: null,
12 | profileError: null,
13 | newsFeedError: null
14 | }
15 |
16 | const errorReducer = (state = initState, action: ErrorActionType) => {
17 | switch (action.type) {
18 | case SET_AUTH_ERR_MSG:
19 | return {
20 | ...state,
21 | authError: action.payload
22 | }
23 | case SET_PROFILE_ERR_MSG:
24 | return {
25 | ...state,
26 | profileError: action.payload
27 | }
28 | case SET_NEWSFEED_ERR_MSG:
29 | return {
30 | ...state,
31 | newsFeedError: action.payload
32 | }
33 | case CLEAR_AUTH_ERR_MSG:
34 | return {
35 | ...state,
36 | authError: null
37 | }
38 | default:
39 | return state;
40 | }
41 | };
42 |
43 | export default errorReducer;
44 |
--------------------------------------------------------------------------------
/frontend/src/redux/sagas/profileSaga.ts:
--------------------------------------------------------------------------------
1 | import { call, put } from "redux-saga/effects";
2 | import { GET_USER_START } from "~/constants/actionType";
3 | import { getUser } from "~/services/api";
4 | import { IProfile } from "~/types/types";
5 | import { setProfileErrorMessage } from "../action/errorActions";
6 | import { isGettingUser } from "../action/loadingActions";
7 | import { getUserSuccess } from "../action/profileActions";
8 |
9 | interface IProfileSaga {
10 | type: string;
11 | payload: any;
12 | }
13 |
14 | function* profileSaga({ type, payload }: IProfileSaga) {
15 | switch (type) {
16 | case GET_USER_START:
17 | try {
18 | yield put(isGettingUser(true));
19 | const user: IProfile = yield call(getUser, payload);
20 |
21 | yield put(isGettingUser(false));
22 | yield put(setProfileErrorMessage(null));
23 | yield put(getUserSuccess(user));
24 | } catch (e) {
25 | yield put(setProfileErrorMessage(e));
26 | yield put(isGettingUser(false));
27 | console.log(e);
28 | }
29 | break;
30 | default:
31 | throw new Error(`Unexpected action type ${type}.`);
32 | }
33 | }
34 |
35 | export default profileSaga;
36 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/loadingReducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SET_AUTH_LOADING,
3 | SET_CREATE_POST_LOADING,
4 | SET_GET_FEED_LOADING,
5 | SET_GET_USER_LOADING
6 | } from "~/constants/actionType";
7 | import { TLoadingActionType } from "../action/loadingActions";
8 |
9 | const initState = {
10 | isLoadingAuth: false,
11 | isLoadingCreatePost: false,
12 | isLoadingGetUser: false,
13 | isLoadingProfile: false,
14 | isLoadingFeed: false
15 | }
16 |
17 | const loadingReducer = (state = initState, action: TLoadingActionType) => {
18 | switch (action.type) {
19 | case SET_AUTH_LOADING:
20 | return {
21 | ...state,
22 | isLoadingAuth: action.payload
23 | }
24 | case SET_CREATE_POST_LOADING:
25 | return {
26 | ...state,
27 | isLoadingCreatePost: action.payload
28 | }
29 | case SET_GET_USER_LOADING:
30 | return {
31 | ...state,
32 | isLoadingGetUser: action.payload
33 | }
34 | case SET_GET_FEED_LOADING:
35 | return {
36 | ...state,
37 | isLoadingFeed: action.payload
38 | }
39 | default:
40 | return state;
41 | }
42 | };
43 |
44 | export default loadingReducer;
45 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Loader.tsx:
--------------------------------------------------------------------------------
1 |
2 | const Loader: React.FC<{ mode?: string; size?: string }> = ({ mode, size }) => {
3 | return (
4 |
27 | );
28 | };
29 |
30 | Loader.defaultProps = {
31 | mode: 'dark',
32 | size: 'sm'
33 | }
34 |
35 | export default Loader;
36 |
--------------------------------------------------------------------------------
/frontend/src/components/main/UserCard/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 | import { Link } from "react-router-dom";
3 | import { FollowButton } from '~/components/main';
4 | import { Avatar } from "~/components/shared";
5 | import { IProfile, IRootReducer, IUser } from "~/types/types";
6 |
7 | interface IProps {
8 | profile: IProfile | IUser;
9 | }
10 |
11 | const UserCard: React.FC = ({ profile }) => {
12 | const myUsername = useSelector((state: IRootReducer) => state.auth.username);
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
@{profile.username}
20 |
21 |
22 |
23 | {profile.username === myUsername ? (
24 |
Me
25 | ) : (
26 |
27 | )}
28 |
29 |
30 | );
31 | };
32 |
33 | export default UserCard;
34 |
--------------------------------------------------------------------------------
/frontend/src/redux/action/chatActions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CLEAR_CHAT,
3 | CLOSE_CHAT,
4 | GET_MESSAGES_SUCCESS,
5 | INITIATE_CHAT,
6 | MINIMIZE_CHAT,
7 | NEW_MESSAGE_ARRIVED
8 | } from "~/constants/actionType";
9 | import { IMessage, IUser, PartialBy } from "~/types/types";
10 |
11 | export const initiateChat = (user: PartialBy) => ({
12 | type: INITIATE_CHAT,
13 | payload: user
14 | });
15 |
16 | export const minimizeChat = (target: string) => ({
17 | type: MINIMIZE_CHAT,
18 | payload: target
19 | });
20 |
21 | export const closeChat = (target: string) => ({
22 | type: CLOSE_CHAT,
23 | payload: target
24 | });
25 |
26 | export const getMessagesSuccess = (target: string, messages: IMessage[]) => ({
27 | type: GET_MESSAGES_SUCCESS,
28 | payload: {
29 | username: target,
30 | messages
31 | }
32 | });
33 |
34 | export const newMessageArrived = (target: string, message: IMessage) => ({
35 | type: NEW_MESSAGE_ARRIVED,
36 | payload: {
37 | username: target,
38 | message
39 | }
40 | });
41 |
42 | export const clearChat = () => ({
43 | type: CLEAR_CHAT
44 | });
45 |
46 | export type TChatActionType =
47 | | ReturnType
48 | | ReturnType
49 | | ReturnType
50 | | ReturnType
51 | | ReturnType
52 | | ReturnType;
--------------------------------------------------------------------------------
/frontend/src/images/SVG/logoAsset 1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/services/fetcher.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosRequestConfig } from "axios";
2 | import { logoutStart } from '~/redux/action/authActions';
3 | import store from '~/redux/store/store';
4 |
5 | const foodieUrl = process.env.REACT_APP_FOODIE_URL || 'http://localhost:9000';
6 | const foodieApiVersion = process.env.REACT_APP_FOODIE_API_VERSION || 'v1';
7 | axios.defaults.baseURL = `${foodieUrl}/api/${foodieApiVersion}`;
8 | axios.defaults.withCredentials = true;
9 |
10 | let isLogoutTriggered = false;
11 |
12 | function resetIsLogoutTriggered() {
13 | isLogoutTriggered = false;
14 | }
15 |
16 | axios.interceptors.response.use(
17 | response => response,
18 | error => {
19 | const { data, status } = error.response;
20 | if (status === 401
21 | && (data?.error?.type || '') !== 'INCORRECT_CREDENTIALS'
22 | && error.config
23 | && !error.config.__isRetryRequest
24 | ) {
25 | if (!isLogoutTriggered) {
26 | isLogoutTriggered = true;
27 | store.dispatch(logoutStart(resetIsLogoutTriggered));
28 | }
29 | }
30 | return Promise.reject(error);
31 | }
32 | );
33 |
34 | const httpRequest = (req: AxiosRequestConfig): Promise => {
35 | return new Promise(async (resolve, reject) => {
36 | try {
37 | const request = await axios(req);
38 |
39 | resolve(request.data.data)
40 | } catch (e) {
41 | reject(e?.response?.data || {});
42 | }
43 | });
44 | }
45 |
46 | export default httpRequest;
--------------------------------------------------------------------------------
/frontend/src/components/main/Comments/CommentInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, MutableRefObject, useEffect } from "react";
2 | import { useSelector } from "react-redux";
3 | import { Avatar } from "~/components/shared";
4 | import { IRootReducer } from "~/types/types";
5 |
6 | interface IProps {
7 | isLoading: boolean;
8 | isSubmitting: boolean;
9 | isUpdateMode: boolean;
10 | [prop: string]: any;
11 | }
12 |
13 | const CommentInput = forwardRef((props, ref) => {
14 | const { isUpdateMode, isSubmitting, isLoading, ...rest } = props;
15 | const userPicture = useSelector((state: IRootReducer) => state.auth.profilePicture);
16 |
17 | useEffect(() => {
18 | ref && (ref as MutableRefObject).current.focus();
19 | }, [ref])
20 |
21 | return (
22 |
23 | {!isUpdateMode &&
}
24 |
25 |
32 | {isUpdateMode && Press Esc to Cancel}
33 |
34 |
35 | );
36 | });
37 |
38 | export default CommentInput;
39 |
--------------------------------------------------------------------------------
/server/src/schemas/NotificationSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IUser } from "./UserSchema";
3 |
4 | export enum ENotificationType {
5 | follow = 'follow',
6 | like = 'like',
7 | commentLike = 'comment-like',
8 | comment = 'comment',
9 | reply = 'reply'
10 | }
11 |
12 | interface INotificationDocument extends Document {
13 | type: ENotificationType;
14 | initiator: IUser['_id'];
15 | target: IUser['_id'];
16 | unread: boolean;
17 | link: string;
18 | createdAt: string | number;
19 | }
20 |
21 | const NotificationSchema = new Schema({
22 | type: {
23 | type: String,
24 | required: true,
25 | enum: ['follow', 'like', 'comment-like', 'comment', 'reply'],
26 | },
27 | initiator: {
28 | type: Schema.Types.ObjectId,
29 | ref: 'User',
30 | required: true
31 | },
32 | target: {
33 | type: Schema.Types.ObjectId,
34 | ref: 'User',
35 | required: true
36 | },
37 | unread: {
38 | type: Boolean,
39 | default: true
40 | },
41 | link: {
42 | type: String,
43 | required: true
44 | },
45 | createdAt: {
46 | type: Date,
47 | required: true
48 | }
49 | }, {
50 | timestamps: true,
51 | toJSON: {
52 | virtuals: true
53 | },
54 | toObject: {
55 | virtuals: true,
56 | getters: true
57 | }
58 | });
59 |
60 | const Notification = model('Notification', NotificationSchema);
61 |
62 | export default Notification;
63 |
--------------------------------------------------------------------------------
/server/src/routes/api/v1/feed.ts:
--------------------------------------------------------------------------------
1 | import { FEED_LIMIT } from '@/constants/constants';
2 | import { makeResponseJson } from '@/helpers/utils';
3 | import { ErrorHandler } from '@/middlewares';
4 | import { EPrivacy } from '@/schemas/PostSchema';
5 | import { NewsFeedService, PostService } from '@/services';
6 | import { NextFunction, Request, Response, Router } from 'express';
7 |
8 | const router = Router({ mergeParams: true });
9 |
10 | router.get(
11 | '/v1/feed',
12 | async (req: Request, res: Response, next: NextFunction) => {
13 |
14 | try {
15 | const offset = parseInt((req.query.offset as string), 10) || 0;
16 | const limit = FEED_LIMIT;
17 | const skip = offset * limit;
18 |
19 | let result = [];
20 |
21 | if (req.isAuthenticated()) {
22 | result = await NewsFeedService.getNewsFeed(
23 | req.user,
24 | { follower: req.user._id },
25 | skip,
26 | limit
27 | );
28 | } else {
29 | result = await PostService.getPosts(null, { privacy: EPrivacy.public }, { skip, limit, sort: { createdAt: -1 } });
30 | }
31 |
32 | if (result.length === 0) {
33 | return next(new ErrorHandler(404, 'No more feed.'));
34 | }
35 |
36 | res.status(200).send(makeResponseJson(result));
37 | } catch (e) {
38 | console.log('CANT GET FEED', e);
39 | next(e);
40 | }
41 | }
42 | );
43 |
44 | export default router;
45 |
--------------------------------------------------------------------------------
/server/src/config/config.ts:
--------------------------------------------------------------------------------
1 | import connectMongo from 'connect-mongo';
2 | import session from 'express-session';
3 | import mongoose from 'mongoose';
4 | import path from 'path';
5 |
6 | const MongoStore = connectMongo(session);
7 | const env = process.env.NODE_ENV || 'dev';
8 |
9 | if (env === 'dev') {
10 | require('dotenv').config({
11 | path: path.join(__dirname, '../../.env-dev')
12 | })
13 | }
14 |
15 | export default {
16 | server: {
17 | env,
18 | port: process.env.PORT || 9000,
19 | },
20 | mongodb: {
21 | uri: process.env.MONGODB_URI,
22 | dbName: process.env.MONGODB_DB_NAME
23 | },
24 | session: {
25 | key: process.env.SESSION_NAME,
26 | secret: process.env.SESSION_SECRET,
27 | resave: false,
28 | saveUninitialized: true,
29 | cookie: {
30 | expires: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
31 | secure: env !== 'dev',
32 | sameSite: env === 'dev' ? 'strict' : 'none',
33 | httpOnly: env !== 'dev'
34 | }, //14 days expiration
35 | store: new MongoStore({
36 | mongooseConnection: mongoose.connection,
37 | collection: 'session'
38 | })
39 | },
40 | cors: {
41 | origin: process.env.CLIENT_URL,
42 | credentials: true,
43 | preflightContinue: true
44 | },
45 | gCloudStorage: {
46 | projectId: process.env.FIREBASE_PROJECT_ID,
47 | keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS
48 | },
49 | cloudinary: {
50 | cloud_name: process.env.CLOUDINARY_NAME,
51 | api_key: process.env.CLOUDINARY_API_KEY,
52 | api_secret: process.env.CLOUDINARY_API_SECRET
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/server/src/config/socket.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config/config';
2 | import User from '@/schemas/UserSchema';
3 | import { Application } from "express";
4 | import { Server } from "http";
5 |
6 | export default function (app: Application, server: Server) {
7 | const io = require('socket.io')(server, {
8 | cors: {
9 | origin: config.cors.origin || 'http://localhost:3000',
10 | methods: ["GET", "POST", "PATCH"],
11 | credentials: true
12 | }
13 | });
14 |
15 | app.set('io', io);
16 |
17 | io.on("connection", (socket: SocketIO.Socket) => {
18 | socket.on("userConnect", (id) => {
19 | User
20 | .findById(id)
21 | .then((user) => {
22 | if (user) {
23 | socket.join(user._id.toString());
24 | console.log('Client connected.');
25 | }
26 | })
27 | .catch((e) => {
28 | console.log('Invalid user ID, cannot join Socket.');
29 | });
30 | });
31 |
32 | socket.on("userDisconnect", (userID) => {
33 | socket.leave(userID);
34 | console.log('Client Disconnected.');
35 | });
36 |
37 | socket.on("onFollowUser", (data) => {
38 | console.log(data);
39 | });
40 |
41 | socket.on("user-typing", ({ user, state }) => {
42 | io.to(user.id).emit("typing", state)
43 | })
44 |
45 | socket.on("disconnect", () => {
46 | console.log('Client disconnected');
47 | });
48 | });
49 | }
--------------------------------------------------------------------------------
/frontend/src/components/main/Modals/ImageLightbox.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import Lightbox from 'react-image-lightbox';
3 |
4 | interface IProps {
5 | images: string[];
6 | isOpen: boolean;
7 | activeIndex: number;
8 | closeLightbox: () => void;
9 | }
10 |
11 | const ImageLightbox: React.FC = (props): React.ReactElement | null => {
12 | const { images, isOpen, closeLightbox, activeIndex } = props;
13 | const [imgIndex, setImgIndex] = useState(activeIndex);
14 |
15 | useEffect(() => {
16 | setImgIndex(activeIndex);
17 | }, [activeIndex]);
18 |
19 | const onNext = () => {
20 | setImgIndex((imgIndex + 1) % images.length);
21 | }
22 |
23 | const onPrev = () => {
24 | setImgIndex((imgIndex + images.length - 1) % images.length);
25 | }
26 |
27 | return isOpen ? (
28 |
29 |
45 |
46 | ) : null;
47 | };
48 |
49 | export default ImageLightbox;
50 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/profileReducer.ts:
--------------------------------------------------------------------------------
1 | import { GET_USER_SUCCESS, UPDATE_COVER_PHOTO, UPDATE_PROFILE_INFO, UPDATE_PROFILE_PICTURE } from "~/constants/actionType";
2 | import { IProfile } from "~/types/types";
3 | import { TProfileActionTypes } from "../action/profileActions";
4 |
5 | const initState: IProfile = {
6 | _id: '',
7 | id: '',
8 | username: '',
9 | email: '',
10 | fullname: '',
11 | firstname: '',
12 | lastname: '',
13 | info: {
14 | bio: '',
15 | birthday: '',
16 | gender: 'unspecified',
17 | },
18 | isEmailValidated: false,
19 | profilePicture: {},
20 | coverPhoto: {},
21 | followersCount: 0,
22 | followingCount: 0,
23 | dateJoined: ''
24 | };
25 |
26 | const profileReducer = (state = initState, action: TProfileActionTypes) => {
27 | switch (action.type) {
28 | case GET_USER_SUCCESS:
29 | return action.payload;
30 | case UPDATE_PROFILE_PICTURE:
31 | return {
32 | ...state,
33 | profilePicture: action.payload
34 | }
35 | case UPDATE_PROFILE_INFO:
36 | const { payload: user } = action;
37 | return {
38 | ...state,
39 | fullname: user.fullname,
40 | firstname: user.firstname,
41 | lastname: user.lastname,
42 | info: user.info
43 | }
44 | case UPDATE_COVER_PHOTO:
45 | return {
46 | ...state,
47 | coverPhoto: action.payload
48 | }
49 | default:
50 | return state;
51 | }
52 | };
53 |
54 | export default profileReducer;
55 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Chats/MinimizedChats.tsx:
--------------------------------------------------------------------------------
1 | import { useDispatch } from "react-redux";
2 | import { CSSTransition, TransitionGroup } from "react-transition-group";
3 | import Avatar from "~/components/shared/Avatar";
4 | import { initiateChat } from "~/redux/action/chatActions";
5 | import { IChatItemsState } from "~/types/types";
6 |
7 | interface IProps {
8 | users: IChatItemsState[]
9 | }
10 |
11 | const MinimizedChats: React.FC = ({ users }) => {
12 | const dispatch = useDispatch();
13 |
14 | return (
15 |
16 |
17 | {users.map(chat => chat.minimized && (
18 |
23 | dispatch(initiateChat(chat))}
26 | title={chat.username}
27 | >
28 |
33 |
34 |
35 | ))}
36 |
37 |
38 | );
39 | };
40 |
41 | export default MinimizedChats;
42 |
43 |
--------------------------------------------------------------------------------
/frontend/craco.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const { CracoAliasPlugin, configPaths } = require('react-app-rewire-alias')
3 |
4 | module.exports = {
5 | style: {
6 | postcss: {
7 | plugins: [
8 | require('postcss-import'),
9 | require('postcss-nested'),
10 | require('postcss-extend'),
11 | require('tailwindcss'),
12 | require('autoprefixer'),
13 | ],
14 | },
15 | },
16 | webpack: {
17 | alias: {
18 | // Add the aliases for all the top-level folders in the `src/` folder.
19 | '~/*': path.join(path.resolve(__dirname, './src/*'))
20 | }
21 | },
22 | jest: {
23 | configure: {
24 | moduleNameMapper: {
25 | // Jest module mapper which will detect our absolute imports.
26 | '^assets(.*)$': '/src/assets$1',
27 | '^components(.*)$': '/src/components$1',
28 | '^routers(.*)$': '/src/routers$1',
29 | '^pages(.*)$': '/src/pages$1',
30 | '^redux(.*)$': '/src/redux$1',
31 | '^utils(.*)$': '/src/utils$1',
32 | '^types(.*)$': '/src/types$1',
33 | '^constants(.*)$': '/src/constants$1',
34 |
35 | // Another example for using a wildcard character
36 | '^~(.*)$': '/src$1'
37 | }
38 | }
39 | },
40 | plugins: [
41 | {
42 | plugin: CracoAliasPlugin,
43 | options: { alias: configPaths('./tsconfig.paths.json') }
44 | }
45 | ]
46 | }
--------------------------------------------------------------------------------
/frontend/src/redux/action/authActions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CHECK_SESSION,
3 | LOGIN_START,
4 | LOGIN_SUCCESS,
5 | LOGOUT_START,
6 | LOGOUT_SUCCESS,
7 | REGISTER_START,
8 | REGISTER_SUCCESS,
9 | UPDATE_AUTH_PICTURE
10 | } from "~/constants/actionType";
11 | import { IRegister, IUser } from "~/types/types";
12 |
13 | export const loginStart = (email: string, password: string) => ({
14 | type: LOGIN_START,
15 | payload: {
16 | email,
17 | password
18 | }
19 | });
20 |
21 | export const loginSuccess = (auth: IUser) => ({
22 | type: LOGIN_SUCCESS,
23 | payload: auth
24 | });
25 |
26 | export const logoutStart = (callback?: () => void) => ({
27 | type: LOGOUT_START,
28 | payload: { callback }
29 | });
30 |
31 | export const logoutSuccess = () => ({
32 | type: LOGOUT_SUCCESS
33 | });
34 |
35 | export const registerStart = ({ email, password, username }: IRegister) => ({
36 | type: REGISTER_START,
37 | payload: {
38 | email,
39 | password,
40 | username
41 | }
42 | });
43 |
44 | export const registerSuccess = (userAuth: IUser) => ({
45 | type: REGISTER_SUCCESS,
46 | payload: userAuth
47 | });
48 |
49 |
50 | export const checkSession = () => ({
51 | type: CHECK_SESSION
52 | });
53 |
54 | export const updateAuthPicture = (image: Object) => ({
55 | type: UPDATE_AUTH_PICTURE,
56 | payload: image
57 | });
58 |
59 | export type TAuthActionType =
60 | | ReturnType
61 | | ReturnType
62 | | ReturnType
63 | | ReturnType
64 | | ReturnType
65 | | ReturnType
66 | | ReturnType
67 | | ReturnType;
--------------------------------------------------------------------------------
/frontend/src/components/shared/SocialLogin.tsx:
--------------------------------------------------------------------------------
1 | import { FacebookFilled, GithubFilled, GoogleOutlined } from "@ant-design/icons";
2 |
3 | const SocialLogin: React.FC<{ isLoading: boolean; }> = ({ isLoading }) => {
4 | const onClickSocialLogin = (e: React.MouseEvent) => {
5 | if (isLoading) e.preventDefault();
6 | }
7 |
8 | return (
9 |
35 | )
36 | };
37 |
38 | export default SocialLogin;
39 |
--------------------------------------------------------------------------------
/server/src/schemas/PostSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, isValidObjectId, model, Schema } from "mongoose";
2 | import { IComment } from "./CommentSchema";
3 | import { IUser } from "./UserSchema";
4 |
5 | export enum EPrivacy {
6 | private = 'private',
7 | public = 'public',
8 | follower = 'follower'
9 | }
10 |
11 | export interface IPost extends Document {
12 | _author_id: IUser['_id'];
13 | privacy: EPrivacy;
14 | photos?: Record[];
15 | description: string;
16 | likes: Array;
17 | comments: Array;
18 | isEdited: boolean;
19 | createdAt: string | number;
20 | updatedAt: string | number;
21 |
22 | author: IUser;
23 |
24 | isPostLiked(id: string): boolean;
25 | }
26 |
27 | const PostSchema = new Schema({
28 | _author_id: {
29 | // author: {
30 | type: Schema.Types.ObjectId,
31 | ref: 'User',
32 | required: true
33 | },
34 | privacy: {
35 | type: String,
36 | default: 'public',
37 | enum: ['private', 'public', 'follower']
38 | },
39 | photos: [Object],
40 | description: {
41 | type: String,
42 | default: ''
43 | },
44 | isEdited: {
45 | type: Boolean,
46 | default: false
47 | },
48 | createdAt: Date,
49 | updatedAt: Date,
50 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } });
51 |
52 | PostSchema.virtual('author', {
53 | ref: 'User',
54 | localField: '_author_id',
55 | foreignField: '_id',
56 | justOne: true
57 | });
58 |
59 | PostSchema.methods.isPostLiked = function (this: IPost, userID) {
60 | if (!isValidObjectId(userID)) return;
61 |
62 | return this.likes.some(user => {
63 | return user._id.toString() === userID.toString();
64 | });
65 | }
66 |
67 | export default model('Post', PostSchema);
68 |
--------------------------------------------------------------------------------
/frontend/src/components/main/BookmarkButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { toast } from 'react-toastify';
3 | import { useDidMount } from '~/hooks';
4 | import { bookmarkPost } from '~/services/api';
5 |
6 | interface IProps {
7 | postID: string;
8 | initBookmarkState: boolean;
9 | children: (props: any) => React.ReactNode;
10 | }
11 |
12 | const BookmarkButton: React.FC = (props) => {
13 | const [isBookmarked, setIsBookmarked] = useState(props.initBookmarkState || false);
14 | const [isLoading, setLoading] = useState(false);
15 | const didMount = useDidMount(true);
16 |
17 | useEffect(() => {
18 | setIsBookmarked(props.initBookmarkState);
19 | }, [props.initBookmarkState]);
20 |
21 | const dispatchBookmark = async () => {
22 | if (isLoading) return;
23 |
24 | try {
25 | // state = TRUE | FALSE
26 | setLoading(true);
27 | const { state } = await bookmarkPost(props.postID);
28 |
29 | if (didMount) {
30 | setIsBookmarked(state);
31 | setLoading(false);
32 | }
33 |
34 | if (state) {
35 | toast.dark('Post successfully bookmarked.', {
36 | hideProgressBar: true,
37 | autoClose: 2000
38 | });
39 | } else {
40 | toast.info('Post removed from bookmarks.', {
41 | hideProgressBar: true,
42 | autoClose: 2000
43 | });
44 | }
45 | } catch (e) {
46 | didMount && setLoading(false);
47 | console.log(e);
48 | }
49 | }
50 |
51 | return (
52 |
53 | { props.children({ dispatchBookmark, isBookmarked, isLoading })}
54 |
55 | );
56 | };
57 |
58 | export default BookmarkButton;
59 |
--------------------------------------------------------------------------------
/server/src/services/follow.service.ts:
--------------------------------------------------------------------------------
1 | import { Follow } from "@/schemas";
2 | import { IUser } from "@/schemas/UserSchema";
3 |
4 | export const getFollow = (
5 | query: Object,
6 | type = 'followers',
7 | user: IUser,
8 | skip?: number,
9 | limit?: number
10 | ): Promise => {
11 | return new Promise(async (resolve, reject) => {
12 | try {
13 | const myFollowingDoc = await Follow.find({ user: user._id });
14 | const myFollowing = myFollowingDoc.map(user => user.target); // map to array of user IDs
15 |
16 | const agg = await Follow.aggregate([
17 | {
18 | $match: query
19 | },
20 | { $skip: skip },
21 | { $limit: limit },
22 | {
23 | $lookup: {
24 | from: 'users',
25 | localField: type === 'following' ? 'target' : 'user',
26 | foreignField: '_id',
27 | as: 'user'
28 | }
29 | },
30 | {
31 | $unwind: '$user'
32 | },
33 | {
34 | $addFields: {
35 | isFollowing: { $in: ['$user._id', myFollowing] }
36 | }
37 | },
38 | {
39 | $project: {
40 | _id: 0,
41 | id: '$user._id',
42 | username: '$user.username',
43 | email: '$user.email',
44 | profilePicture: '$user.profilePicture',
45 | isFollowing: 1
46 | }
47 | }
48 | ]);
49 |
50 | resolve(agg);
51 | } catch (err) {
52 | reject(err);
53 | }
54 | });
55 | }
--------------------------------------------------------------------------------
/frontend/src/components/main/LikeButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { LikeOutlined } from '@ant-design/icons';
2 | import { useEffect, useState } from 'react';
3 | import { useDidMount } from '~/hooks';
4 | import { likePost } from '~/services/api';
5 |
6 | interface IProps {
7 | postID: string;
8 | isLiked: boolean;
9 | likeCallback: (postID: string, state: boolean, newLikeCount: number) => void;
10 | }
11 |
12 | const LikeButton: React.FC = (props) => {
13 | const [isLiked, setIsLiked] = useState(props.isLiked);
14 | const [isLoading, setLoading] = useState(false);
15 | const didMount = useDidMount();
16 |
17 | useEffect(() => {
18 | setIsLiked(props.isLiked);
19 | }, [props.isLiked]);
20 |
21 | const dispatchLike = async () => {
22 | if (isLoading) return;
23 |
24 | try {
25 | setLoading(true);
26 |
27 | const { state, likesCount } = await likePost(props.postID);
28 | if (didMount) {
29 | setLoading(false);
30 | setIsLiked(state);
31 | }
32 |
33 | props.likeCallback(props.postID, state, likesCount);
34 | } catch (e) {
35 | didMount && setLoading(false);
36 | console.log(e);
37 | }
38 | }
39 |
40 | return (
41 |
45 |
46 |
47 |
48 | {isLiked ? 'Unlike' : 'Like'}
49 |
50 | );
51 | };
52 |
53 | export default LikeButton;
54 |
--------------------------------------------------------------------------------
/frontend/src/redux/store/store.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'react';
2 | import { applyMiddleware, compose, createStore, Store } from 'redux';
3 | import createSagaMiddleware from 'redux-saga';
4 | import rootReducer from '../reducer/rootReducer';
5 | import rootSaga from '../sagas';
6 |
7 | declare global {
8 | interface Window {
9 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
10 | }
11 | }
12 |
13 | const localStorageMiddleware = (store: Store) => {
14 | return (next: Dispatch) => (action: any) => {
15 | const result = next(action);
16 | try {
17 | const { settings } = store.getState();
18 | localStorage.setItem('foodie_theme', JSON.stringify(settings.theme));
19 | } catch (e) {
20 | console.log('Error while saving in localStorage', e);
21 | }
22 | return result;
23 | };
24 | };
25 |
26 | const reHydrateStore = () => {
27 | const storage = localStorage.getItem('foodie_theme');
28 | if (storage && storage !== null) {
29 | return {
30 | settings: {
31 | theme: JSON.parse(storage)
32 | }
33 | };
34 | }
35 | return undefined;
36 | };
37 |
38 | const sagaMiddleware = createSagaMiddleware();
39 | const middlewares: any = [sagaMiddleware, localStorageMiddleware];
40 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
41 |
42 | if (process.env.NODE_ENV === `development`) {
43 | const { logger } = require(`redux-logger`);
44 |
45 | middlewares.push(logger);
46 | }
47 |
48 | const configureStore = () => {
49 | const store = createStore(
50 | rootReducer,
51 | reHydrateStore(),
52 | composeEnhancers(applyMiddleware(...middlewares)),
53 | );
54 |
55 | sagaMiddleware.run(rootSaga);
56 | return store;
57 | };
58 |
59 | const store = configureStore();
60 |
61 | export default store;
62 |
--------------------------------------------------------------------------------
/server/src/schemas/CommentSchema.ts:
--------------------------------------------------------------------------------
1 | import { Document, model, Schema } from "mongoose";
2 | import { IPost } from "./PostSchema";
3 | import { IUser } from "./UserSchema";
4 |
5 | export interface IComment extends Document {
6 | _post_id: IPost['_id'];
7 | body: string;
8 | _author_id: IUser['_id'];
9 | depth: number;
10 | parent: IComment['id'];
11 | parents: IComment['id'][];
12 | isEdited: boolean;
13 | createdAt: number | Date;
14 | updatedAt: number | Date;
15 | }
16 |
17 | const options = {
18 | timestamps: true,
19 | toJSON: {
20 | virtuals: true,
21 | transform: function (doc, ret, opt) {
22 | delete ret.parents;
23 | return ret;
24 | }
25 | },
26 | toObject: {
27 | getters: true,
28 | virtuals: true,
29 | transform: function (doc, ret, opt) {
30 | delete ret.parents;
31 | return ret;
32 | }
33 | }
34 | }
35 |
36 | const CommentSchema = new Schema({
37 | _post_id: {
38 | type: Schema.Types.ObjectId,
39 | ref: 'Post',
40 | required: true
41 | },
42 | parent: {
43 | type: Schema.Types.ObjectId,
44 | ref: 'Comment',
45 | default: null
46 | },
47 | parents: [{
48 | type: Schema.Types.ObjectId,
49 | ref: 'Comment'
50 | }],
51 | depth: {
52 | type: Number,
53 | default: 1
54 | },
55 | body: String,
56 | _author_id: {
57 | type: Schema.Types.ObjectId,
58 | ref: 'User'
59 | },
60 | isEdited: {
61 | type: Boolean,
62 | default: false
63 | },
64 | createdAt: Date,
65 | updatedAt: Date
66 | }, options);
67 |
68 | CommentSchema.virtual('author', {
69 | ref: 'User',
70 | localField: '_author_id',
71 | foreignField: '_id',
72 | justOne: true
73 | });
74 |
75 | export default model('Comment', CommentSchema);
76 |
--------------------------------------------------------------------------------
/frontend/src/redux/sagas/newsFeedSaga.ts:
--------------------------------------------------------------------------------
1 | import { toast } from "react-toastify";
2 | import { call, put } from "redux-saga/effects";
3 | import { CREATE_POST_START, GET_FEED_START } from "~/constants/actionType";
4 | import { createPost, getNewsFeed } from "~/services/api";
5 | import { IPost } from "~/types/types";
6 | import { setNewsFeedErrorMessage } from "../action/errorActions";
7 | import { createPostSuccess, getNewsFeedSuccess } from "../action/feedActions";
8 | import { isCreatingPost, isGettingFeed } from "../action/loadingActions";
9 |
10 | interface INewsFeedSaga {
11 | type: string;
12 | payload: any;
13 | }
14 |
15 | function* newsFeedSaga({ type, payload }: INewsFeedSaga) {
16 | switch (type) {
17 | case GET_FEED_START:
18 | try {
19 | yield put(isGettingFeed(true));
20 | yield put(setNewsFeedErrorMessage(null));
21 |
22 | const posts: IPost[] = yield call(getNewsFeed, payload);
23 |
24 | yield put(isGettingFeed(false));
25 | yield put(getNewsFeedSuccess(posts));
26 | } catch (e) {
27 | console.log(e);
28 | yield put(isGettingFeed(false));
29 | yield put(setNewsFeedErrorMessage(e))
30 | }
31 |
32 | break;
33 | case CREATE_POST_START:
34 | try {
35 | yield put(isCreatingPost(true));
36 |
37 | const post: IPost = yield call(createPost, payload);
38 |
39 | yield put(createPostSuccess(post));
40 | yield put(isCreatingPost(false));
41 | toast.dismiss();
42 | toast.dark('Post succesfully created.');
43 | } catch (e) {
44 | yield put(isCreatingPost(false));
45 | console.log(e);
46 | }
47 | break;
48 | default:
49 | throw new Error('Unexpected action type.')
50 | }
51 | }
52 |
53 | export default newsFeedSaga;
54 |
--------------------------------------------------------------------------------
/frontend/src/redux/action/feedActions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CLEAR_FEED,
3 | CREATE_POST_START,
4 | CREATE_POST_SUCCESS,
5 | DELETE_FEED_POST,
6 | GET_FEED_START,
7 | GET_FEED_SUCCESS,
8 | HAS_NEW_FEED,
9 | UPDATE_FEED_POST,
10 | UPDATE_POST_LIKES
11 | } from "~/constants/actionType";
12 | import { IFetchParams, IPost } from "~/types/types";
13 |
14 | export const getNewsFeedStart = (options?: IFetchParams) => ({
15 | type: GET_FEED_START,
16 | payload: options
17 | });
18 |
19 | export const getNewsFeedSuccess = (posts: IPost[]) => ({
20 | type: GET_FEED_SUCCESS,
21 | payload: posts
22 | });
23 |
24 | export const createPostStart = (post: FormData) => ({
25 | type: CREATE_POST_START,
26 | payload: post
27 | });
28 |
29 | export const createPostSuccess = (post: IPost) => ({
30 | type: CREATE_POST_SUCCESS,
31 | payload: post
32 | });
33 |
34 | export const updateFeedPost = (post: IPost) => ({
35 | type: UPDATE_FEED_POST,
36 | payload: post
37 | });
38 |
39 | export const updatePostLikes = (postID: string, state: boolean, likesCount: number) => ({
40 | type: UPDATE_POST_LIKES,
41 | payload: { postID, state, likesCount }
42 | });
43 |
44 | export const deleteFeedPost = (postID: string) => ({
45 | type: DELETE_FEED_POST,
46 | payload: postID
47 | });
48 |
49 | export const clearNewsFeed = () => ({
50 | type: CLEAR_FEED
51 | });
52 |
53 | export const hasNewFeed = (bool = true) => ({
54 | type: HAS_NEW_FEED,
55 | payload: bool
56 | });
57 |
58 | export type TNewsFeedActionType =
59 | | ReturnType
60 | | ReturnType
61 | | ReturnType
62 | | ReturnType
63 | | ReturnType
64 | | ReturnType
65 | | ReturnType
66 | | ReturnType
67 | | ReturnType;
--------------------------------------------------------------------------------
/server/src/storage/filestorage.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config/config';
2 | import { Storage } from '@google-cloud/storage';
3 | import { format } from 'util';
4 |
5 | const storage = new Storage(config.gCloudStorage);
6 |
7 | const bucket = storage.bucket(process.env.FIREBASE_STORAGE_BUCKET_URL);
8 |
9 | const uploadImageToStorage = (file) => {
10 | return new Promise((resolve, reject) => {
11 | if (!file) {
12 | reject('No image file');
13 | }
14 | let newFileName = `${file.originalname}`;
15 |
16 | let fileUpload = bucket.file(newFileName);
17 |
18 | const blobStream = fileUpload.createWriteStream({
19 | metadata: {
20 | contentType: file.mimetype
21 | }
22 | });
23 |
24 | blobStream.on('error', (err) => {
25 | console.log(err);
26 | reject('Something is wrong! Unable to upload at the moment.');
27 | });
28 |
29 | blobStream.on('finish', () => {
30 | // The public URL can be used to directly access the file via HTTP.
31 | const url = format(`https://storage.googleapis.com/${bucket.name}/${fileUpload.name}`);
32 | resolve(url);
33 | });
34 |
35 | blobStream.end(file.buffer);
36 | });
37 | }
38 |
39 | const deleteImageFromStorage = (...images) => {
40 | return new Promise(async (resolve, reject) => {
41 | if (images.length === 0) {
42 | return reject('Images to delete not provided.');
43 | }
44 |
45 | try {
46 | images.map(async (image) => {
47 | const spl = image.split('/');
48 | const filename = spl[spl.length - 1];
49 |
50 | await bucket.file(filename).delete();
51 | });
52 |
53 | resolve('Successfully deleted.');
54 | } catch (e) {
55 | console.log(e);
56 | reject('Cannot delete images.');
57 | }
58 | });
59 | }
60 |
61 | export { uploadImageToStorage, deleteImageFromStorage };
62 |
63 |
--------------------------------------------------------------------------------
/frontend/src/components/main/FollowButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { CheckOutlined, UserAddOutlined } from "@ant-design/icons";
2 | import { useEffect, useState } from "react";
3 | import { useDidMount } from "~/hooks";
4 | import { followUser, unfollowUser } from "~/services/api";
5 |
6 | interface IProps {
7 | isFollowing: boolean;
8 | userID: string;
9 | size?: string;
10 | }
11 |
12 | const FollowButton: React.FC = (props) => {
13 | const [isFollowing, setIsFollowing] = useState(props.isFollowing);
14 | const [isLoading, setLoading] = useState(false);
15 | const didMount = useDidMount();
16 |
17 | useEffect(() => {
18 | setIsFollowing(props.isFollowing);
19 | }, [props.isFollowing])
20 |
21 | const dispatchFollow = async () => {
22 | try {
23 | setLoading(true);
24 | if (isFollowing) {
25 | const result = await unfollowUser(props.userID);
26 | didMount && setIsFollowing(result.state);
27 | } else {
28 | const result = await followUser(props.userID);
29 | didMount && setIsFollowing(result.state);
30 | }
31 |
32 | didMount && setLoading(false);
33 | } catch (e) {
34 | didMount && setLoading(false);
35 | console.log(e);
36 | }
37 | };
38 |
39 | return (
40 |
55 | );
56 | };
57 |
58 | export default FollowButton;
59 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `yarn start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `yarn test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `yarn build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `yarn eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/frontend/src/pages/home/SideMenu.tsx:
--------------------------------------------------------------------------------
1 | import { StarOutlined, TeamOutlined } from "@ant-design/icons";
2 | import { Link } from "react-router-dom";
3 | import { Avatar } from "~/components/shared";
4 |
5 | interface IProps {
6 | username: string;
7 | profilePicture?: string;
8 | }
9 |
10 | const SideMenu: React.FC = ({ username, profilePicture }) => {
11 | return (
12 |
13 | -
14 |
15 |
16 |
My Profile
17 |
18 |
19 | -
20 |
21 |
22 |
Following
23 |
24 |
25 | -
26 |
27 |
28 |
Followers
29 |
30 |
31 | -
32 |
33 |
34 |
Bookmarks
35 |
36 |
37 |
38 | )
39 | };
40 |
41 | export default SideMenu;
42 |
--------------------------------------------------------------------------------
/server/src/storage/cloudinary.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config/config';
2 | import { AdminApiOptions, v2 as cloudinaryV2 } from 'cloudinary';
3 | import Multer from 'multer';
4 |
5 | cloudinaryV2.config(config.cloudinary);
6 |
7 | export const multer = Multer({
8 | dest: 'uploads/',
9 | limits: {
10 | fileSize: 2 * 1024 * 1024 // no larger than 2mb
11 | }
12 | });
13 |
14 | export const uploadImageToStorage = (file: File | File[], folder: string) => {
15 | if (file) {
16 | return new Promise(async (resolve, reject) => {
17 | const opts: AdminApiOptions = {
18 | folder,
19 | resource_type: 'auto',
20 | overwrite: true,
21 | quality: 'auto'
22 | };
23 |
24 | if (Array.isArray(file)) {
25 | const req = file.map((img: any) => {
26 | return cloudinaryV2.uploader.upload(img.path, opts);
27 | });
28 |
29 | try {
30 | const result = await Promise.all(req);
31 | resolve(result);
32 | } catch (err) {
33 | reject(err);
34 | }
35 | } else {
36 | try {
37 | const result = await cloudinaryV2.uploader.upload((file as any).path, opts);
38 | resolve(result);
39 | } catch (err) {
40 | reject(err);
41 | }
42 | }
43 | });
44 | }
45 | }
46 |
47 | export const deleteImageFromStorage = (publicID: string | string[]) => {
48 | if (publicID) {
49 | return new Promise(async (resolve, reject) => {
50 | if (Array.isArray(publicID)) {
51 | try {
52 | await cloudinaryV2.api.delete_resources(publicID);
53 | resolve({ state: true });
54 | } catch (err) {
55 | reject(err);
56 | }
57 | } else {
58 | try {
59 | await cloudinaryV2.uploader.destroy(publicID, { invalidate: true });
60 | resolve({ state: true });
61 | } catch (err) {
62 | reject(err);
63 | }
64 | }
65 | });
66 | }
67 | }
--------------------------------------------------------------------------------
/frontend/src/styles/utils/utils.css:
--------------------------------------------------------------------------------
1 | .excerpt-text {
2 | display: -webkit-box;
3 | overflow : hidden;
4 | text-overflow: ellipsis;
5 | -webkit-line-clamp: number_of_lines_you_want;
6 | -webkit-box-orient: vertical;
7 | }
8 |
9 | .animate-loader {
10 | width: 10px;
11 | height: 10px;
12 | border-radius: 50%;
13 | animation-duration: .5s;
14 | animation-iteration-count: infinite;
15 | animation-timing-function: ease;
16 | animation-direction: alternate;
17 |
18 | &:nth-of-type(1) {
19 | animation-delay: .2s;
20 | }
21 |
22 | &:nth-of-type(2) {
23 | animation-delay: .5s;
24 | }
25 |
26 | &:nth-of-type(3) {
27 | animation-delay: .8s;
28 | }
29 | }
30 |
31 | .animate-loader-dark {
32 | @apply bg-indigo-700;
33 | animation-name: blink-dark;
34 |
35 | }
36 |
37 | .animate-loader-light {
38 | @apply bg-white;
39 | animation-name: blink-light;
40 | }
41 |
42 | @keyframes blink-dark {
43 | to {
44 | background: #a29afc;
45 | }
46 | }
47 |
48 | @keyframes blink-light {
49 | to {
50 | opacity: .3;
51 | }
52 | }
53 |
54 | .Toastify {
55 | z-index: 9999;
56 | }
57 |
58 | .animate-fade {
59 | animation: animate-fade .5s ease;
60 | }
61 |
62 | @keyframes animate-fade {
63 | 0% {
64 | opacity: 0;
65 | }
66 | 100% {
67 | opacity: 1;
68 | }
69 | }
70 |
71 | .fade-enter {
72 | max-height: 0;
73 | opacity: 0;
74 | }
75 |
76 | .fade-enter-active {
77 | max-height: 500px;
78 | opacity: 1;
79 | transition: all .5s;
80 | }
81 |
82 | .fade-exit {
83 | max-height: 500px;
84 | opacity: 1;
85 | }
86 |
87 | .fade-exit-active {
88 | max-height: 0;
89 | opacity: 0;
90 | transition: all .5s;
91 | }
92 |
93 | .social-login-divider {
94 | position: relative;
95 | display: block;
96 | width: 100%;
97 | text-align: center;
98 | @apply text-sm;
99 | @apply text-gray-400;
100 |
101 | &:after,
102 | &:before {
103 | content: '';
104 | position: absolute;
105 | top: 0;
106 | bottom: 0;
107 | margin: auto 0;
108 | width: 45%;
109 | height: 1px;
110 | @apply bg-gray-200;
111 | }
112 |
113 | &:after {
114 | right: 0;
115 | }
116 |
117 | &:before {
118 | left: 0;
119 | }
120 | }
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "node ./build/server.js",
7 | "start": "nodemon --exec ts-node --files -r tsconfig-paths/register src/server.ts",
8 | "build": "rimraf ./build && tsc && ef-tspm",
9 | "postinstall": "rimraf ./build && tsc && ef-tspm"
10 | },
11 | "engines": {
12 | "node": "12.18.1"
13 | },
14 | "dependencies": {
15 | "@google-cloud/storage": "^5.7.2",
16 | "bad-words": "^3.0.4",
17 | "bcrypt": "^5.0.0",
18 | "cloudinary": "^1.25.0",
19 | "connect-mongo": "^3.2.0",
20 | "cookie-parser": "~1.4.4",
21 | "cors": "^2.8.5",
22 | "cross-env": "^7.0.3",
23 | "csurf": "^1.11.0",
24 | "debug": "~2.6.9",
25 | "express": "^4.16.4",
26 | "express-rate-limit": "^5.2.3",
27 | "express-session": "^1.17.1",
28 | "helmet": "^4.2.0",
29 | "hpp": "^0.2.3",
30 | "http-errors": "~1.6.3",
31 | "jade": "~1.11.0",
32 | "joi": "^17.3.0",
33 | "jsonwebtoken": "^8.5.1",
34 | "lodash.omit": "^4.5.0",
35 | "mongoose": "^5.11.5",
36 | "morgan": "~1.9.1",
37 | "multer": "^1.4.2",
38 | "node-fetch": "^2.6.1",
39 | "passport": "^0.4.1",
40 | "passport-facebook": "^3.0.0",
41 | "passport-github": "^1.1.0",
42 | "passport-google-oauth2": "^0.2.0",
43 | "passport-local": "^1.0.0",
44 | "socket.io": "^3.0.4"
45 | },
46 | "devDependencies": {
47 | "@ef-carbon/tspm": "^2.2.5",
48 | "@types/bad-words": "^3.0.0",
49 | "@types/bcrypt": "^3.0.0",
50 | "@types/connect-mongo": "^3.1.3",
51 | "@types/csurf": "^1.11.0",
52 | "@types/express": "^4.17.11",
53 | "@types/express-session": "^1.17.0",
54 | "@types/hpp": "^0.2.1",
55 | "@types/http-errors": "^1.8.0",
56 | "@types/morgan": "^1.9.2",
57 | "@types/multer": "^1.4.5",
58 | "@types/node": "^14.14.25",
59 | "@types/passport": "^1.0.5",
60 | "@types/passport-facebook": "^2.1.10",
61 | "@types/passport-github": "^1.1.5",
62 | "@types/passport-google-oauth2": "^0.1.3",
63 | "@types/passport-local": "^1.0.33",
64 | "@types/socket.io": "^2.1.13",
65 | "clean-css": "^4.2.3",
66 | "dotenv": "^8.2.0",
67 | "mquery": "^3.2.3",
68 | "nodemon": "^2.0.6",
69 | "rimraf": "^3.0.2",
70 | "ts-node": "^9.1.1",
71 | "tsconfig-paths": "^3.9.0",
72 | "typescript": "^4.1.3"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Comments/CommentList.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingOutlined } from "@ant-design/icons";
2 | import React, { lazy, Suspense, useEffect, useState } from "react";
3 | import { useDispatch } from "react-redux";
4 | import { CSSTransition, TransitionGroup } from "react-transition-group";
5 | import { useDidMount, useModal } from "~/hooks";
6 | import { setTargetComment } from "~/redux/action/helperActions";
7 | import { IComment } from "~/types/types";
8 | import CommentItem from "./CommentItem";
9 |
10 | const DeleteCommentModal = lazy(() => import('~/components/main/Modals/DeleteCommentModal'))
11 |
12 | interface IProps {
13 | comments: IComment[];
14 | updateCommentCallback?: (comment: IComment) => void;
15 | }
16 |
17 | const CommentList: React.FC = ({ comments, updateCommentCallback }) => {
18 | const didMount = useDidMount();
19 | const dispatch = useDispatch();
20 | const [replies, setReplies] = useState(comments);
21 | const { isOpen, closeModal, openModal } = useModal();
22 |
23 | useEffect(() => {
24 | didMount && setReplies(comments);
25 | // eslint-disable-next-line react-hooks/exhaustive-deps
26 | }, [comments]);
27 |
28 | const deleteSuccessCallback = (comment: IComment) => {
29 | if (didMount) {
30 | (updateCommentCallback) && updateCommentCallback(comment); // For updating the base/parent comment
31 | dispatch(setTargetComment(null));
32 | setReplies(oldComments => oldComments.filter((cmt) => cmt.id !== comment.id));
33 | }
34 | }
35 |
36 | return (
37 |
38 | {replies.map(comment => (
39 |
44 |
45 |
46 | ))}
47 | {/* ---- DELETE MODAL ---- */}
48 | }>
49 | {isOpen && (
50 |
55 | )}
56 |
57 |
58 | );
59 | };
60 |
61 | export default CommentList;
62 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/ThemeToggler.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { setTheme } from '~/redux/action/settingsActions';
4 | import { IRootReducer } from '~/types/types';
5 |
6 | const ThemeToggler = () => {
7 | const { theme } = useSelector((state: IRootReducer) => ({ theme: state.settings.theme }));
8 | const dispatch = useDispatch();
9 |
10 | useEffect(() => {
11 | const root = document.documentElement;
12 |
13 | if (theme === 'dark') {
14 | root.classList.add('dark');
15 | } else {
16 | root.classList.remove('dark');
17 | }
18 | }, [theme]);
19 |
20 | const onThemeChange = (e: React.ChangeEvent) => {
21 | if (e.target.checked) {
22 | dispatch(setTheme('dark'));
23 | } else {
24 | dispatch(setTheme('light'));
25 | }
26 | }
27 |
28 | return (
29 |
57 | );
58 | };
59 |
60 | export default ThemeToggler;
--------------------------------------------------------------------------------
/frontend/src/constants/actionType.ts:
--------------------------------------------------------------------------------
1 | // ---- AUTH CONSTANTS
2 | export const LOGIN_START = 'LOGIN_START';
3 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
4 | export const CHECK_SESSION = 'CHECK_SESSION';
5 | export const LOGOUT_START = 'LOGOUT_START';
6 | export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
7 | export const REGISTER_START = 'REGISTER_START';
8 | export const REGISTER_SUCCESS = 'REGISTER_SUCCESS';
9 |
10 | // ---- ERROR CONSTANTS
11 | export const SET_AUTH_ERR_MSG = 'SET_AUTH_ERR_MSG';
12 | export const CLEAR_AUTH_ERR_MSG = 'CLEAR_AUTH_ERR_MSG';
13 | export const SET_PROFILE_ERR_MSG = 'SET_PROFILE_ERR_MSG';
14 | export const SET_NEWSFEED_ERR_MSG = 'SET_NEWSFEED_ERR_MSG';
15 |
16 | // ---- LOADING CONSTANTS
17 | export const SET_AUTH_LOADING = 'SET_AUTH_LOADING';
18 | export const SET_CREATE_POST_LOADING = 'SET_CREATE_POST_LOADING';
19 | export const SET_GET_USER_LOADING = 'SET_GET_USER_LOADING';
20 | export const SET_GET_FEED_LOADING = 'SET_GET_FEED_LOADING';
21 |
22 | // ---- FEED CONSTANTS
23 | export const GET_FEED_START = 'GET_FEED_START';
24 | export const GET_FEED_SUCCESS = 'GET_FEED_SUCCESS';
25 | export const CREATE_POST_START = 'CREATE_POST_START';
26 | export const CREATE_POST_SUCCESS = 'CREATE_POST_SUCCESS';
27 | export const UPDATE_FEED_POST = 'UPDATE_FEED_POST';
28 | export const DELETE_FEED_POST = 'DELETE_FEED_POST';
29 | export const UPDATE_USER_POST = 'UPDATE_USER_POST';
30 | export const CLEAR_FEED = 'CLEAR_FEED';
31 | export const HAS_NEW_FEED = 'HAS_NEW_FEED';
32 | export const UPDATE_POST_LIKES = 'UPDATE_POST_LIKES';
33 |
34 | // ---- PROFILE CONSTANTS
35 | export const UPDATE_PROFILE_INFO = 'UPDATE_PROFILE_INFO';
36 | export const UPDATE_PROFILE_PICTURE = 'UPDATE_PROFILE_PICTURE';
37 | export const UPDATE_COVER_PHOTO = 'UPDATE_COVER_PHOTO';
38 | export const UPDATE_AUTH_PICTURE = 'UPDATE_AUTH_PICTURE';
39 | export const GET_USER_START = 'GET_USER_START';
40 | export const GET_USER_SUCCESS = 'GET_USER_SUCCESS';
41 |
42 | // ---- CHAT CONSTANTS
43 | export const INITIATE_CHAT = 'INITIATE_CHAT';
44 | export const MINIMIZE_CHAT = 'MINIMIZE_CHAT';
45 | export const CLOSE_CHAT = 'CLOSE_CHAT';
46 | export const CLEAR_CHAT = 'CLEAR_CHAT';
47 | export const GET_MESSAGES_SUCCESS = 'GET_MESSAGES_SUCCESS';
48 | export const NEW_MESSAGE_ARRIVED = 'NEW_MESSAGE_ARRIVED';
49 |
50 | // ---- SETTINGS CONSTANTS
51 | export const SET_THEME = 'SET_THEME';
52 |
53 | // ---- HELPERS CONSTANTS
54 | export const SET_TARGET_COMMENT = 'SET_TARGET_COMMENT_ID';
55 | export const SET_TARGET_POST = 'SET_TARGET_POST';
56 |
57 | // ---- MODAL CONSTANTS
58 | export const SHOW_MODAL = 'SHOW_MODAL';
59 | export const HIDE_MODAL = 'HIDE_MODAL';
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/newsFeedReducer.ts:
--------------------------------------------------------------------------------
1 | import { CLEAR_FEED, CREATE_POST_SUCCESS, DELETE_FEED_POST, GET_FEED_SUCCESS, HAS_NEW_FEED, UPDATE_FEED_POST, UPDATE_POST_LIKES } from "~/constants/actionType";
2 | import { INewsFeed, IPost } from "~/types/types";
3 | import { TNewsFeedActionType } from "../action/feedActions";
4 |
5 | const initState: INewsFeed = {
6 | items: [],
7 | offset: 0,
8 | hasNewFeed: false
9 | };
10 |
11 | const newsFeedReducer = (state = initState, action: TNewsFeedActionType) => {
12 | switch (action.type) {
13 | case GET_FEED_SUCCESS:
14 | return {
15 | ...state,
16 | items: [...state.items, ...action.payload],
17 | offset: state.offset + 1
18 | };
19 | case CREATE_POST_SUCCESS:
20 | return {
21 | ...state,
22 | items: [action.payload, ...state.items]
23 | };
24 | case CLEAR_FEED:
25 | return initState;
26 | case UPDATE_FEED_POST:
27 | return {
28 | ...state,
29 | items: state.items.map((post: IPost) => {
30 | if (post.id === action.payload.id) {
31 | return {
32 | ...post,
33 | ...action.payload
34 | };
35 | }
36 | return post;
37 | })
38 | }
39 | case UPDATE_POST_LIKES:
40 | return {
41 | ...state,
42 | items: state.items.map((post: IPost) => {
43 | if (post.id === action.payload.postID) {
44 | return {
45 | ...post,
46 | isLiked: action.payload.state,
47 | likesCount: action.payload.likesCount
48 | };
49 | }
50 | return post;
51 | })
52 | }
53 | case DELETE_FEED_POST:
54 | return {
55 | ...state,
56 | // eslint-disable-next-line array-callback-return
57 | items: state.items.filter((post: IPost) => {
58 | if (post.id !== action.payload) {
59 | return post;
60 | }
61 | })
62 | }
63 | case HAS_NEW_FEED:
64 | return {
65 | ...state,
66 | hasNewFeed: action.payload
67 | }
68 | default:
69 | return state;
70 | }
71 | };
72 |
73 | export default newsFeedReducer;
74 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Modals/ComposeMessageModal.tsx:
--------------------------------------------------------------------------------
1 | import { CloseOutlined, FormOutlined } from '@ant-design/icons';
2 | import Modal from 'react-modal';
3 | import { useDispatch } from 'react-redux';
4 | import { useHistory } from 'react-router-dom';
5 | import { SearchInput } from '~/components/shared';
6 | import { initiateChat } from '~/redux/action/chatActions';
7 | import { IUser } from '~/types/types';
8 |
9 | interface IProps {
10 | isOpen: boolean;
11 | onAfterOpen?: () => void;
12 | closeModal: () => void;
13 | openModal: () => void;
14 | userID: string;
15 | }
16 |
17 | Modal.setAppElement('#root');
18 |
19 | const ComposeMessageModal: React.FC = (props) => {
20 | const dispatch = useDispatch();
21 | const history = useHistory();
22 |
23 | const clickSearchResultCallback = (user: IUser) => {
24 | if (props.userID === user.id) return;
25 | dispatch(initiateChat(user));
26 | props.closeModal();
27 |
28 | if (window.screen.width < 800) {
29 | history.push(`/chat/${user.username}`);
30 | }
31 | }
32 |
33 | return (
34 |
43 |
44 |
48 |
49 |
50 |
51 |
52 | Compose Message
53 |
54 |
55 |
To:
56 |
62 |
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | export default ComposeMessageModal;
70 |
--------------------------------------------------------------------------------
/frontend/src/helpers/cropImage.tsx:
--------------------------------------------------------------------------------
1 | const createImage = (url: string): Promise =>
2 | new Promise((resolve, reject) => {
3 | const image = new Image()
4 | image.addEventListener('load', () => resolve(image))
5 | image.addEventListener('error', error => reject(error))
6 | image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
7 | image.src = url
8 | })
9 |
10 | function getRadianAngle(degreeValue: number): number {
11 | return (degreeValue * Math.PI) / 180
12 | }
13 |
14 | interface IPixelCrop {
15 | width: number;
16 | height: number;
17 | x: number;
18 | y: number;
19 | }
20 |
21 | export default async function getCroppedImg(imageSrc: string, pixelCrop: IPixelCrop | null, rotation = 0): Promise {
22 | const image: HTMLImageElement = await createImage(imageSrc)
23 | const canvas = document.createElement('canvas')
24 | const ctx: CanvasRenderingContext2D | null = canvas.getContext('2d')
25 |
26 | const maxSize = Math.max(image.width, image.height)
27 | const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
28 |
29 | if (!ctx || !pixelCrop) return;
30 | // set each dimensions to double largest dimension to allow for a safe area for the
31 | // image to rotate in without being clipped by canvas context
32 | canvas.width = safeArea
33 | canvas.height = safeArea
34 |
35 | // translate canvas context to a central location on image to allow rotating around the center.
36 | ctx.translate(safeArea / 2, safeArea / 2)
37 | ctx.rotate(getRadianAngle(rotation))
38 | ctx.translate(-safeArea / 2, -safeArea / 2)
39 |
40 | // draw rotated image and store data.
41 | ctx.drawImage(
42 | image,
43 | safeArea / 2 - image.width * 0.5,
44 | safeArea / 2 - image.height * 0.5
45 | )
46 | const data = ctx.getImageData(0, 0, safeArea, safeArea)
47 |
48 | // set canvas width to final desired crop size - this will clear existing context
49 | canvas.width = pixelCrop.width
50 | canvas.height = pixelCrop.height
51 |
52 | // paste generated rotate image with correct offsets for x,y crop values.
53 | ctx.putImageData(
54 | data,
55 | Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
56 | Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
57 | )
58 |
59 | // As Base64 string
60 | // return canvas.toDataURL('image/jpeg');
61 |
62 | // As a blob
63 | return new Promise(resolve => {
64 | canvas.toBlob(file => {
65 | resolve({ url: URL.createObjectURL(file), blob: file });
66 | }, 'image/jpeg')
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
31 |
32 |
41 | Foodie | Social Network
42 |
43 |
44 |
45 |
46 |
47 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useFileHandler.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { toast } from 'react-toastify';
3 | import { IFileHandler, IImage } from '~/types/types';
4 |
5 | const useFileHandler = (type = "multiple", initState: T): IFileHandler => {
6 | const [imageFile, setImageFile] = useState(initState);
7 | const [isFileLoading, setFileLoading] = useState(false);
8 |
9 | const removeImage = (id: string) => {
10 | if (!Array.isArray(imageFile)) return;
11 |
12 | const items = imageFile.filter(item => item.id !== id);
13 |
14 | setImageFile(items as T);
15 | };
16 |
17 | const clearFiles = () => {
18 | setImageFile(initState as T);
19 | }
20 |
21 | const onFileChange = (event: React.ChangeEvent, callback?: (file?: IImage) => void) => {
22 | if (!event.target.files) return;
23 | if ((event.target.files.length + (imageFile as IImage[]).length) > 5) {
24 | return toast.error('Maximum of 5 photos per post allowed.', { hideProgressBar: true });
25 | }
26 |
27 | // TODO === FILTER OUT DUPLICATE IMAGES
28 |
29 | const val = event.target.value;
30 | const img = event.target.files[0] as File;
31 |
32 | if (!img) return;
33 |
34 | const size = img.size / 1024 / 1024;
35 | const regex = /(\.jpg|\.jpeg|\.png)$/i;
36 |
37 | setFileLoading(true);
38 | if (!regex.exec(val)) {
39 | toast.error('File type must be JPEG or PNG', { hideProgressBar: true });
40 | setFileLoading(false);
41 | } else if (size > 2) {
42 | toast.error('File size exceeded 2mb', { hideProgressBar: true });
43 | setFileLoading(false);
44 | } else if (type === 'single') {
45 | const file = event.target.files[0] as File;
46 | const url = URL.createObjectURL(file);
47 | setImageFile({
48 | file,
49 | url,
50 | id: file.name
51 | } as T);
52 | if (callback) callback(imageFile as IImage);
53 | } else {
54 | Array.from(event.target.files).forEach((file) => {
55 | const url = URL.createObjectURL(file);
56 | setImageFile((oldFiles) => ([...oldFiles as any, {
57 | file,
58 | url,
59 | id: file.name
60 | }] as T));
61 | });
62 | if (callback) callback(imageFile as IImage);
63 | setFileLoading(false);
64 | }
65 | };
66 |
67 | return {
68 | imageFile,
69 | setImageFile,
70 | isFileLoading,
71 | onFileChange,
72 | removeImage,
73 | clearFiles
74 | };
75 | };
76 |
77 | export default useFileHandler;
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@ant-design/icons": "^4.3.0",
7 | "@tailwindcss/aspect-ratio": "^0.2.0",
8 | "@tailwindcss/forms": "^0.2.1",
9 | "@tailwindcss/postcss7-compat": "^2.0.2",
10 | "@tailwindcss/typography": "^0.3.1",
11 | "@testing-library/jest-dom": "^5.11.4",
12 | "@testing-library/react": "^11.1.0",
13 | "@testing-library/user-event": "^12.1.10",
14 | "@types/jest": "^26.0.15",
15 | "@types/node": "^12.0.0",
16 | "@types/react": "^16.9.53",
17 | "@types/react-dom": "^16.9.8",
18 | "@types/react-redux": "^7.1.12",
19 | "@types/react-router-dom": "^5.1.6",
20 | "@types/redux": "^3.6.0",
21 | "@types/redux-logger": "^3.0.8",
22 | "autoprefixer": "^9",
23 | "axios": "^0.21.0",
24 | "dayjs": "^1.9.8",
25 | "history": "4.10.1",
26 | "lodash.debounce": "^4.0.8",
27 | "postcss": "^7",
28 | "react": "^17.0.1",
29 | "react-content-loader": "^6.0.1",
30 | "react-dom": "^17.0.1",
31 | "react-easy-crop": "^3.3.1",
32 | "react-image-lightbox": "^5.1.1",
33 | "react-infinite-scroll-hook": "^3.0.0",
34 | "react-modal": "^3.12.1",
35 | "react-redux": "^7.2.2",
36 | "react-router-dom": "^5.2.0",
37 | "react-scripts": "4.0.1",
38 | "react-toastify": "^6.2.0",
39 | "react-transition-group": "^4.4.1",
40 | "redux": "^4.0.5",
41 | "redux-logger": "^3.0.6",
42 | "redux-saga": "^1.1.3",
43 | "socket.io": "^3.0.4",
44 | "socket.io-client": "^3.0.4",
45 | "tailwindcss": "npm:@tailwindcss/postcss7-compat",
46 | "typescript": "^4.0.3",
47 | "web-vitals": "^0.2.4"
48 | },
49 | "scripts": {
50 | "start": "craco start",
51 | "build": "craco build",
52 | "test": "craco test",
53 | "eject": "react-scripts eject"
54 | },
55 | "eslintConfig": {
56 | "extends": [
57 | "react-app",
58 | "react-app/jest"
59 | ]
60 | },
61 | "browserslist": {
62 | "production": [
63 | ">0.2%",
64 | "not dead",
65 | "not op_mini all"
66 | ],
67 | "development": [
68 | "last 1 chrome version",
69 | "last 1 firefox version",
70 | "last 1 safari version"
71 | ]
72 | },
73 | "proxy": "http://localhost:9000",
74 | "devDependencies": {
75 | "@craco/craco": "^6.0.0",
76 | "@neojp/tailwindcss-important-variant": "^1.0.1",
77 | "@tailwindcss/custom-forms": "^0.2.1",
78 | "@tailwindcss/jit": "^0.1.1",
79 | "@types/lodash.debounce": "^4.0.6",
80 | "@types/react-modal": "^3.10.6",
81 | "@types/react-transition-group": "^4.4.0",
82 | "@types/socket.io": "^2.1.12",
83 | "node-sass": "4.14.1",
84 | "postcss-extend": "^1.0.5",
85 | "postcss-import": "12.0.1",
86 | "postcss-loader": "^4.1.0",
87 | "postcss-nested": "4.2.3",
88 | "react-app-rewire-alias": "^0.1.9",
89 | "tailwindcss-children": "^2.1.0"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Modals/LogoutModal.tsx:
--------------------------------------------------------------------------------
1 | import { CloseOutlined } from '@ant-design/icons';
2 | import Modal from 'react-modal';
3 | import { IError } from '~/types/types';
4 |
5 | interface IProps {
6 | isOpen: boolean;
7 | onAfterOpen?: () => void;
8 | closeModal: () => void;
9 | openModal: () => void;
10 | dispatchLogout: () => void;
11 | isLoggingOut: boolean;
12 | error: IError;
13 | }
14 |
15 | Modal.setAppElement('#root');
16 |
17 | const LogoutModal: React.FC = (props) => {
18 | const onCloseModal = () => {
19 | if (!props.isLoggingOut) {
20 | props.closeModal();
21 | }
22 | }
23 |
24 | return (
25 |
34 |
35 |
39 |
40 |
41 | {props.error && (
42 |
43 | {props.error?.error?.message || 'Unable to process your request.'}
44 |
45 | )}
46 |
47 |
Confirm Logout
48 |
Are you sure you want to logout?
49 |
50 |
57 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | export default LogoutModal;
73 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 | import { useEffect, useState } from "react";
3 | import { useDispatch } from "react-redux";
4 | import { Route, Router, Switch } from "react-router-dom";
5 | import { Slide, ToastContainer } from 'react-toastify';
6 | import 'react-toastify/dist/ReactToastify.css';
7 | import { Chats } from '~/components/main';
8 | import { NavBar, Preloader } from "~/components/shared";
9 | import * as ROUTE from "~/constants/routes";
10 | import * as pages from '~/pages';
11 | import { ProtectedRoute, PublicRoute } from "~/routers";
12 | import { loginSuccess } from "./redux/action/authActions";
13 | import { checkAuthSession } from "./services/api";
14 | import socket from './socket/socket';
15 |
16 | export const history = createBrowserHistory();
17 |
18 | function App() {
19 | const [isCheckingSession, setCheckingSession] = useState(true);
20 | const dispatch = useDispatch();
21 | const isNotMobile = window.screen.width >= 800;
22 |
23 | useEffect(() => {
24 | (async () => {
25 | try {
26 | const { auth } = await checkAuthSession();
27 |
28 | dispatch(loginSuccess(auth));
29 |
30 | socket.on('connect', () => {
31 | socket.emit('userConnect', auth.id);
32 | console.log('Client connected to socket.');
33 | });
34 |
35 | // Try to reconnect again
36 | socket.on('error', function () {
37 | socket.emit('userConnect', auth.id);
38 | });
39 |
40 | setCheckingSession(false);
41 | } catch (e) {
42 | console.log('ERROR', e);
43 | setCheckingSession(false);
44 | }
45 | })();
46 | // eslint-disable-next-line react-hooks/exhaustive-deps
47 | }, []);
48 |
49 | return isCheckingSession ? (
50 |
51 | ) : (
52 |
53 |
54 |
62 |
63 |
64 |
65 |
66 |
67 | } />
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | {isNotMobile && }
76 |
77 |
78 | );
79 | }
80 |
81 | export default App;
82 |
--------------------------------------------------------------------------------
/frontend/src/pages/profile/Header/CoverPhotoOverlay.tsx:
--------------------------------------------------------------------------------
1 | import { CameraOutlined, CloseOutlined } from "@ant-design/icons";
2 | import Loader from "~/components/shared/Loader";
3 | import { IFileHandler, IImage } from "~/types/types";
4 |
5 | interface IProps {
6 | coverPhotoOverlayRef: React.RefObject;
7 | coverPhoto: IFileHandler;
8 | isUploadingCoverPhoto: boolean;
9 | isOwnProfile: boolean;
10 | handleSaveCoverPhoto: () => void;
11 | }
12 |
13 | const CoverPhotoOverlay: React.FC = (props) => {
14 | return (
15 |
19 |
27 | {props.isOwnProfile && (
28 | <>
29 | {props.isUploadingCoverPhoto ?
: (
30 | <>
31 | {props.coverPhoto.imageFile.file ? (
32 |
33 |
36 |
37 |
43 |
44 |
45 |
46 | ) : (
47 |
55 |
56 | )}
57 | >
58 | )}
59 | >
60 | )}
61 |
62 |
63 | );
64 | };
65 |
66 | export default CoverPhotoOverlay;
67 |
--------------------------------------------------------------------------------
/server/src/validations/validations.ts:
--------------------------------------------------------------------------------
1 | import { ErrorHandler } from '@/middlewares';
2 | import { NextFunction, Request, Response } from 'express';
3 | import Joi, { Schema } from 'joi';
4 |
5 | const email = Joi
6 | .string()
7 | .email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
8 | .required()
9 | .messages({
10 | 'string.base': `Email should be a type of 'text'`,
11 | 'string.empty': `Email cannot be an empty field`,
12 | 'string.min': `Email should have a minimum length of {#limit}`,
13 | 'any.required': `Email is a required field.`
14 | });
15 |
16 | const password = Joi
17 | .string()
18 | .min(8)
19 | .max(50)
20 | .required()
21 | .messages({
22 | 'string.base': `Password should be a type of 'text'`,
23 | 'string.empty': `Password cannot be an empty field`,
24 | 'string.min': `Password should have a minimum length of {#limit}`,
25 | 'any.required': `Password is a required field`
26 | });
27 | const username = Joi
28 | .string()
29 | .required()
30 | .messages({
31 | 'string.base': 'Username should be of type "text"',
32 | 'string.empty': `Username cannot be an empty field`,
33 | 'string.min': `Username should have a minimum length of {#limit}`,
34 | 'any.required': 'Username field is required'
35 | });
36 |
37 | export const schemas = {
38 | loginSchema: Joi.object().keys({
39 | username,
40 | password
41 | }).options({ abortEarly: false }),
42 | registerSchema: Joi.object().keys({
43 | email,
44 | password,
45 | username
46 | }).options({ abortEarly: false }),
47 | createPostSchema: Joi.object().keys({
48 | description: Joi.string(),
49 | photos: Joi.array(),
50 | privacy: Joi.string()
51 | }),
52 | commentSchema: Joi.object().keys({
53 | body: Joi
54 | .string()
55 | .required()
56 | .messages({
57 | 'string.base': 'Comment body should be of type "string"',
58 | 'string.empty': `Comment body cannot be an empty field`,
59 | 'any.required': 'Comment body field is required'
60 | }),
61 | post_id: Joi.string().empty(''),
62 | comment_id: Joi.string().empty('')
63 | }),
64 | editProfileSchema: Joi.object().keys({
65 | firstname: Joi.string().empty(''),
66 | lastname: Joi.string().empty(''),
67 | bio: Joi.string().empty(''),
68 | gender: Joi.string().empty(''),
69 | birthday: Joi.date().empty('')
70 | })
71 | };
72 |
73 | export const validateBody = (schema: Schema) => {
74 | return (req: Request & { value: any }, res: Response, next: NextFunction) => {
75 | const result = schema.validate(req.body);
76 |
77 | if (result.error) {
78 | console.log(result.error);
79 | return next(new ErrorHandler(400, result.error.details[0].message))
80 | } else {
81 | if (!req.value) {
82 | req.value = {}
83 | }
84 | req.value['body'] = result.value;
85 | next();
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/server/src/routes/api/v1/search.ts:
--------------------------------------------------------------------------------
1 | import { makeResponseJson } from '@/helpers/utils';
2 | import { ErrorHandler } from '@/middlewares';
3 | import { Follow, User } from '@/schemas';
4 | import { EPrivacy } from '@/schemas/PostSchema';
5 | import { PostService } from '@/services';
6 | import { NextFunction, Request, Response, Router } from 'express';
7 |
8 | const router = Router({ mergeParams: true });
9 |
10 | router.get(
11 | '/v1/search',
12 | async (req: Request, res: Response, next: NextFunction) => {
13 | try {
14 | const { q, type } = req.query;
15 | const offset = parseInt(req.query.offset as string) || 0;
16 | const limit = parseInt(req.query.limit as string) || 10;
17 | const skip = offset * limit;
18 |
19 | if (!q) return next(new ErrorHandler(400, 'Search query is required.'));
20 |
21 | let result = [];
22 |
23 | if (type === 'posts') {
24 | const posts = await PostService
25 | .getPosts(
26 | req.user,
27 | {
28 | description: {
29 | $regex: q,
30 | $options: 'i'
31 | },
32 | privacy: EPrivacy.public
33 | },
34 | {
35 | sort: { createdAt: -1 },
36 | skip,
37 | limit
38 | }
39 | );
40 |
41 | if (posts.length === 0) {
42 | return next(new ErrorHandler(404, 'No posts found.'));
43 | }
44 |
45 | result = posts;
46 | // console.log(posts);
47 | } else {
48 | const users = await User
49 | .find({
50 | $or: [
51 | { firstname: { $regex: q, $options: 'i' } },
52 | { lastname: { $regex: q, $options: 'i' } },
53 | { username: { $regex: q, $options: 'i' } }
54 | ]
55 | })
56 | .limit(limit)
57 | .skip(skip);
58 |
59 | if (users.length === 0) {
60 | return next(new ErrorHandler(404, 'No users found.'));
61 | }
62 |
63 | const myFollowingDoc = await Follow.find({ user: req.user?._id });
64 | const myFollowing = myFollowingDoc.map(user => user.target);
65 |
66 | const usersResult = users.map((user) => {
67 | return {
68 | ...user.toProfileJSON(),
69 | isFollowing: myFollowing.includes(user.id)
70 | }
71 | });
72 |
73 | result = usersResult;
74 | }
75 |
76 | res.status(200).send(makeResponseJson(result));
77 | } catch (e) {
78 | console.log('CANT PERFORM SEARCH: ', e);
79 | next(e);
80 | }
81 |
82 | }
83 | );
84 |
85 | export default router;
86 |
--------------------------------------------------------------------------------
/frontend/src/pages/post/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { RouteComponentProps } from 'react-router-dom';
3 | import { PostItem, PostModals } from '~/components/main';
4 | import { Loader } from '~/components/shared';
5 | import { useDocumentTitle } from '~/hooks';
6 | import { PageNotFound } from '~/pages';
7 | import { getSinglePost } from '~/services/api';
8 | import { IError, IPost } from '~/types/types';
9 |
10 | const Post: React.FC> = ({ history, match }) => {
11 | const [post, setPost] = useState(null);
12 | const [isLoading, setIsLoading] = useState(false);
13 | const [error, setError] = useState(null);
14 | const { post_id } = match.params;
15 |
16 | useDocumentTitle(`${post?.description} - Foodie` || 'View Post');
17 | useEffect(() => {
18 | fetchPost();
19 | // eslint-disable-next-line react-hooks/exhaustive-deps
20 | }, []);
21 |
22 | const likeCallback = (postID: string, state: boolean, newLikesCount: number) => {
23 | setPost({
24 | ...post,
25 | isLiked: state,
26 | likesCount: newLikesCount
27 | } as IPost);
28 | }
29 |
30 | const updateSuccessCallback = (updatedPost: IPost) => {
31 | setPost({ ...post, ...updatedPost });
32 | }
33 |
34 | const deleteSuccessCallback = () => {
35 | history.push('/');
36 | }
37 |
38 | const fetchPost = async () => {
39 | try {
40 | setIsLoading(true);
41 |
42 | const fetchedPost = await getSinglePost(post_id);
43 | console.log(fetchedPost);
44 | setIsLoading(false);
45 | setPost(fetchedPost);
46 | } catch (e) {
47 | console.log(e);
48 | setIsLoading(false);
49 | setError(e);
50 | }
51 | };
52 |
53 | return (
54 | <>
55 | {(isLoading && !error) && (
56 |
57 |
58 |
59 | )}
60 | {(!isLoading && !error && post) && (
61 |
64 | )}
65 | {(!isLoading && error) && (
66 | <>
67 | {error.status_code === 404 ? (
68 |
69 | ) : (
70 |
71 |
72 | {error?.error?.message || 'Something went wrong :('}
73 |
74 |
75 | )}
76 | >
77 | )}
78 | {/* ----- ALL PSOST MODALS ----- */}
79 |
83 | >
84 | )
85 | };
86 |
87 | export default Post;
88 |
--------------------------------------------------------------------------------
/server/src/app.ts:
--------------------------------------------------------------------------------
1 | import cors from 'cors';
2 | import csurf from 'csurf';
3 | import createDebug from 'debug';
4 | import express from 'express';
5 | import session, { SessionOptions } from 'express-session';
6 | import helmet from 'helmet';
7 | import hpp from 'hpp';
8 | import http, { Server } from 'http';
9 | import createError from 'http-errors';
10 | import logger from 'morgan';
11 | import passport from 'passport';
12 | import config from './config/config';
13 | import initializePassport from './config/passport';
14 | import initializeSocket from './config/socket';
15 | import initializeDB from './db/db';
16 | import errorHandler from './middlewares/error.middleware';
17 | import routers from './routes/createRouter';
18 |
19 | const debug = createDebug('server:server');
20 |
21 | console.log(config);
22 |
23 | class Express {
24 | public app: express.Application;
25 | public server: Server;
26 |
27 | constructor() {
28 | this.app = express();
29 | this.server = http.createServer(this.app);
30 | initializeDB();
31 | this.initializeMiddlewares();
32 | initializeSocket(this.app, this.server);
33 | initializePassport(passport);
34 | }
35 |
36 | private initializeMiddlewares() {
37 | this.app.disable('x-powered-by');
38 | this.app.use(express.json());
39 | this.app.use(express.urlencoded({ extended: true }));
40 | this.app.use(cors(config.cors));
41 | this.app.set('trust proxy', 1);
42 | this.app.use(logger('dev'));
43 | this.app.use(helmet());
44 | this.app.use(hpp());
45 |
46 | this.app.use(session(config.session as SessionOptions));
47 | this.app.use(passport.initialize());
48 | this.app.use(passport.session());
49 | this.app.use('/api', routers);
50 |
51 | // catch 404 and forward to error handler
52 | this.app.use(function (req, res, next) {
53 | next(createError(404));
54 | });
55 |
56 | // error handler
57 | this.app.use(csurf());
58 | this.app.use(errorHandler);
59 | }
60 |
61 | public onError() {
62 | this.server.on('error', (error: NodeJS.ErrnoException) => {
63 | if (error.syscall !== 'listen') {
64 | throw error;
65 | }
66 |
67 | const bind = typeof config.server.port === 'string'
68 | ? 'Pipe ' + config.server.port
69 | : 'Port ' + config.server.port;
70 |
71 | // handle specific listen errors with friendly messages
72 | switch (error.code) {
73 | case 'EACCES':
74 | console.error(bind + ' requires elevated privileges');
75 | process.exit(1);
76 | case 'EADDRINUSE':
77 | console.error(bind + ' is already in use');
78 | process.exit(1);
79 | default:
80 | throw error;
81 | }
82 | })
83 | }
84 |
85 | public onListening() {
86 | this.server.on('listening', () => {
87 | const addr = this.server.address();
88 | const bind = typeof addr === 'string'
89 | ? 'pipe ' + addr
90 | : 'port ' + addr.port;
91 |
92 | debug('Listening on ' + bind);
93 | })
94 | }
95 |
96 | public listen() {
97 | this.server.listen(config.server.port, () => {
98 | console.log(`# Application is listening on port ${config.server.port} #`)
99 | })
100 | }
101 | }
102 |
103 | export default Express;
--------------------------------------------------------------------------------
/frontend/src/components/main/SuggestedPeople/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Link } from "react-router-dom";
3 | import { FollowButton } from "~/components/main";
4 | import { Avatar, UserLoader } from "~/components/shared";
5 | import { SUGGESTED_PEOPLE } from "~/constants/routes";
6 | import { getSuggestedPeople } from "~/services/api";
7 | import { IError, IProfile } from "~/types/types";
8 |
9 | const SuggestedPeople: React.FC = () => {
10 | const [people, setPeople] = useState([]);
11 | const [isLoading, setIsLoading] = useState(false);
12 | const [error, setError] = useState(null);
13 |
14 | useEffect(() => {
15 | (async function () {
16 | try {
17 | setIsLoading(true);
18 | const users = await getSuggestedPeople({ offset: 0, limit: 6 });
19 |
20 | setPeople(users);
21 | setIsLoading(false);
22 | } catch (e) {
23 | setIsLoading(false);
24 | setError(e);
25 | }
26 | })();
27 | }, []);
28 |
29 | return (
30 |
31 |
32 |
Suggested People
33 | See all
34 |
35 | {isLoading && (
36 |
37 |
38 |
39 |
40 |
41 |
42 | )}
43 | {(!isLoading && error) && (
44 |
45 |
46 | {(error as IError)?.error?.message || 'Something went wrong :('}
47 |
48 |
49 | )}
50 | {!error && people.map((user) => (
51 |
52 |
53 |
54 |
55 |
56 |
{user.username}
57 |
58 |
59 |
60 |
65 |
66 |
67 |
68 | ))}
69 |
70 | );
71 | };
72 |
73 | export default SuggestedPeople;
74 |
--------------------------------------------------------------------------------
/frontend/src/components/main/Options/CommentOptions.tsx:
--------------------------------------------------------------------------------
1 | import { DeleteOutlined, EditOutlined, EllipsisOutlined } from '@ant-design/icons';
2 | import { useEffect, useRef, useState } from 'react';
3 | import { useDispatch } from 'react-redux';
4 | import { setTargetComment } from '~/redux/action/helperActions';
5 | import { IComment } from '~/types/types';
6 |
7 | interface IProps {
8 | comment: IComment;
9 | onClickEdit: () => void;
10 | openDeleteModal: () => void;
11 | }
12 |
13 | const CommentOptions: React.FC = (props) => {
14 | const [isOpen, setIsOpen] = useState(false);
15 | const isOpenRef = useRef(isOpen);
16 | const dispatch = useDispatch();
17 |
18 | useEffect(() => {
19 | document.addEventListener('click', handleClickOutside);
20 |
21 | return () => {
22 | document.removeEventListener('click', handleClickOutside);
23 | }
24 | // eslint-disable-next-line react-hooks/exhaustive-deps
25 | }, []);
26 |
27 | useEffect(() => {
28 | isOpenRef.current = isOpen;
29 | }, [isOpen]);
30 |
31 | const handleClickOutside = (e: Event) => {
32 | const option = (e.target as HTMLDivElement).closest(`#comment_${props.comment.id}`);
33 |
34 | if (!option && isOpenRef.current) {
35 | setIsOpen(false);
36 | }
37 | }
38 |
39 | const toggleOpen = () => {
40 | setIsOpen(!isOpen);
41 | }
42 |
43 | const onClickDelete = () => {
44 | dispatch(setTargetComment(props.comment));
45 | props.openDeleteModal();
46 | }
47 |
48 | const onClickEdit = () => {
49 | setIsOpen(false);
50 |
51 | props.onClickEdit();
52 | dispatch(setTargetComment(props.comment));
53 | }
54 |
55 | return (
56 |
84 | );
85 | };
86 |
87 | export default CommentOptions;
88 |
--------------------------------------------------------------------------------
/frontend/src/redux/sagas/authSaga.ts:
--------------------------------------------------------------------------------
1 | import { call, put, select } from "redux-saga/effects";
2 | import { history } from '~/App';
3 | import { CHECK_SESSION, LOGIN_START, LOGOUT_START, REGISTER_START } from "~/constants/actionType";
4 | import { LOGIN } from "~/constants/routes";
5 | import { checkAuthSession, login, logout, register } from "~/services/api";
6 | import socket from "~/socket/socket";
7 | import { IError, IUser } from "~/types/types";
8 | import { loginSuccess, logoutSuccess, registerSuccess } from "../action/authActions";
9 | import { clearChat } from "../action/chatActions";
10 | import { setAuthErrorMessage } from "../action/errorActions";
11 | import { clearNewsFeed } from "../action/feedActions";
12 | import { isAuthenticating } from "../action/loadingActions";
13 |
14 | interface IAuthSaga {
15 | type: string;
16 | payload: any;
17 | }
18 |
19 | function* handleError(e: IError) {
20 | yield put(isAuthenticating(false));
21 |
22 | yield put(setAuthErrorMessage(e));
23 | }
24 |
25 | function* authSaga({ type, payload }: IAuthSaga) {
26 | switch (type) {
27 | case LOGIN_START:
28 | try {
29 | yield put(isAuthenticating(true));
30 | const { auth } = yield call(login, payload.email, payload.password);
31 | socket.emit('userConnect', auth.id);
32 | yield put(clearNewsFeed());
33 | yield put(loginSuccess(auth));
34 | yield put(isAuthenticating(false));
35 | } catch (e) {
36 | console.log(e);
37 |
38 | yield handleError(e);
39 | }
40 | break;
41 | case CHECK_SESSION:
42 | try {
43 | yield put(isAuthenticating(true));
44 | const { auth } = yield call(checkAuthSession);
45 |
46 | console.log('SUCCESS ', auth);
47 | yield put(loginSuccess(auth));
48 | yield put(isAuthenticating(false));
49 | } catch (e) {
50 | yield handleError(e);
51 | }
52 | break;
53 | case LOGOUT_START:
54 | try {
55 | const { auth } = yield select();
56 | yield put(isAuthenticating(true));
57 | yield call(logout);
58 |
59 | payload.callback && payload.callback();
60 |
61 | yield put(logoutSuccess());
62 | yield put(isAuthenticating(false));
63 | yield put(clearNewsFeed());
64 | yield put(clearChat());
65 | history.push(LOGIN);
66 | socket.emit('userDisconnect', auth.id);
67 | } catch (e) {
68 | yield handleError(e);
69 | }
70 | break;
71 | case REGISTER_START:
72 | try {
73 | yield put(isAuthenticating(true));
74 |
75 | const user: IUser = yield call(register, payload);
76 |
77 | socket.emit('userConnect', user.id);
78 | yield put(registerSuccess(user));
79 | yield put(isAuthenticating(false));
80 | }
81 | catch (e) {
82 | console.log('ERR', e);
83 | yield handleError(e);
84 | }
85 | break;
86 | default:
87 | return;
88 | }
89 | }
90 |
91 | export default authSaga;
92 |
--------------------------------------------------------------------------------
/server/src/routes/api/v1/bookmark.ts:
--------------------------------------------------------------------------------
1 | import { BOOKMARKS_LIMIT } from '@/constants/constants';
2 | import { makeResponseJson } from '@/helpers/utils';
3 | import { ErrorHandler } from '@/middlewares/error.middleware';
4 | import { isAuthenticated, validateObjectID } from '@/middlewares/middlewares';
5 | import { Bookmark, Post } from '@/schemas';
6 | import { NextFunction, Request, Response, Router } from 'express';
7 | import { Types } from 'mongoose';
8 |
9 | const router = Router({ mergeParams: true });
10 |
11 | router.post(
12 | '/v1/bookmark/post/:post_id',
13 | isAuthenticated,
14 | validateObjectID('post_id'),
15 | async (req: Request, res: Response, next: NextFunction) => {
16 | try {
17 | const { post_id } = req.params;
18 | const userID = req.user._id;
19 |
20 | const post = await Post.findById(post_id);
21 | if (!post) return res.sendStatus(404);
22 |
23 | if (userID.toString() === post._author_id.toString()) {
24 | return next(new ErrorHandler(400, 'You can\'t bookmark your own post.'));
25 | }
26 |
27 | const isPostBookmarked = await Bookmark
28 | .findOne({
29 | _author_id: userID,
30 | _post_id: Types.ObjectId(post_id)
31 | });
32 |
33 | if (isPostBookmarked) {
34 | await Bookmark.findOneAndDelete({ _author_id: userID, _post_id: Types.ObjectId(post_id) });
35 |
36 | res.status(200).send(makeResponseJson({ state: false }));
37 | } else {
38 | const bookmark = new Bookmark({
39 | _post_id: post_id,
40 | _author_id: userID,
41 | createdAt: Date.now()
42 | });
43 | await bookmark.save();
44 |
45 | res.status(200).send(makeResponseJson({ state: true }));
46 | }
47 | } catch (e) {
48 | console.log('CANT BOOKMARK POST ', e);
49 | next(e)
50 | }
51 | }
52 | );
53 |
54 | router.get(
55 | '/v1/bookmarks',
56 | isAuthenticated,
57 | async (req: Request, res: Response, next: NextFunction) => {
58 | try {
59 | const userID = req.user._id;
60 | const offset = parseInt((req.query.offset as string), 10) || 0;
61 | const limit = BOOKMARKS_LIMIT;
62 | const skip = offset * limit;
63 |
64 | const bookmarks = await Bookmark
65 | .find({ _author_id: userID })
66 | .populate({
67 | path: 'post',
68 | select: 'photos description',
69 | populate: {
70 | path: 'likesCount commentsCount'
71 | }
72 | })
73 | .limit(limit)
74 | .skip(skip)
75 | .sort({ createdAt: -1 });
76 |
77 | if (bookmarks.length === 0) {
78 | return next(new ErrorHandler(404, "You don't have any bookmarks."))
79 | }
80 |
81 | const result = bookmarks.map((item) => {
82 | return {
83 | ...item.toObject(),
84 | isBookmarked: true,
85 | }
86 | });
87 |
88 | res.status(200).send(makeResponseJson(result));
89 | } catch (e) {
90 | console.log('CANT GET BOOKMARKS ', e);
91 | next(e);
92 | }
93 | }
94 | );
95 |
96 | export default router;
97 |
--------------------------------------------------------------------------------
/frontend/src/pages/profile/Tabs/Info.tsx:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import { useSelector } from "react-redux";
3 | import { useHistory } from "react-router-dom";
4 | import { useDocumentTitle } from '~/hooks';
5 | import { IRootReducer } from "~/types/types";
6 |
7 | const Info = () => {
8 | const { profile, isOwnProfile } = useSelector((state: IRootReducer) => ({
9 | profile: state.profile,
10 | isOwnProfile: state.auth.username === state.profile.username
11 | }));
12 | const history = useHistory();
13 | useDocumentTitle(`Info - ${profile.username} | Foodie`);
14 |
15 | return (
16 |
17 |
18 |
Info
19 | {isOwnProfile && (
20 | history.push(`/user/${profile.username}/edit`)}
23 | >
24 | Edit
25 |
26 | )}
27 |
28 |
29 |
30 |
Full Name
31 | {profile.fullname ? (
32 | {profile.fullname}
33 | ) : (
34 | Name not set.
35 | )}
36 |
37 |
38 |
Gender
39 | {profile.info.gender ? (
40 | {profile.info.gender}
41 | ) : (
42 | Gender not set.
43 | )}
44 |
45 |
46 |
Birthday
47 | {profile.info.birthday ? (
48 | {dayjs(profile.info.birthday).format('MMM.DD, YYYY')}
49 | ) : (
50 | Birthday not set.
51 | )}
52 |
53 |
54 |
Bio
55 | {profile.info.bio ? (
56 | {profile.info.bio}
57 | ) : (
58 | Bio not set.
59 | )}
60 |
61 |
62 |
Date Joined
63 | {dayjs(profile.dateJoined).format('MMM.DD, YYYY')}
64 |
65 |
66 |
67 | );
68 | };
69 |
70 | export default Info;
71 |
--------------------------------------------------------------------------------
/server/src/routes/api/v1/notification.ts:
--------------------------------------------------------------------------------
1 | import { NOTIFICATIONS_LIMIT } from '@/constants/constants';
2 | import { makeResponseJson } from '@/helpers/utils';
3 | import { ErrorHandler, isAuthenticated } from '@/middlewares';
4 | import { Notification } from '@/schemas';
5 | import { NextFunction, Request, Response, Router } from 'express';
6 |
7 | const router = Router({ mergeParams: true });
8 |
9 | router.get(
10 | '/v1/notifications',
11 | isAuthenticated,
12 | async (req: Request, res: Response, next: NextFunction) => {
13 | try {
14 | let offset = parseInt(req.query.offset as string) || 0;
15 |
16 | const limit = NOTIFICATIONS_LIMIT;
17 | const skip = offset * limit;
18 |
19 | const notifications = await Notification
20 | .find({ target: req.user._id })
21 | .populate('target initiator', 'profilePicture username fullname')
22 | .sort({ createdAt: -1 })
23 | .limit(limit)
24 | .skip(skip);
25 | const unreadCount = await Notification.find({ target: req.user._id, unread: true });
26 | const count = await Notification.find({ target: req.user._id });
27 | const result = { notifications, unreadCount: unreadCount.length, count: count.length };
28 |
29 | if (notifications.length === 0 && offset === 0) {
30 | return next(new ErrorHandler(404, 'You have no notifications.'));
31 | } else if (notifications.length === 0 && offset >= 1) {
32 | return next(new ErrorHandler(404, 'No more notifications.'));
33 | }
34 |
35 | res.status(200).send(makeResponseJson(result));
36 | } catch (e) {
37 | console.log(e);
38 | next(e);
39 | }
40 | }
41 | );
42 |
43 | router.get(
44 | '/v1/notifications/unread',
45 | isAuthenticated,
46 | async (req: Request, res: Response, next: NextFunction) => {
47 | try {
48 | const notif = await Notification.find({ target: req.user._id, unread: true });
49 |
50 | res.status(200).send(makeResponseJson({ count: notif.length }));
51 | } catch (e) {
52 | console.log('CANT GET UNREAD NOTIFICATIONS', e);
53 | next(e);
54 | }
55 | }
56 | );
57 |
58 | router.patch(
59 | '/v1/notifications/mark',
60 | isAuthenticated,
61 | async (req: Request, res: Response, next: NextFunction) => {
62 | try {
63 | await Notification
64 | .updateMany(
65 | { target: req.user._id },
66 | {
67 | $set: {
68 | unread: false
69 | }
70 | });
71 | res.status(200).send(makeResponseJson({ state: false }));
72 | } catch (e) {
73 | console.log('CANT MARK ALL AS UNREAD', e);
74 | next(e);
75 | }
76 | }
77 | );
78 |
79 | router.patch(
80 | '/v1/read/notification/:id',
81 | isAuthenticated,
82 | async (req: Request, res: Response, next: NextFunction) => {
83 | try {
84 | const { id } = req.params;
85 | const notif = await Notification.findById(id);
86 | if (!notif) return res.sendStatus(400);
87 |
88 | await Notification
89 | .findByIdAndUpdate(id, {
90 | $set: {
91 | unread: false
92 | }
93 | });
94 |
95 | res.status(200).send(makeResponseJson({ state: false })) // state = false EQ unread = false
96 | } catch (e) {
97 | next(e);
98 | }
99 | }
100 | );
101 |
102 | export default router;
103 |
--------------------------------------------------------------------------------
/frontend/src/styles/base/base.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background: #f9f9f9;
7 | --modalBackground: #fff;
8 | --modalOverlayBackground: rgba(0,0,0, .3);
9 | --scrollBarTrackBg: #cacaca;
10 | }
11 |
12 | :root.dark {
13 | --background: #08070f;
14 | --modalBackground: #100f17;
15 | --modalOverlayBackground: rgba(0,0,0, .7);
16 | --scrollBarTrackBg: #1e1c2a;
17 | }
18 |
19 | @layer base {
20 | h1, h2 {
21 | @apply font-bold;
22 | }
23 |
24 | h3, h4, h5, h6 {
25 | @apply font-medium;
26 | }
27 |
28 | h1 {
29 | @apply text-4xl leading-loose;
30 | }
31 |
32 | h2 {
33 | @apply text-2xl;
34 | }
35 |
36 | h3 {
37 | @apply text-xl;
38 | }
39 |
40 | a {
41 | @apply text-indigo-600;
42 | }
43 |
44 | button {
45 | @apply
46 | group
47 | relative
48 | disabled:opacity-50
49 | disabled:cursor-not-allowed
50 | flex
51 | justify-center
52 | py-3
53 | px-4
54 | border
55 | outline-none
56 | border-transparent
57 | text-sm
58 | font-medium
59 | rounded-full
60 | text-white
61 | bg-indigo-600;
62 |
63 | &:hover {
64 | @apply bg-indigo-700;
65 | }
66 |
67 | &:focus {
68 | @apply bg-indigo-900;
69 | @apply outline-none;
70 | }
71 | }
72 |
73 | input[type=text],
74 | input[type=email],
75 | input[type=password],
76 | input[type=date],
77 | textarea {
78 | @apply
79 | appearance-none
80 | relative
81 | block
82 | w-full
83 | px-6
84 | py-3
85 | border
86 | border-gray-300
87 | placeholder-gray-500
88 | text-gray-900
89 | rounded-full
90 | readonly:opacity-50
91 | focus:border-indigo-100
92 | hover:cursor-not-allowed;
93 |
94 | &:focus {
95 | @apply outline-none;
96 | @apply z-10;
97 | }
98 |
99 | @screen mobile {
100 | @apply text-sm;
101 | }
102 | }
103 |
104 | input[type=checkbox] {
105 | @apply
106 | h-4
107 | w-4
108 | text-indigo-600
109 | border-indigo-500
110 | rounded
111 | hover:cursor-not-allowed
112 | focus:ring-0
113 | readonly:opacity-50;
114 |
115 | &:focus {
116 | @apply ring-0 outline-none;
117 | }
118 | }
119 |
120 | textarea {
121 | @apply rounded-md;
122 | @apply resize-none;
123 | }
124 |
125 | label {
126 | @apply text-gray-500;
127 | @apply text-sm;
128 | }
129 |
130 | select {
131 | @apply border-gray-300;
132 | @apply rounded-full;
133 | @apply px-4 py-3;
134 | }
135 | }
136 |
137 | @layer utilities {
138 | .scrollbar {
139 | scrollbar-color: white;
140 | scrollbar-width: thin;
141 |
142 | &::-webkit-scrollbar {
143 | width: 10px;
144 | }
145 |
146 | &::-webkit-scrollbar-track {
147 | background: var(--scrollBarTrackBg);
148 | }
149 |
150 | &::-webkit-scrollbar-thumb {
151 | @apply bg-gray-500;
152 | border-radius: 10px;
153 |
154 | &:hover {
155 | @apply bg-indigo-500;
156 | }
157 | }
158 | }
159 | }
160 |
161 | body {
162 | margin: 0;
163 | font-family: 'SF Pro Display', sans-serif;
164 | -webkit-font-smoothing: antialiased;
165 | -moz-osx-font-smoothing: grayscale;
166 | min-height: 100vh;
167 | line-height: 1.6;
168 | background: var(--background);
169 |
170 | }
171 |
172 | code {
173 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
174 | monospace;
175 | }
176 |
177 | .App {
178 | min-height: 100vh;
179 | }
180 |
181 | .anticon {
182 | @apply inline-flex items-center justify-center;
183 | }
--------------------------------------------------------------------------------
/frontend/src/pages/suggested_people/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import useInfiniteScroll from "react-infinite-scroll-hook";
3 | import { CSSTransition, TransitionGroup } from "react-transition-group";
4 | import { UserCard } from "~/components/main";
5 | import { Loader, UserLoader } from "~/components/shared";
6 | import { useDidMount } from "~/hooks";
7 | import { getSuggestedPeople } from "~/services/api";
8 | import { IError, IProfile } from "~/types/types";
9 |
10 | const SuggestedPeople = () => {
11 | const [people, setPeople] = useState([]);
12 | const [isLoading, setIsLoading] = useState(false);
13 | const [error, setError] = useState(null);
14 | const [offset, setOffset] = useState(0);
15 | const didMount = useDidMount(true);
16 |
17 | useEffect(() => {
18 | fetchSuggested();
19 | // eslint-disable-next-line react-hooks/exhaustive-deps
20 | }, []);
21 |
22 | const fetchSuggested = async () => {
23 | try {
24 | setIsLoading(true);
25 | const users = await getSuggestedPeople({ offset });
26 |
27 | if (didMount) {
28 | setPeople([...people, ...users]);
29 | setOffset(offset + 1);
30 | setIsLoading(false);
31 | }
32 | } catch (e) {
33 | if (didMount) {
34 | setIsLoading(false);
35 | setError(e);
36 | }
37 | }
38 | }
39 |
40 | const infiniteRef = useInfiniteScroll({
41 | loading: isLoading,
42 | hasNextPage: !error && people.length >= 10,
43 | onLoadMore: fetchSuggested,
44 | scrollContainer: 'window',
45 | });
46 |
47 | return (
48 |
49 |
50 |
Suggested People
51 |
Follow people to see their updates
52 |
53 | {(isLoading && people.length === 0) && (
54 |
55 |
56 |
57 |
58 |
59 |
60 | )}
61 | {(!isLoading && error && people.length === 0) && (
62 |
63 |
64 | {(error as IError)?.error?.message || 'Something went wrong :('}
65 |
66 |
67 | )}
68 |
69 | }
72 | >
73 | {people.map(user => (
74 |
79 |
80 |
81 |
82 |
83 | ))}
84 |
85 |
86 | {(isLoading && people.length >= 10 && !error) && (
87 |
88 |
89 |
90 | )}
91 |
92 | );
93 | };
94 |
95 | export default SuggestedPeople;
96 |
--------------------------------------------------------------------------------
/frontend/src/redux/reducer/chatReducer.ts:
--------------------------------------------------------------------------------
1 | import { CLEAR_CHAT, CLOSE_CHAT, GET_MESSAGES_SUCCESS, INITIATE_CHAT, MINIMIZE_CHAT, NEW_MESSAGE_ARRIVED } from "~/constants/actionType";
2 | import { IChatState } from "~/types/types";
3 | import { TChatActionType } from "../action/chatActions";
4 |
5 | const initState: IChatState = {
6 | active: '',
7 | items: []
8 | };
9 |
10 | const chatReducer = (state = initState, action: TChatActionType) => {
11 | switch (action.type) {
12 | case INITIATE_CHAT:
13 | const exists = state.items.some(chat => (chat.id as unknown) === action.payload.id);
14 | const initChat = {
15 | username: action.payload.username,
16 | id: action.payload.id,
17 | profilePicture: action.payload.profilePicture,
18 | minimized: false,
19 | chats: []
20 | };
21 |
22 | const maxItems = 4;
23 | const hasReachedLimit = state.items.length === maxItems;
24 |
25 |
26 | if (!exists) {
27 | // Delete first and set minimized to true
28 | const mapped = state.items.map(chat => ({
29 | ...chat,
30 | minimized: true
31 | }));
32 | const deletedFirstItem = mapped.splice(1);
33 |
34 | return {
35 | active: action.payload.id,
36 | items: hasReachedLimit
37 | ? [...deletedFirstItem, initChat]
38 | : [...(state.items.map(chat => ({
39 | ...chat,
40 | minimized: true
41 | }))), initChat]
42 | }
43 | } else {
44 | return {
45 | active: action.payload.id,
46 | items: state.items.map((chat) => {
47 | if ((chat.id as unknown) === action.payload.id) {
48 | return {
49 | ...chat,
50 | minimized: false
51 | }
52 | }
53 |
54 | return {
55 | ...chat,
56 | minimized: true
57 | };
58 | })
59 | };
60 | }
61 | case MINIMIZE_CHAT:
62 | return {
63 | active: '',
64 | items: state.items.map((chat) => {
65 | if (chat.username === action.payload) {
66 | return {
67 | ...chat,
68 | minimized: true
69 | }
70 | }
71 | return chat;
72 | })
73 | }
74 | case CLOSE_CHAT:
75 | return {
76 | active: '',
77 | items: state.items.filter(chat => chat.username !== action.payload)
78 | }
79 | case GET_MESSAGES_SUCCESS:
80 | return {
81 | ...state,
82 | items: state.items.map(chat => chat.username !== action.payload.username ? chat : {
83 | ...chat,
84 | offset: (chat.offset || 0) + 1,
85 | chats: [...action.payload.messages, ...chat.chats]
86 | })
87 | }
88 | case NEW_MESSAGE_ARRIVED:
89 | return {
90 | ...state,
91 | items: state.items.map(chat => chat.username !== action.payload.username ? chat : {
92 | ...chat,
93 | chats: [...chat.chats, action.payload.message]
94 | })
95 | }
96 | case CLEAR_CHAT:
97 | return initState;
98 | default:
99 | return state;
100 | }
101 | };
102 |
103 | export default chatReducer;
104 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Foodie
2 | A social media for food lovers and for people looking for new ideas for their next menu. A facebook/instagram-like inspired social media.
3 |
4 |  
5 |
6 |
7 |
8 | ## Table of contents
9 | * [Features](#features)
10 | * [Technologies](#technologies)
11 | * [Installation](#installation)
12 | * [Run Locally](#run_local)
13 | * [Deployment](#deployment)
14 | * [Screenshots](#screenshots)
15 |
16 | ## Features
17 | This web app consists of a basic features/functionalities of a socia media
18 | * Login and Registration
19 | * Notification
20 | * Private Messaging
21 | * Post CRUD functionality
22 | * Comment feature
23 | * Profile Customization
24 | * Followers/Following feature
25 | * Search Feature
26 |
27 | ## Technologies
28 | | Front End | Back End |
29 | | ----------- | ------------|
30 | | React 17.0.1| Node 12.18.1|
31 | | TypeScript | MongoDB |
32 | | Redux | Mongoose |
33 | | Redux-Saga | SocketIO |
34 | | React Router| Express JS |
35 | | TailwindCSS | Passport JS |
36 | | PostCSS | Google Cloud Storage|
37 | | Axios | |
38 |
39 | ## Installation
40 | To install both ends (frontend/server).
41 | ```
42 | $ npm run init-project
43 | ```
44 |
45 | Or to install them individually
46 | ```
47 | $ cd frontend // or cd server
48 | $ npm install
49 | ```
50 |
51 | ## Run locally
52 | Before running the project, make sure to have the following done:
53 | * Download and install [MongoDB](https://www.mongodb.com/)
54 | * Create [Firebase Project](https://console.firebase.google.com/u/0/) for storage bucket
55 | * Create Google Service Account json key and configure ENV variable to your machine
56 |
57 | Create ```.env-dev``` or ```.end-prod``` env variables and set the following:
58 | ```
59 | MONGODB_URI=
60 | DB_NAME=
61 | PORT=
62 | CLIENT_URL=
63 | SESSION_SECRET=
64 | SESSION_NAME=
65 | FIREBASE_PROJECT_ID=
66 | FIREBASE_STORAGE_BUCKET_URL=
67 | GOOGLE_APPLICATION_CREDENTIALS=
68 | FACEBOOK_CLIENT_ID=
69 | FACEBOOK_CLIENT_SECRET=
70 | GITHUB_CLIENT_ID=
71 | GITHUB_CLIENT_SECRET=
72 | ```
73 |
74 | You can get your Facebook client id/secret here [Facebook for developers](http://developers.facebook.com/) and for GitHub here [Register Github OAuth App](https://github.com/settings/applications/new) and set the necessary env vars above.
75 |
76 | After doing the steps above, you have to run your ```Mongo Server``` and finally you can now run both ends simultaneously by running:
77 | ```
78 | $ npm start
79 | ```
80 |
81 | Or you can run them individually
82 | ```
83 | $ npm run start-client // frontend
84 | $ npm run start-server // backend
85 |
86 | // Or you can change to individual directory then run
87 | $ cd frontend // or cd server
88 | $ npm start
89 | ```
90 |
91 | ## Deployment
92 | You can deploy your react app in [Vercel](http://vercel.app/) or whatever your preferred deployment platform.
93 | And for the backend, you can deploy your server in [Heroku](https://heroku.com)
94 |
95 | ## Screenshots
96 |
97 | 
98 | 
99 | 
100 | 
101 |
--------------------------------------------------------------------------------
/frontend/src/pages/profile/Tabs/Followers.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import useInfiniteScroll from "react-infinite-scroll-hook";
3 | import { CSSTransition, TransitionGroup } from "react-transition-group";
4 | import { UserCard } from "~/components/main";
5 | import { Loader, UserLoader } from "~/components/shared";
6 | import { useDidMount, useDocumentTitle } from "~/hooks";
7 | import { getFollowers } from "~/services/api";
8 | import { IError, IProfile } from "~/types/types";
9 |
10 | interface IProps {
11 | username: string;
12 | }
13 |
14 | const Followers: React.FC = ({ username }) => {
15 | const [followers, setFollowers] = useState([]);
16 | const [isLoading, setIsLoading] = useState(false);
17 | const [offset, setOffset] = useState(0); // Pagination
18 | const didMount = useDidMount(true);
19 | const [error, setError] = useState(null);
20 |
21 | useDocumentTitle(`Followers - ${username} | Foodie`);
22 | useEffect(() => {
23 | fetchFollowers();
24 | // eslint-disable-next-line react-hooks/exhaustive-deps
25 | }, []);
26 |
27 | const fetchFollowers = async () => {
28 | try {
29 | setIsLoading(true);
30 | const fetchedFollowers = await getFollowers(username, { offset });
31 |
32 | if (didMount) {
33 | setFollowers([...followers, ...fetchedFollowers]);
34 | setIsLoading(false);
35 | setOffset(offset + 1);
36 |
37 | setError(null);
38 | }
39 | } catch (e) {
40 | if (didMount) {
41 | setIsLoading(false);
42 | setError(e)
43 | }
44 | console.log(e);
45 | }
46 | };
47 |
48 | const infiniteRef = useInfiniteScroll({
49 | loading: isLoading,
50 | hasNextPage: !error && followers.length >= 10,
51 | onLoadMore: fetchFollowers,
52 | scrollContainer: 'window',
53 | threshold: 200
54 | });
55 |
56 | return (
57 | }>
58 | {(isLoading && followers.length === 0) && (
59 |
60 |
61 |
62 |
63 |
64 |
65 | )}
66 | {(!isLoading && followers.length === 0 && error) && (
67 |
68 |
{error?.error?.message || 'Something went wrong.'}
69 |
70 | )}
71 | {followers.length !== 0 && (
72 |
73 |
Followers
74 |
75 | {followers.map(user => (
76 |
81 |
82 |
83 |
84 |
85 | ))}
86 |
87 | {(followers.length !== 0 && !error && isLoading) && (
88 |
89 |
90 |
91 | )}
92 |
93 | )}
94 |
95 | );
96 | };
97 |
98 | export default Followers;
99 |
--------------------------------------------------------------------------------
/server/src/middlewares/error.middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 |
3 | class ErrorHandler extends Error {
4 | statusCode: number | undefined;
5 | message: string | undefined;
6 | constructor(statusCode?: number, message?: string) {
7 | super()
8 | this.statusCode = statusCode;
9 | this.message = message;
10 | }
11 | }
12 |
13 | interface IErrorResponseJSON {
14 | statusCode: number;
15 | title?: string;
16 | type?: string;
17 | message?: string;
18 | errors?: any[];
19 | }
20 |
21 | const errorResponseJSON = ({ statusCode, title, type, message, errors = null }: IErrorResponseJSON) => ({
22 | status_code: statusCode,
23 | success: false,
24 | data: null,
25 | error: { type, title, message, errors },
26 | timestamp: new Date().getTime()
27 | })
28 |
29 | const errorMiddleware = (err: any, req: Request, res: Response, next: NextFunction) => {
30 | const { statusCode = 500, message = 'Internal Server Error' } = err;
31 |
32 | if (err.name === 'MongoError' && err.code === 11000) { // Mongo error
33 | const field = Object.keys(err.keyValue);
34 |
35 | return res.status(409).json(errorResponseJSON({
36 | statusCode: 409,
37 | title: 'Conflict',
38 | type: 'CONFLICT_ERROR',
39 | message: `An account with that ${field} already exists.`
40 | }));
41 | }
42 |
43 | if (err.name === 'ValidationError') { // For mongoose validation error handler
44 | const errors = Object.values(err.errors).map((el: any) => ({ message: el.message, path: el.path }));
45 |
46 | return res.status(400).json(errorResponseJSON({
47 | statusCode: 400,
48 | title: 'Invalid Input',
49 | type: 'INVALID_INPUT_ERROR',
50 | message: err?.message || 'Invalid input.',
51 | errors
52 | }));
53 | }
54 |
55 | if (err.statusCode === 400) { // BadRequestError
56 | return res.status(400).json(errorResponseJSON({
57 | statusCode: 400,
58 | title: 'Bad Request.',
59 | type: 'BAD_REQUEST_ERROR',
60 | message: err?.message || 'Bad request.'
61 | }));
62 | }
63 |
64 | if (err.statusCode === 401) { // UnathorizeError
65 | return res.status(401).json(errorResponseJSON({
66 | statusCode: 401,
67 | title: 'Unauthorize Error',
68 | type: 'UNAUTHORIZE_ERROR',
69 | message: err?.message || "You're not authorized to perform your request."
70 | }));
71 | }
72 |
73 | if (err.statusCode === 403) { // Forbidden
74 | return res.status(403).json(errorResponseJSON({
75 | statusCode: 403,
76 | title: 'Forbidden Error',
77 | type: 'FORBIDDEN_ERROR',
78 | message: err?.message || 'Forbidden request.'
79 | }));
80 | }
81 |
82 | if (err.statusCode === 404) { // NotFoundError
83 | return res.status(404).json(errorResponseJSON({
84 | statusCode: 404,
85 | title: 'Resource Not Found',
86 | type: 'NOT_FOUND_ERROR',
87 | message: err?.message || 'Requested resource not found.'
88 | }));
89 | }
90 |
91 | if (err.statusCode === 422) { // UnprocessableEntity
92 | // return res.status(422).json(errorResponseJSON(422, err?.message || 'Unable to process your request.'));
93 | return res.status(422).json(errorResponseJSON({
94 | statusCode: 422,
95 | title: 'Unprocessable Entity',
96 | type: 'UNPROCESSABLE_ENTITY_ERROR',
97 | message: err?.message || 'Unable to process your request.'
98 | }));
99 | }
100 |
101 | console.log('FROM MIDDLEWARE ------------------------', err)
102 | res.status(statusCode).json(errorResponseJSON({
103 | statusCode,
104 | title: 'Server Error',
105 | type: 'SERVER_ERROR',
106 | message
107 | }));
108 | }
109 |
110 | export { errorMiddleware as default, ErrorHandler };
111 |
112 |
--------------------------------------------------------------------------------
/frontend/src/pages/profile/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { Route, RouteComponentProps, Switch } from "react-router-dom";
4 | import { Boundary, ProfileLoader } from "~/components/shared";
5 | import * as ROUTE from "~/constants/routes";
6 | import { PageNotFound } from "~/pages";
7 | import { getUserStart } from "~/redux/action/profileActions";
8 | import { IRootReducer } from "~/types/types";
9 | import Header from './Header';
10 | import * as Tab from './Tabs';
11 |
12 | interface MatchParams {
13 | username: string;
14 | }
15 |
16 | interface IProps extends RouteComponentProps {
17 | children: React.ReactNode;
18 | }
19 |
20 | const Profile: React.FC = (props) => {
21 | const dispatch = useDispatch();
22 | const { username } = props.match.params;
23 | const state = useSelector((state: IRootReducer) => ({
24 | profile: state.profile,
25 | auth: state.auth,
26 | error: state.error.profileError,
27 | isLoadingGetUser: state.loading.isLoadingGetUser
28 | }));
29 |
30 | useEffect(() => {
31 | if (state.profile.username !== username) {
32 | dispatch(getUserStart(username));
33 | }
34 | // eslint-disable-next-line react-hooks/exhaustive-deps
35 | }, []);
36 |
37 | return (
38 |
39 | {(state.error && !state.isLoadingGetUser) && (
40 |
41 | )}
42 | {(state.isLoadingGetUser) && (
43 |
44 | )}
45 | {(!state.error && !state.isLoadingGetUser && state.profile.id) && (
46 |
47 |
51 |
52 |
53 |
57 |
58 |
59 |
60 |
61 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | )}
87 |
88 | );
89 | };
90 |
91 | export default Profile;
92 |
--------------------------------------------------------------------------------