├── .github
└── FUNDING.yml
├── .gitignore
├── .vscode
└── settings.json
├── Dockerfile
├── README.md
├── index.html
├── nginx.conf
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
├── app
│ ├── hooks.ts
│ ├── services
│ │ ├── api.ts
│ │ ├── commentsApi.ts
│ │ ├── followApi.ts
│ │ ├── likesApi.ts
│ │ ├── postsApi.ts
│ │ └── userApi.ts
│ ├── store.ts
│ └── types.ts
├── components
│ ├── button
│ │ └── index.tsx
│ ├── card
│ │ └── index.tsx
│ ├── container
│ │ └── index.tsx
│ ├── count-info
│ │ └── index.tsx
│ ├── create-comment
│ │ └── index.tsx
│ ├── create-post
│ │ └── index.tsx
│ ├── edit-profile
│ │ └── index.tsx
│ ├── error-message
│ │ └── index.tsx
│ ├── go-back
│ │ └── index.tsx
│ ├── header
│ │ └── index.tsx
│ ├── input
│ │ └── index.tsx
│ ├── layout
│ │ └── index.tsx
│ ├── meta-info
│ │ └── index.tsx
│ ├── nav-bar
│ │ └── index.tsx
│ ├── nav-button
│ │ └── index.tsx
│ ├── profile-info
│ │ └── index.tsx
│ ├── profile
│ │ └── index.tsx
│ ├── theme-provider
│ │ └── index.tsx
│ ├── typography
│ │ └── index.tsx
│ └── user
│ │ └── index.tsx
├── constants.ts
├── features
│ └── user
│ │ ├── authGuard.tsx
│ │ ├── login.tsx
│ │ ├── register.tsx
│ │ └── userSlice.ts
├── hooks
│ └── useAuthGuard.ts
├── index.css
├── logo.svg
├── main.tsx
├── middleware
│ └── auth.ts
├── pages
│ ├── auth
│ │ └── index.tsx
│ ├── current-post
│ │ └── index.tsx
│ ├── followers
│ │ └── index.tsx
│ ├── following
│ │ └── index.tsx
│ ├── posts
│ │ └── index.tsx
│ └── user-profile
│ │ └── index.tsx
├── setupTests.ts
├── utils
│ ├── format-to-client-date.ts
│ └── has-error-field.ts
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: ['https://www.donationalerts.com/r/maxroslow']
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | # Dependencies
11 | node_modules
12 | .yarn/*
13 | !.yarn/patches
14 | !.yarn/plugins
15 | !.yarn/releases
16 | !.yarn/sdks
17 | !.yarn/versions
18 | # Swap the comments on the following lines if you don't wish to use zero-installs
19 | # Documentation here: https://yarnpkg.com/features/zero-installs
20 | !.yarn/cache
21 | #.pnp.*
22 |
23 | # Testing
24 | coverage
25 |
26 | # Production
27 | build
28 |
29 | # Miscellaneous
30 | *.local
31 |
32 | vite.config.ts.*.mjs
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules\\typescript\\lib"
3 | }
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Stage 1: Build React Application
2 | FROM node:14 AS build
3 |
4 | WORKDIR /usr/src/app
5 |
6 | COPY package*.json ./
7 |
8 | RUN npm install
9 |
10 | COPY . .
11 |
12 | RUN npm run build
13 |
14 | # Stage 2: Serve React Application with Nginx
15 | FROM nginx:stable-alpine
16 |
17 | COPY --from=build /usr/src/app/build /usr/share/nginx/html
18 |
19 | COPY nginx.conf /etc/nginx/conf.d/default.conf
20 |
21 | EXPOSE 8000
22 |
23 | CMD ["nginx", "-g", "daemon off;"]
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Для запуска проекта, необходимо выполнить следующие шаги:
2 |
3 | 1. Склонировать репозиторий с клиентским приложением по ссылке https://github.com/brian7346/react-threads.git на свой компьютер.
4 | ```
5 | git clone https://github.com/brian7346/react-threads.git
6 | ```
7 |
8 | 2. Склонировать репозиторий с api по ссылке [https://github.com/brian7346/express-threads-api.git](https://github.com/brian7346/express-threads-api/tree/main) на свой компьютер.
9 | ```
10 | git clone https://github.com/brian7346/express-threads-api.git
11 | ```
12 |
13 | 3. Открыть терминал (или командную строку) и перейти в корневую директорию сервера.
14 | ```
15 | cd express-threads-api
16 | ```
17 |
18 | 4. Переименовать файл .env.local (убрать .local)
19 | ```
20 | .env
21 | ```
22 |
23 | 5. Запустить команду docker compose которая поднимет сервер, клиент и базу данных
24 | ```
25 | docker compose up
26 | ```
27 |
28 | 6. Открыть браузер и перейти по адресу http://localhost:80, чтобы увидеть запущенный проект.
29 |
30 |
31 |
32 | # Если вы хотите скачать образ базы данных MongoDB
33 |
34 | Запустите контейнер с образом MongoDB и настройками replica set (он автоматичиски скачает и запустит этот образ):
35 |
36 | ```
37 | docker run --name mongo \
38 | -p 27017:27017 \
39 | -e MONGO_INITDB_ROOT_USERNAME="monty" \
40 | -e MONGO_INITDB_ROOT_PASSWORD="pass" \
41 | -d prismagraphql/mongo-single-replica:5.0.3
42 | ```
43 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Network Social
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 |
5 | location / {
6 | root /usr/share/nginx/html; # Путь к папке с собранными статическими файлами вашего приложения
7 | index index.html index.htm;
8 | try_files $uri $uri/ /index.html =404;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-template-redux",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "start": "vite",
9 | "build": "tsc && vite build",
10 | "preview": "vite preview",
11 | "test": "vitest",
12 | "format": "prettier --write .",
13 | "lint": "eslint .",
14 | "type-check": "tsc"
15 | },
16 | "dependencies": {
17 | "@nextui-org/react": "^2.1.13",
18 | "@reduxjs/toolkit": "^1.8.1",
19 | "framer-motion": "^10.16.4",
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0",
22 | "react-hook-form": "^7.46.2",
23 | "react-icons": "^4.11.0",
24 | "react-redux": "^8.0.1",
25 | "react-router-dom": "^6.16.0"
26 | },
27 | "devDependencies": {
28 | "@testing-library/dom": "^9.2.0",
29 | "@testing-library/jest-dom": "^5.11.4",
30 | "@testing-library/react": "^14.0.0",
31 | "@testing-library/user-event": "^14.2.5",
32 | "@types/react": "^18.0.15",
33 | "@types/react-dom": "^18.0.6",
34 | "@types/testing-library__jest-dom": "^5.14.5",
35 | "@vitejs/plugin-react": "^4.0.0",
36 | "autoprefixer": "^10.4.15",
37 | "eslint": "^8.0.0",
38 | "eslint-config-react-app": "^7.0.1",
39 | "eslint-plugin-prettier": "^4.2.1",
40 | "jsdom": "^21.1.0",
41 | "postcss": "^8.4.29",
42 | "prettier": "^2.7.1",
43 | "prettier-config-nick": "^1.0.2",
44 | "tailwindcss": "^3.3.3",
45 | "typescript": "^5.0.2",
46 | "vite": "^4.0.0",
47 | "vitest": "^0.30.1"
48 | },
49 | "eslintConfig": {
50 | "extends": [
51 | "react-app",
52 | "react-app/jest"
53 | ],
54 | "plugins": [
55 | "prettier"
56 | ],
57 | "rules": {
58 | "react/jsx-no-target-blank": "off"
59 | }
60 | },
61 | "prettier": "prettier-config-nick"
62 | }
63 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/hooks.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
2 | import type { RootState, AppDispatch } from "./store"
3 |
4 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
5 | export const useAppDispatch: () => AppDispatch = useDispatch
6 | export const useAppSelector: TypedUseSelectorHook = useSelector
7 |
--------------------------------------------------------------------------------
/src/app/services/api.ts:
--------------------------------------------------------------------------------
1 | import { createApi, fetchBaseQuery, retry } from "@reduxjs/toolkit/query/react"
2 | import { RootState } from "../store"
3 | import { BASE_URL } from "../../constants"
4 |
5 | const baseQuery = fetchBaseQuery({
6 | baseUrl: `${BASE_URL}/api`,
7 | prepareHeaders: (headers, { getState }) => {
8 | const token =
9 | (getState() as RootState).auth.token || localStorage.getItem("token")
10 |
11 | if (token) {
12 | headers.set("authorization", `Bearer ${token}`)
13 | }
14 | return headers
15 | },
16 | })
17 |
18 | const baseQueryWithRetry = retry(baseQuery, { maxRetries: 0 })
19 |
20 | export const api = createApi({
21 | reducerPath: "splitApi",
22 | baseQuery: baseQueryWithRetry,
23 | refetchOnMountOrArgChange: true,
24 | endpoints: () => ({}),
25 | })
26 |
--------------------------------------------------------------------------------
/src/app/services/commentsApi.ts:
--------------------------------------------------------------------------------
1 | import { Comment } from "../types"
2 | import { api } from "./api"
3 |
4 | export const commentsApi = api.injectEndpoints({
5 | endpoints: (builder) => ({
6 | createComment: builder.mutation>({
7 | query: (newComment) => ({
8 | url: `/comments`,
9 | method: "POST",
10 | body: newComment,
11 | }),
12 | }),
13 | deleteComment: builder.mutation({
14 | query: (commentId) => ({
15 | url: `/comments/${commentId}`,
16 | method: "DELETE",
17 | }),
18 | }),
19 | }),
20 | })
21 |
22 | export const { useCreateCommentMutation, useDeleteCommentMutation } =
23 | commentsApi
24 |
25 | export const {
26 | endpoints: { createComment, deleteComment },
27 | } = commentsApi
28 |
--------------------------------------------------------------------------------
/src/app/services/followApi.ts:
--------------------------------------------------------------------------------
1 | import { api } from "./api"
2 |
3 | export const followApi = api.injectEndpoints({
4 | endpoints: (builder) => ({
5 | followUser: builder.mutation({
6 | query: (body) => ({
7 | url: `/follow`,
8 | method: "POST",
9 | body,
10 | }),
11 | }),
12 | unfollowUser: builder.mutation({
13 | query: (userId) => ({
14 | url: `/unfollow/${userId}`,
15 | method: "DELETE",
16 | }),
17 | }),
18 | }),
19 | })
20 |
21 | export const { useFollowUserMutation, useUnfollowUserMutation } = followApi
22 |
23 | export const {
24 | endpoints: { followUser, unfollowUser },
25 | } = followApi
26 |
--------------------------------------------------------------------------------
/src/app/services/likesApi.ts:
--------------------------------------------------------------------------------
1 | import { Like } from "../types"
2 | import { api } from "./api"
3 |
4 | export const likesApi = api.injectEndpoints({
5 | endpoints: (builder) => ({
6 | likePost: builder.mutation({
7 | query: (body) => ({
8 | url: "/likes",
9 | method: "POST",
10 | body,
11 | }),
12 | }),
13 | unlikePost: builder.mutation({
14 | query: (postId) => ({
15 | url: `/likes/${postId}`,
16 | method: "DELETE",
17 | }),
18 | }),
19 | }),
20 | })
21 |
22 | export const { useLikePostMutation, useUnlikePostMutation } = likesApi
23 |
24 | export const {
25 | endpoints: { likePost, unlikePost },
26 | } = likesApi
27 |
--------------------------------------------------------------------------------
/src/app/services/postsApi.ts:
--------------------------------------------------------------------------------
1 | import { Post } from "../types"
2 | import { api } from "./api"
3 |
4 | export const postApi = api.injectEndpoints({
5 | endpoints: (builder) => ({
6 | createPost: builder.mutation({
7 | query: (postData) => ({
8 | url: "/posts",
9 | method: "POST",
10 | body: postData,
11 | }),
12 | }),
13 | getAllPosts: builder.query({
14 | query: () => ({
15 | url: "/posts",
16 | method: "GET",
17 | }),
18 | }),
19 | getPostById: builder.query({
20 | query: (id) => ({
21 | url: `/posts/${id}`,
22 | method: "GET",
23 | }),
24 | }),
25 | deletePost: builder.mutation({
26 | query: (id) => ({
27 | url: `/posts/${id}`,
28 | method: "DELETE",
29 | }),
30 | }),
31 | }),
32 | })
33 |
34 | export const {
35 | useCreatePostMutation,
36 | useGetAllPostsQuery,
37 | useGetPostByIdQuery,
38 | useDeletePostMutation,
39 | useLazyGetAllPostsQuery,
40 | useLazyGetPostByIdQuery,
41 | } = postApi
42 |
43 | export const {
44 | endpoints: { createPost, getAllPosts, getPostById, deletePost },
45 | } = postApi
46 |
--------------------------------------------------------------------------------
/src/app/services/userApi.ts:
--------------------------------------------------------------------------------
1 | import { User } from "../types"
2 | import { api } from "./api"
3 |
4 | export const userApi = api.injectEndpoints({
5 | endpoints: (builder) => ({
6 | login: builder.mutation<
7 | { token: string },
8 | { email: string; password: string }
9 | >({
10 | query: (userData) => ({
11 | url: "/login",
12 | method: "POST",
13 | body: userData,
14 | }),
15 | }),
16 | register: builder.mutation<
17 | { email: string; password: string; name: string },
18 | { email: string; password: string; name: string }
19 | >({
20 | query: (userData) => ({
21 | url: "/register",
22 | method: "POST",
23 | body: userData,
24 | }),
25 | }),
26 | current: builder.query({
27 | query: () => ({
28 | url: "/current",
29 | method: "GET",
30 | }),
31 | }),
32 | getUserById: builder.query({
33 | query: (id) => ({
34 | url: `/users/${id}`,
35 | method: "GET",
36 | }),
37 | }),
38 | updateUser: builder.mutation({
39 | query: ({ userData, id }) => ({
40 | url: `/users/${id}`,
41 | method: "PUT",
42 | body: userData,
43 | }),
44 | }),
45 | }),
46 | })
47 |
48 | export const {
49 | useRegisterMutation,
50 | useLoginMutation,
51 | useCurrentQuery,
52 | useLazyCurrentQuery,
53 | useGetUserByIdQuery,
54 | useLazyGetUserByIdQuery,
55 | useUpdateUserMutation,
56 | } = userApi
57 |
58 | export const {
59 | endpoints: { login, register, current, getUserById, updateUser },
60 | } = userApi
61 |
--------------------------------------------------------------------------------
/src/app/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
2 | import { api } from "./services/api"
3 | import auth from "../features/user/userSlice"
4 | import { listenerMiddleware } from "../middleware/auth"
5 |
6 | export const store = configureStore({
7 | reducer: {
8 | [api.reducerPath]: api.reducer,
9 | auth,
10 | },
11 | middleware: (getDefaultMiddleware) =>
12 | getDefaultMiddleware()
13 | .concat(api.middleware)
14 | .prepend(listenerMiddleware.middleware),
15 | })
16 |
17 | export type AppDispatch = typeof store.dispatch
18 | export type RootState = ReturnType
19 | export type AppThunk = ThunkAction<
20 | ReturnType,
21 | RootState,
22 | unknown,
23 | Action
24 | >
25 |
--------------------------------------------------------------------------------
/src/app/types.ts:
--------------------------------------------------------------------------------
1 | export type User = {
2 | id: string
3 | email: string
4 | password: string
5 | name?: string
6 | avatarUrl?: string
7 | dateOfBirth?: Date
8 | createdAt: Date
9 | updatedAt: Date
10 | bio?: string
11 | location?: string
12 | posts: Post[]
13 | following: Follows[]
14 | followers: Follows[]
15 | likes: Like[]
16 | comments: Comment[]
17 | isFollowing?: boolean
18 | }
19 |
20 | export type Follows = {
21 | id: string
22 | follower: User
23 | followerId: string
24 | following: User
25 | followingId: string
26 | }
27 |
28 | export type Post = {
29 | id: string
30 | content: string
31 | author: User
32 | authorId: string
33 | likes: Like[]
34 | comments: Comment[]
35 | likedByUser: boolean
36 | createdAt: Date
37 | updatedAt: Date
38 | }
39 |
40 | export type Like = {
41 | id: string
42 | user: User
43 | userId: string
44 | post: Post
45 | postId: string
46 | }
47 |
48 | export type Comment = {
49 | id: string
50 | content: string
51 | user: User
52 | userId: string
53 | post: Post
54 | postId: string
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/button/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button as NextButton } from "@nextui-org/react"
2 | import React from "react"
3 |
4 | type Props = {
5 | children: React.ReactNode
6 | icon?: JSX.Element
7 | className?: string
8 | type?: "button" | "submit" | "reset"
9 | fullWidth?: boolean
10 | color?:
11 | | "default"
12 | | "primary"
13 | | "secondary"
14 | | "success"
15 | | "warning"
16 | | "danger"
17 | | undefined
18 | }
19 |
20 | export const Button: React.FC = ({
21 | children,
22 | icon,
23 | className,
24 | type,
25 | fullWidth,
26 | color,
27 | }) => {
28 | return (
29 |
38 | {children}
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/card/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card as NextUiCard,
3 | CardHeader,
4 | CardBody,
5 | CardFooter,
6 | } from "@nextui-org/react"
7 | import { MetaInfo } from "../meta-info"
8 | import { Typography } from "../typography"
9 | import { User } from "../user"
10 | import { Link, useNavigate } from "react-router-dom"
11 | import { FaRegComment } from "react-icons/fa6"
12 | import {
13 | useUnlikePostMutation,
14 | useLikePostMutation,
15 | } from "../../app/services/likesApi"
16 | import {
17 | useDeletePostMutation,
18 | useLazyGetAllPostsQuery,
19 | useLazyGetPostByIdQuery,
20 | } from "../../app/services/postsApi"
21 | import { FcDislike } from "react-icons/fc"
22 | import { MdOutlineFavoriteBorder } from "react-icons/md"
23 | import { formatToClientDate } from "../../utils/format-to-client-date"
24 | import { RiDeleteBinLine } from "react-icons/ri"
25 | import { useSelector } from "react-redux"
26 | import { selectCurrent } from "../../features/user/userSlice"
27 | import { useDeleteCommentMutation } from "../../app/services/commentsApi"
28 | import { Spinner } from "@nextui-org/react"
29 | import { ErrorMessage } from "../error-message"
30 | import { useState } from "react"
31 | import { hasErrorField } from "../../utils/has-error-field"
32 |
33 | type Props = {
34 | avatarUrl: string
35 | name: string
36 | authorId: string
37 | content: string
38 | commentId?: string
39 | likesCount?: number
40 | commentsCount?: number
41 | createdAt?: Date
42 | id?: string
43 | cardFor: "comment" | "post" | "current-post"
44 | likedByUser?: boolean
45 | }
46 |
47 | export const Card = ({
48 | avatarUrl = "",
49 | name = "",
50 | content = "",
51 | authorId = "",
52 | id = "",
53 | likesCount = 0,
54 | commentsCount = 0,
55 | cardFor = "post",
56 | likedByUser = false,
57 | createdAt,
58 | commentId = "",
59 | }: Props) => {
60 | const [likePost] = useLikePostMutation()
61 | const [unlikePost] = useUnlikePostMutation()
62 | const [triggerGetAllPosts] = useLazyGetAllPostsQuery()
63 | const [triggerGetPostById] = useLazyGetPostByIdQuery()
64 | const [deletePost, deletePostStatus] = useDeletePostMutation()
65 | const [deleteComment, deleteCommentStatus] = useDeleteCommentMutation()
66 | const [error, setError] = useState("")
67 | const navigate = useNavigate()
68 | const currentUser = useSelector(selectCurrent)
69 |
70 | const refetchPosts = async () => {
71 | switch (cardFor) {
72 | case "post":
73 | await triggerGetAllPosts().unwrap()
74 | break
75 | case "current-post":
76 | await triggerGetAllPosts().unwrap()
77 | break
78 | case "comment":
79 | await triggerGetPostById(id).unwrap()
80 | break
81 | default:
82 | throw new Error("Неверный аргумент cardFor")
83 | }
84 | }
85 |
86 | const handleClick = async () => {
87 | try {
88 | likedByUser
89 | ? await unlikePost(id).unwrap()
90 | : await likePost({ postId: id }).unwrap()
91 |
92 | await refetchPosts()
93 | } catch (err) {
94 | if (hasErrorField(err)) {
95 | setError(err.data.error)
96 | } else {
97 | setError(err as string)
98 | }
99 | }
100 | }
101 |
102 | const handleDelete = async () => {
103 | try {
104 | switch (cardFor) {
105 | case "post":
106 | await deletePost(id).unwrap()
107 | await refetchPosts()
108 | break
109 | case "current-post":
110 | await deletePost(id).unwrap()
111 | navigate('/')
112 | break
113 | case "comment":
114 | await deleteComment(commentId).unwrap()
115 | await refetchPosts()
116 | break
117 | default:
118 | throw new Error("Неверный аргумент cardFor")
119 | }
120 |
121 | } catch (err) {
122 | console.log(err)
123 | if (hasErrorField(err)) {
124 | setError(err.data.error)
125 | } else {
126 | setError(err as string)
127 | }
128 | }
129 | }
130 |
131 | return (
132 |
133 |
134 |
135 |
141 |
142 | {authorId === currentUser?.id && (
143 |
144 | {deletePostStatus.isLoading || deleteCommentStatus.isLoading ? (
145 |
146 | ) : (
147 |
148 | )}
149 |
150 | )}
151 |
152 |
153 | {content}
154 |
155 | {cardFor !== "comment" && (
156 |
157 |
158 |
159 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 | )}
171 |
172 | )
173 | }
174 |
--------------------------------------------------------------------------------
/src/components/container/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | type Props = {
4 | children: React.ReactElement[] | React.ReactElement
5 | }
6 |
7 | export const Container: React.FC = ({ children }) => {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/count-info/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | type Props = {
4 | count: number;
5 | title: string;
6 | }
7 |
8 | export const CountInfo: React.FC = ({
9 | count,
10 | title,
11 | }) => {
12 | return (
13 |
14 | {count}
15 | {title}
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/create-comment/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Textarea } from "@nextui-org/react"
2 | import { IoMdCreate } from "react-icons/io"
3 | import { useForm, Controller } from "react-hook-form"
4 | import { ErrorMessage } from "../error-message"
5 | import { useCreateCommentMutation } from "../../app/services/commentsApi"
6 | import { useParams } from "react-router-dom"
7 | import { useLazyGetPostByIdQuery } from "../../app/services/postsApi"
8 |
9 | export const CreateComment = () => {
10 | const { id } = useParams<{ id: string }>()
11 | const [createComment] = useCreateCommentMutation()
12 | const [getPostById] = useLazyGetPostByIdQuery()
13 |
14 | const {
15 | handleSubmit,
16 | control,
17 | formState: { errors },
18 | setValue,
19 | } = useForm()
20 |
21 | const onSubmit = handleSubmit(async (data) => {
22 | try {
23 | if (id) {
24 | await createComment({ content: data.comment, postId: id }).unwrap()
25 | await getPostById(id).unwrap()
26 | setValue("comment", "")
27 | }
28 | } catch (error) {
29 | console.log("err", error)
30 | }
31 | })
32 |
33 | const error = errors?.comment?.message as string
34 |
35 | return (
36 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/create-post/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Textarea } from "@nextui-org/react"
2 | import { IoMdCreate } from "react-icons/io"
3 | import {
4 | useCreatePostMutation,
5 | useLazyGetAllPostsQuery,
6 | } from "../../app/services/postsApi"
7 | import { useForm, Controller } from "react-hook-form"
8 | import { ErrorMessage } from "../error-message"
9 |
10 | export const CreatePost = () => {
11 | const [createPost] = useCreatePostMutation()
12 | const [triggerGetAllPosts] = useLazyGetAllPostsQuery()
13 | const {
14 | handleSubmit,
15 | control,
16 | formState: { errors },
17 | setValue,
18 | } = useForm()
19 |
20 | const onSubmit = handleSubmit(async (data) => {
21 | try {
22 | await createPost({ content: data.post }).unwrap()
23 | setValue("post", "")
24 | await triggerGetAllPosts().unwrap()
25 | } catch (error) {
26 | console.log("err", error)
27 | }
28 | })
29 | const error = errors?.post?.message as string
30 |
31 | return (
32 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/edit-profile/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Modal,
4 | ModalBody,
5 | ModalContent,
6 | ModalFooter,
7 | ModalHeader,
8 | Textarea,
9 | } from "@nextui-org/react"
10 | import React, { useContext, useState } from "react"
11 | import { ThemeContext } from "../theme-provider"
12 | import { Controller, useForm } from "react-hook-form"
13 | import { User } from "../../app/types"
14 | import { Input } from "../input"
15 | import { useUpdateUserMutation } from "../../app/services/userApi"
16 | import { useParams } from "react-router-dom"
17 | import { hasErrorField } from "../../utils/has-error-field"
18 | import { ErrorMessage } from "../error-message"
19 | import { MdOutlineEmail } from "react-icons/md"
20 |
21 | type Props = {
22 | isOpen: boolean
23 | onClose: () => void
24 | user?: User;
25 | }
26 |
27 | export const EditProfile: React.FC = ({
28 | isOpen = false,
29 | onClose = () => null,
30 | user
31 | }) => {
32 | const { theme } = useContext(ThemeContext)
33 | const [updateUser, { isLoading }] = useUpdateUserMutation()
34 | const [error, setError] = useState("")
35 | const [selectedFile, setSelectedFile] = useState(null)
36 | const { id } = useParams<{ id: string }>()
37 |
38 | const { handleSubmit, control } = useForm({
39 | mode: "onChange",
40 | reValidateMode: "onBlur",
41 | defaultValues: {
42 | email: user?.email,
43 | name: user?.name,
44 | dateOfBirth: user?.dateOfBirth,
45 | bio: user?.bio,
46 | location: user?.location,
47 | },
48 | })
49 |
50 | const handleFileChange = (event: React.ChangeEvent) => {
51 | if (event.target.files !== null) {
52 | setSelectedFile(event.target.files[0])
53 | }
54 | }
55 |
56 | const onSubmit = async (data: User) => {
57 | if (id) {
58 | try {
59 | const formData = new FormData()
60 | data.name && formData.append("name", data.name)
61 | data.email && data.email !== user?.email && formData.append("email", data.email)
62 | data.dateOfBirth &&
63 | formData.append(
64 | "dateOfBirth",
65 | new Date(data.dateOfBirth).toISOString(),
66 | )
67 | data.bio && formData.append("bio", data.bio)
68 | data.location && formData.append("location", data.location)
69 | selectedFile && formData.append("avatar", selectedFile)
70 |
71 | await updateUser({ userData: formData, id }).unwrap()
72 | onClose()
73 | } catch (err) {
74 | console.log(err)
75 | if (hasErrorField(err)) {
76 | setError(err.data.error)
77 | }
78 | }
79 | }
80 | }
81 |
82 | return (
83 |
89 |
90 | {(onClose) => (
91 | <>
92 |
93 | Изменения профиля
94 |
95 |
96 |
150 |
151 |
152 |
155 |
156 | >
157 | )}
158 |
159 |
160 | )
161 | }
162 |
--------------------------------------------------------------------------------
/src/components/error-message/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | export const ErrorMessage = ({ error = "" }: { error: string }) => {
4 | return error && {error}
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/go-back/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { FaRegArrowAltCircleLeft } from "react-icons/fa"
3 | import { useNavigate } from "react-router-dom"
4 |
5 | export const GoBack = () => {
6 | const navigate = useNavigate()
7 |
8 | const handleGoBack = () => {
9 | navigate(-1)
10 | }
11 |
12 | return (
13 |
17 |
18 | Назад
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/header/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Navbar,
3 | NavbarBrand,
4 | NavbarContent,
5 | NavbarItem,
6 | Button,
7 | } from "@nextui-org/react"
8 | import { LuSunMedium } from "react-icons/lu"
9 | import { FaRegMoon } from "react-icons/fa"
10 | import { useDispatch, useSelector } from "react-redux"
11 | import { CiLogout } from "react-icons/ci"
12 | import { logout, selectIsAuthenticated } from "../../features/user/userSlice"
13 | import { useNavigate } from "react-router-dom"
14 | import { useContext } from "react"
15 | import { ThemeContext } from "../theme-provider"
16 |
17 | export const Header = () => {
18 | const isAuthenticated = useSelector(selectIsAuthenticated)
19 | const { theme, toggleTheme } = useContext(ThemeContext)
20 | const dispatch = useDispatch()
21 | const navigate = useNavigate()
22 |
23 | const hadleLogout = () => {
24 | dispatch(logout())
25 | localStorage.removeItem('token')
26 | navigate("/auth")
27 | }
28 |
29 | return (
30 |
31 |
32 | Network Social
33 |
34 |
35 |
36 | toggleTheme()}
39 | >
40 | {theme === "light" ? : }
41 |
42 |
43 | {isAuthenticated && (
44 |
52 | )}
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/input/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { Control, useController } from "react-hook-form"
3 | import { Input as NextInput } from "@nextui-org/react"
4 |
5 | type Props = {
6 | name: string
7 | label: string
8 | placeholder?: string
9 | type?: string
10 | control: Control
11 | required?: string
12 | endContent?: JSX.Element
13 | }
14 |
15 | export const Input: React.FC = ({
16 | name,
17 | label,
18 | placeholder,
19 | type,
20 | control,
21 | required = "",
22 | endContent,
23 | }) => {
24 | const {
25 | field,
26 | fieldState: { invalid },
27 | formState: { errors },
28 | } = useController({
29 | name,
30 | control,
31 | rules: { required },
32 | })
33 |
34 | return (
35 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react"
2 | import { Container } from "../container"
3 | import { NavBar } from "../nav-bar"
4 | import { Link, Outlet, useNavigate } from "react-router-dom"
5 | import { Profile } from "../profile"
6 | import { useSelector } from "react-redux"
7 | import {
8 | selectUser,
9 | selectIsAuthenticated,
10 | } from "../../features/user/userSlice"
11 | import { Header } from "../header"
12 |
13 | export const Layout = () => {
14 | const isAuthenticated = useSelector(selectIsAuthenticated)
15 | const user = useSelector(selectUser)
16 | const navigate = useNavigate()
17 |
18 | useEffect(() => {
19 | if (!isAuthenticated) {
20 | navigate("/auth")
21 | }
22 | }, [])
23 |
24 | return (
25 | <>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 |
38 | >
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/meta-info/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { IconType } from "react-icons"
3 |
4 | type Props = {
5 | count: number
6 | Icon: IconType
7 | }
8 |
9 | export const MetaInfo: React.FC = ({ count, Icon }) => {
10 | return (
11 |
12 | {count > 0 && (
13 |
{count}
14 | )}
15 |
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/nav-bar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { BsPostcard } from "react-icons/bs"
3 | import { FaUsers } from "react-icons/fa"
4 | import { FiUsers } from "react-icons/fi"
5 | import { NavButton } from "../nav-button"
6 |
7 | export const NavBar: React.FC = () => {
8 | return (
9 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/nav-button/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { Link } from "react-router-dom"
3 | import { Button } from "../button"
4 |
5 | type Props = {
6 | children: React.ReactNode
7 | icon: JSX.Element
8 | href: string
9 | }
10 |
11 | export const NavButton: React.FC = ({ children, icon, href }) => {
12 | return (
13 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/profile-info/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | type Props = {
4 | title: string;
5 | info?: string;
6 | }
7 |
8 | export const ProfileInfo: React.FC = ({
9 | title,
10 | info,
11 | }) => {
12 |
13 | if (!info) {
14 | return null;
15 | }
16 |
17 | return (
18 |
19 | {title}{info}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/profile/index.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardHeader, CardBody, Image } from "@nextui-org/react"
2 | import { useSelector } from "react-redux"
3 | import { selectCurrent } from "../../features/user/userSlice"
4 | import { MdAlternateEmail } from "react-icons/md"
5 | import { BASE_URL } from "../../constants"
6 | import { Link } from "react-router-dom"
7 |
8 | export const Profile = () => {
9 | const current = useSelector(selectCurrent)
10 |
11 | if (!current) {
12 | return null
13 | }
14 |
15 | const { name, email, avatarUrl, id } = current
16 |
17 | return (
18 |
19 |
20 |
26 |
27 |
28 |
29 | {name}
30 |
31 |
32 |
33 | {email}
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/theme-provider/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | type ThemeContextType = {
4 | theme: "dark" | "light"
5 | toggleTheme: () => void
6 | }
7 |
8 | export const ThemeContext = React.createContext({
9 | theme: "dark",
10 | toggleTheme: () => null,
11 | })
12 |
13 | export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
14 | const storedTheme = localStorage.getItem('theme');
15 | const currentTheme = storedTheme ? storedTheme as 'dark' | 'light' : 'dark';
16 |
17 | const [theme, setTheme] = React.useState<"dark" | "light">(currentTheme)
18 |
19 | console.log('theme', theme, currentTheme)
20 |
21 | const toggleTheme = () => {
22 | setTheme((prevTheme) => {
23 | const newTheme = prevTheme === "light" ? "dark" : "light";
24 | localStorage.setItem('theme', newTheme);
25 |
26 | return newTheme
27 | })
28 | }
29 |
30 | return (
31 |
32 |
33 | {children}
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/typography/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | type Props = {
4 | children: string
5 | size?: string
6 | }
7 |
8 | export const Typography: React.FC = ({ children, size = "text-xl" }) => {
9 | return {children}
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/user/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { User as NextUiUser } from "@nextui-org/react"
3 | import { BASE_URL } from "../../constants"
4 |
5 | type Props = {
6 | name: string
7 | avatarUrl: string
8 | description?: string
9 | className?: string
10 | }
11 |
12 | export const User: React.FC = ({
13 | name = "",
14 | description = "",
15 | avatarUrl = "",
16 | className = "",
17 | }) => {
18 | return (
19 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const BASE_URL =
2 | process.env.NODE_ENV === "production" ? "http://localhost:3000" : "http://localhost:3000"
3 |
--------------------------------------------------------------------------------
/src/features/user/authGuard.tsx:
--------------------------------------------------------------------------------
1 | import { useCurrentQuery } from "../../app/services/userApi"
2 | import { Spinner } from "@nextui-org/react"
3 |
4 | export const AuthGuard = ({ children }: { children: JSX.Element }) => {
5 | const { isLoading } = useCurrentQuery()
6 |
7 | if (isLoading) {
8 | return
9 | }
10 |
11 | return children
12 | }
13 |
--------------------------------------------------------------------------------
/src/features/user/login.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "../../components/input"
2 | import { useForm } from "react-hook-form"
3 | import { Button, Link } from "@nextui-org/react"
4 | import {
5 | useLazyCurrentQuery,
6 | useLoginMutation,
7 | } from "../../app/services/userApi"
8 | import { useNavigate } from "react-router-dom"
9 | import { useState } from "react"
10 | import { ErrorMessage } from "../../components/error-message"
11 | import { hasErrorField } from "../../utils/has-error-field"
12 |
13 | type Login = {
14 | email: string
15 | password: string
16 | }
17 |
18 | type Props = {
19 | setSelected: (value: string) => void
20 | }
21 |
22 | export const Login = ({ setSelected }: Props) => {
23 | const {
24 | handleSubmit,
25 | control,
26 | formState: { errors },
27 | } = useForm({
28 | mode: "onChange",
29 | reValidateMode: "onBlur",
30 | defaultValues: {
31 | email: "",
32 | password: "",
33 | },
34 | })
35 |
36 | const [login, { isLoading }] = useLoginMutation()
37 | const navigate = useNavigate()
38 | const [error, setError] = useState("")
39 | const [triggerCurrentQuery] = useLazyCurrentQuery()
40 |
41 | const onSubmit = async (data: Login) => {
42 | try {
43 | await login(data).unwrap()
44 | await triggerCurrentQuery()
45 | navigate("/")
46 | } catch (err) {
47 | if (hasErrorField(err)) {
48 | setError(err.data.error)
49 | }
50 | }
51 | }
52 | return (
53 |
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/src/features/user/register.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "../../components/input"
2 | import { useForm } from "react-hook-form"
3 | import { Button, Link } from "@nextui-org/react"
4 | import { useRegisterMutation } from "../../app/services/userApi"
5 | import { ErrorMessage } from "../../components/error-message"
6 | import { hasErrorField } from "../../utils/has-error-field"
7 | import { useState } from "react"
8 |
9 | type Register = {
10 | email: string
11 | name: string
12 | password: string
13 | }
14 |
15 | type Props = {
16 | setSelected: (value: string) => void
17 | }
18 |
19 | export const Register = ({ setSelected }: Props) => {
20 | const {
21 | handleSubmit,
22 | control,
23 | formState: { errors },
24 | } = useForm({
25 | mode: "onChange",
26 | reValidateMode: "onBlur",
27 | defaultValues: {
28 | email: "",
29 | password: "",
30 | name: "",
31 | },
32 | })
33 |
34 | const [register] = useRegisterMutation()
35 | const [error, setError] = useState("")
36 |
37 | const onSubmit = async (data: Register) => {
38 | try {
39 | await register(data).unwrap()
40 | setSelected("login")
41 | } catch (err) {
42 | if (hasErrorField(err)) {
43 | setError(err.data.error)
44 | }
45 | }
46 | }
47 |
48 | return (
49 |
88 | )
89 | }
90 |
--------------------------------------------------------------------------------
/src/features/user/userSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit"
2 | import { userApi } from "../../app/services/userApi"
3 | import { RootState } from "../../app/store"
4 | import { User } from "../../app/types"
5 |
6 | interface InitialState {
7 | user: User | null
8 | isAuthenticated: boolean
9 | users: User[] | null
10 | current: User | null
11 | token?: string
12 | }
13 |
14 | const initialState: InitialState = {
15 | user: null,
16 | isAuthenticated: false,
17 | users: null,
18 | current: null,
19 | }
20 |
21 | const slice = createSlice({
22 | name: "user",
23 | initialState,
24 | reducers: {
25 | logout: () => initialState,
26 | resetUser: (state) => {
27 | state.user = null
28 | },
29 | },
30 | extraReducers: (builder) => {
31 | builder
32 | .addMatcher(userApi.endpoints.login.matchFulfilled, (state, action) => {
33 | state.token = action.payload.token
34 | state.isAuthenticated = true
35 | })
36 | .addMatcher(userApi.endpoints.current.matchFulfilled, (state, action) => {
37 | state.isAuthenticated = true
38 | state.current = action.payload
39 | })
40 | .addMatcher(
41 | userApi.endpoints.getUserById.matchFulfilled,
42 | (state, action) => {
43 | state.user = action.payload
44 | },
45 | )
46 | },
47 | })
48 |
49 | export const { logout, resetUser } = slice.actions
50 | export default slice.reducer
51 |
52 | export const selectIsAuthenticated = (state: RootState) =>
53 | state.auth.isAuthenticated
54 |
55 | export const selectCurrent = (state: RootState) => state.auth.current
56 |
57 | export const selectUsers = (state: RootState) => state.auth.users
58 |
59 | export const selectUser = (state: RootState) => state.auth.user
60 |
--------------------------------------------------------------------------------
/src/hooks/useAuthGuard.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux"
2 | import { selectIsAuthenticated } from "../features/user/userSlice"
3 | import { useNavigate } from "react-router-dom"
4 | import { useEffect } from "react"
5 |
6 | export const useAuthGuard = () => {
7 | const isAuthenticated = useSelector(selectIsAuthenticated)
8 | const navigate = useNavigate()
9 |
10 | useEffect(() => {
11 | if (isAuthenticated) {
12 | navigate("/")
13 | }
14 | }, [])
15 | }
16 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@500;600;700&family=Noto+Sans:wght@500;700;900&family=Roboto&display=swap");
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | body {
7 | margin: 0;
8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
9 | "Noto Sans", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
10 | "Helvetica Neue", sans-serif;
11 | -webkit-font-smoothing: antialiased;
12 | -moz-osx-font-smoothing: grayscale;
13 | }
14 |
15 | main {
16 | min-height: 100vh;
17 | }
18 |
19 | code {
20 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
21 | monospace;
22 | }
23 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import ReactDOM from "react-dom/client"
3 | import { Provider } from "react-redux"
4 | import { NextUIProvider } from "@nextui-org/react"
5 | import { store } from "./app/store"
6 | import { createBrowserRouter, RouterProvider } from "react-router-dom"
7 | import "./index.css"
8 | import { Auth } from "./pages/auth"
9 | import { AuthGuard } from "./features/user/authGuard"
10 | import { Posts } from "./pages/posts"
11 | import { ThemeProvider } from "./components/theme-provider"
12 | import { Layout } from "./components/layout"
13 | import { UserProfile } from "./pages/user-profile"
14 | import { CurrentPost } from "./pages/current-post"
15 | import { Followers } from "./pages/followers"
16 | import { Following } from "./pages/following"
17 |
18 | const router = createBrowserRouter([
19 | {
20 | path: "/auth",
21 | element: ,
22 | },
23 | {
24 | path: "/",
25 | element: ,
26 | children: [
27 | {
28 | path: "",
29 | element: ,
30 | },
31 | {
32 | path: "posts/:id",
33 | element: ,
34 | },
35 | {
36 | path: "users/:id",
37 | element: ,
38 | },
39 | {
40 | path: "followers",
41 | element: ,
42 | },
43 | {
44 | path: "following",
45 | element: ,
46 | },
47 | ],
48 | },
49 | ])
50 |
51 | ReactDOM.createRoot(document.getElementById("root")!).render(
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | ,
61 | )
62 |
--------------------------------------------------------------------------------
/src/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import { createListenerMiddleware } from "@reduxjs/toolkit"
2 | import { userApi } from "../app/services/userApi"
3 |
4 | export const listenerMiddleware = createListenerMiddleware()
5 |
6 | listenerMiddleware.startListening({
7 | matcher: userApi.endpoints.login.matchFulfilled,
8 | effect: async (action, listenerApi) => {
9 | listenerApi.cancelActiveListeners()
10 |
11 | if (action.payload.token) {
12 | localStorage.setItem("token", action.payload.token)
13 | }
14 | },
15 | })
16 |
--------------------------------------------------------------------------------
/src/pages/auth/index.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardBody, Tab, Tabs } from "@nextui-org/react"
2 | import { useState } from "react"
3 | import { Login } from "../../features/user/login"
4 | import { Register } from "../../features/user/register"
5 | import { useAuthGuard } from "../../hooks/useAuthGuard"
6 |
7 | export const Auth = () => {
8 | const [selected, setSelected] = useState("login")
9 |
10 | useAuthGuard()
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | setSelected(key as string)}
22 | >
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/current-post/index.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom"
2 | import { useGetPostByIdQuery } from "../../app/services/postsApi"
3 | import { Card } from "../../components/card"
4 | import { CreateComment } from "../../components/create-comment"
5 | import { GoBack } from "../../components/go-back"
6 |
7 | export const CurrentPost = () => {
8 | const params = useParams<{ id: string }>()
9 | const { data } = useGetPostByIdQuery(params?.id ?? "")
10 |
11 | if (!data) {
12 | return Поста не существует
13 | }
14 |
15 | const {
16 | content,
17 | id,
18 | authorId,
19 | comments,
20 | likes,
21 | author,
22 | likedByUser,
23 | createdAt,
24 | } = data
25 |
26 | return (
27 | <>
28 |
29 |
41 |
42 |
43 |
44 |
45 | {data.comments
46 | ? data.comments.map((comment) => (
47 |
57 | ))
58 | : null}
59 |
60 | >
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/src/pages/followers/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux"
2 | import { selectCurrent } from "../../features/user/userSlice"
3 | import { Link } from "react-router-dom"
4 | import { Card, CardBody } from "@nextui-org/react"
5 | import { User } from "../../components/user"
6 |
7 | export const Followers = () => {
8 | const currentUser = useSelector(selectCurrent)
9 |
10 | if (!currentUser) {
11 | return null
12 | }
13 |
14 | return currentUser.followers.length > 0 ? (
15 |
16 | {currentUser.followers.map((user) => (
17 |
18 |
19 |
20 |
25 |
26 |
27 |
28 | ))}
29 |
30 | ) : (
31 | У вас нет подписчиков
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/pages/following/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux"
2 | import { selectCurrent } from "../../features/user/userSlice"
3 | import { Link } from "react-router-dom"
4 | import { Card, CardBody } from "@nextui-org/react"
5 | import { User } from "../../components/user"
6 |
7 | export const Following = () => {
8 | const currentUser = useSelector(selectCurrent)
9 |
10 | if (!currentUser) {
11 | return null
12 | }
13 |
14 | return currentUser.following.length > 0 ? (
15 |
16 | {currentUser.following.map((user) => (
17 |
18 |
19 |
20 |
25 |
26 |
27 |
28 | ))}
29 |
30 | ) : (
31 | Вы не подписаны ни на кого
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/pages/posts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from "../../components/card"
2 | import { CreatePost } from "../../components/create-post"
3 | import { useGetAllPostsQuery } from "../../app/services/postsApi"
4 |
5 | export const Posts = () => {
6 | const { data } = useGetAllPostsQuery()
7 |
8 | return (
9 | <>
10 |
11 |
12 |
13 | {data && data.length > 0
14 | ? data.map(
15 | ({
16 | content,
17 | author,
18 | id,
19 | authorId,
20 | comments,
21 | likes,
22 | likedByUser,
23 | createdAt,
24 | }) => (
25 |
38 | ),
39 | )
40 | : null}
41 | >
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/pages/user-profile/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react"
2 | import { useParams } from "react-router-dom"
3 | import {
4 | useGetUserByIdQuery,
5 | useLazyCurrentQuery,
6 | useLazyGetUserByIdQuery,
7 | } from "../../app/services/userApi"
8 | import { useDispatch, useSelector } from "react-redux"
9 | import { resetUser, selectCurrent } from "../../features/user/userSlice"
10 | import { Button, Card, Image } from "@nextui-org/react"
11 | import { MdOutlinePersonAddAlt1 } from "react-icons/md"
12 | import { MdOutlinePersonAddDisabled } from "react-icons/md"
13 | import { useDisclosure } from "@nextui-org/react"
14 | import {
15 | useFollowUserMutation,
16 | useUnfollowUserMutation,
17 | } from "../../app/services/followApi"
18 | import { GoBack } from "../../components/go-back"
19 | import { BASE_URL } from "../../constants"
20 | import { CiEdit } from "react-icons/ci"
21 | import { EditProfile } from "../../components/edit-profile"
22 | import { formatToClientDate } from "../../utils/format-to-client-date"
23 | import { ProfileInfo } from "../../components/profile-info"
24 | import { CountInfo } from "../../components/count-info"
25 |
26 | export const UserProfile = () => {
27 | const { id } = useParams<{ id: string }>()
28 | const { isOpen, onOpen, onClose } = useDisclosure()
29 | const currentUser = useSelector(selectCurrent)
30 | const { data } = useGetUserByIdQuery(id ?? "")
31 | const [followUser] = useFollowUserMutation()
32 | const [unfolowUser] = useUnfollowUserMutation()
33 | const [triggerGetUserByIdQuery] = useLazyGetUserByIdQuery()
34 | const [triggerCurrentQuery] = useLazyCurrentQuery()
35 |
36 | const dispatch = useDispatch()
37 |
38 | useEffect(
39 | () => () => {
40 | dispatch(resetUser())
41 | },
42 | [],
43 | )
44 |
45 | const handleFollow = async () => {
46 | try {
47 | if (id) {
48 | data?.isFollowing
49 | ? await unfolowUser(id).unwrap()
50 | : await followUser({ followingId: id }).unwrap()
51 |
52 | await triggerGetUserByIdQuery(id)
53 |
54 | await triggerCurrentQuery()
55 | }
56 | } catch (error) {
57 | console.log(error)
58 | }
59 | }
60 |
61 | const handleClose = async () => {
62 | try {
63 | if (id) {
64 | await triggerGetUserByIdQuery(id)
65 | await triggerCurrentQuery()
66 | onClose()
67 | }
68 | } catch (err) {
69 | console.log(err)
70 | }
71 | }
72 |
73 | if (!data) {
74 | return null
75 | }
76 |
77 | return (
78 | <>
79 |
80 |
81 |
82 |
89 |
90 | {data.name}
91 | {currentUser?.id !== id ? (
92 |
100 | ) : (
101 |
102 | )
103 | }
104 | >
105 | {data?.isFollowing ? 'Отписаться' : 'Подписаться'}
106 |
107 | ) : (
108 | }
110 | onClick={() => onOpen()}
111 | >
112 | Редактировать
113 |
114 | )}
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | >
131 | )
132 | }
133 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import "@testing-library/jest-dom"
3 |
--------------------------------------------------------------------------------
/src/utils/format-to-client-date.ts:
--------------------------------------------------------------------------------
1 | export const formatToClientDate = (date?: Date) => {
2 | if (!date) {
3 | return ''
4 | }
5 |
6 | return new Date(date).toLocaleDateString()
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/has-error-field.ts:
--------------------------------------------------------------------------------
1 | export function hasErrorField(
2 | err: unknown,
3 | ): err is { data: { error: string } } {
4 | return (
5 | typeof err === "object" &&
6 | err !== null &&
7 | "data" in err &&
8 | typeof err.data === "object" &&
9 | err.data !== null &&
10 | "error" in err.data
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { nextui } = require("@nextui-org/theme")
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | export default {
5 | content: [
6 | "./index.html",
7 | "./src/**/*.{js,ts,jsx,tsx}",
8 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
9 | ],
10 | theme: {
11 | extend: {},
12 | },
13 | darkMode: "class",
14 | plugins: [nextui()],
15 | }
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "module": "ESNext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "types": ["testing-library__jest-dom"]
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "bundler",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config"
2 | import react from "@vitejs/plugin-react"
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | open: true,
9 | },
10 | build: {
11 | outDir: "build",
12 | sourcemap: true,
13 | },
14 | test: {
15 | globals: true,
16 | environment: "jsdom",
17 | setupFiles: "src/setupTests",
18 | mockReset: true,
19 | },
20 | })
21 |
--------------------------------------------------------------------------------