├── .env.example
├── .eslintrc.json
├── .github
├── ISSUE_TEMPLATE
│ └── custom-template-for-quiz.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── production.yaml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierrc.json
├── Licence
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
├── MaplestoryOTFLight.woff2
├── favicon.png
├── icons
│ ├── close.svg
│ ├── hamburger_menu.svg
│ └── user_profile.svg
└── share.png
├── src
├── App.tsx
├── RouteChangeTracker.ts
├── apis
│ ├── auth.ts
│ ├── follow.ts
│ ├── getFollowUser.ts
│ ├── instance.ts
│ ├── notification.ts
│ ├── search.ts
│ ├── signup.ts
│ ├── story.ts
│ ├── userInfo.ts
│ └── userList.ts
├── assets
│ └── images
│ │ ├── defaultImage.png
│ │ └── notFound.png
├── components
│ ├── Follow
│ │ ├── FollowEmpty.tsx
│ │ ├── FollowModal.tsx
│ │ ├── FollowerButton.tsx
│ │ ├── FollowingButton.tsx
│ │ └── FollowingList.tsx
│ ├── Home
│ │ ├── NoResultBox.tsx
│ │ ├── SearchForm.tsx
│ │ ├── UserList.tsx
│ │ └── UserProfile.tsx
│ ├── Message
│ │ ├── MessageBubble.tsx
│ │ └── MessageInputForm.tsx
│ ├── Notification
│ │ ├── NotificationButton.tsx
│ │ ├── NotificationList.tsx
│ │ ├── NotificationMsg.tsx
│ │ ├── TabContainer.tsx
│ │ └── TabItem.tsx
│ ├── Profile
│ │ ├── ImageForm.tsx
│ │ ├── PasswordForm.tsx
│ │ ├── ProfileModal.tsx
│ │ └── TextForm.tsx
│ ├── SignIn
│ │ ├── SignInForm.tsx
│ │ └── SignInLinks.tsx
│ ├── SignUp
│ │ ├── SignUpButton.tsx
│ │ ├── SignUpInput.tsx
│ │ └── SignUpSelector.tsx
│ ├── Story
│ │ ├── CommentForm.tsx
│ │ ├── CommentList.tsx
│ │ ├── LikeButton.tsx
│ │ ├── StoryComment.tsx
│ │ └── StoryInfo.tsx
│ ├── StoryBook
│ │ ├── Empty.tsx
│ │ ├── FollowButton.tsx
│ │ ├── Loading.tsx
│ │ ├── StoriesByYear.tsx
│ │ ├── StoryAddButton.tsx
│ │ ├── StoryBookTitle.tsx
│ │ └── StoryCard.tsx
│ ├── StoryEdit
│ │ ├── DatePicker.tsx
│ │ ├── ImageUpload.tsx
│ │ ├── StoryEditForm.tsx
│ │ └── SubmitButton.tsx
│ └── shared
│ │ ├── DarkModeSwitch.tsx
│ │ ├── Header.tsx
│ │ ├── ScrollToTop.tsx
│ │ └── SignInButton.tsx
├── constants
│ ├── apiParams.ts
│ ├── apiUrls.ts
│ ├── auth.ts
│ ├── colors.ts
│ ├── errorMessages.ts
│ ├── http.ts
│ └── routes.ts
├── contexts
│ ├── DisplayModeContext.tsx
│ └── NotificationContext.tsx
├── hooks
│ ├── useCheckAuthToken.ts
│ ├── useComment.ts
│ ├── useDebounce.ts
│ ├── useFetchStories.ts
│ ├── useFetchUser.ts
│ ├── useGetFollow.ts
│ ├── useGetFollower.ts
│ ├── useInfiniteScroll.ts
│ ├── useIntersectionObserver.ts
│ ├── useIsOverByScroll.ts
│ ├── useLazyLoadImage.ts
│ ├── useLike.ts
│ ├── useSearchForm.ts
│ ├── useSignInForm.ts
│ ├── useSignUpForm.ts
│ ├── useStory.ts
│ └── useTimeoutFn.ts
├── interfaces
│ ├── comment.ts
│ ├── displayMode.ts
│ ├── followList.ts
│ ├── like.ts
│ ├── message.ts
│ ├── notification.ts
│ ├── signUp.ts
│ ├── story.ts
│ └── user.ts
├── main.tsx
├── pages
│ ├── 404.tsx
│ ├── Chat.tsx
│ ├── Home.tsx
│ ├── Notification.tsx
│ ├── Profile.tsx
│ ├── SignIn.tsx
│ ├── SignUp.tsx
│ ├── Story.tsx
│ ├── StoryBook.tsx
│ ├── StoryEdit.tsx
│ └── follow.tsx
├── styles
│ └── GlobalStyle.tsx
├── utils
│ ├── calcCreatedToCurrentTime.ts
│ ├── getChangedIndex.ts
│ ├── getF4FId.ts
│ ├── helpers.ts
│ ├── setUserListImageFirst.ts
│ ├── signUpIsValid.ts
│ ├── signUpValidate.ts
│ ├── storage.ts
│ ├── validationSearchForm.ts
│ └── validations.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_API_URL=KDT 프론트엔드 팀 프로젝트 API URL
2 | VITE_GOOGLE_ANALYTICS_TRACKING_ID=구글 애널리틱스 추척 ID
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "settings": {
7 | "react": {
8 | "version": "detect"
9 | }
10 | },
11 | "extends": [
12 | "eslint:recommended",
13 | "plugin:react/recommended",
14 | "plugin:@typescript-eslint/recommended",
15 | "prettier"
16 | ],
17 | "overrides": [],
18 | "parser": "@typescript-eslint/parser",
19 | "parserOptions": {
20 | "ecmaVersion": "latest",
21 | "sourceType": "module"
22 | },
23 | "plugins": [
24 | "react",
25 | "prettier",
26 | "@typescript-eslint",
27 | "simple-import-sort",
28 | "import"
29 | ],
30 | "rules": {
31 | "simple-import-sort/imports": "error",
32 | "simple-import-sort/exports": "error",
33 | "import/first": "error",
34 | "import/newline-after-import": "error",
35 | "import/no-duplicates": "error",
36 | "prettier/prettier": [
37 | "error",
38 | { "singleQuote": true, "endOfLine": "auto", "bracketSameLine": true }
39 | ],
40 | "react/react-in-jsx-scope": "off",
41 | "no-undef": "off",
42 | "@typescript-eslint/no-unused-vars": "error"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/custom-template-for-quiz.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 이슈 이름
3 | about: Describe this issue template's purpose here.
4 | title: 이슈 이름
5 | labels: 이슈 이름
6 | assignees: ''
7 | ---
8 |
9 | ## 작업 목록
10 |
11 | - 큰 틀
12 | - 작은 틀
13 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # 이슈 번호가 있다면 적어주세요
2 |
3 | ## 작업 목록
4 |
5 | ### 참고 사진이 있다면 첨부
6 |
7 | ## 예정
8 |
9 | ## 궁금한 점
10 |
--------------------------------------------------------------------------------
/.github/workflows/production.yaml:
--------------------------------------------------------------------------------
1 | name: Vercel Production Deployment
2 | env:
3 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
4 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
5 | on:
6 | push:
7 | branches-ignore:
8 | - main
9 | jobs:
10 | deploy:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-node@v3
15 | with:
16 | node-version: 18
17 | - name: create vercel.json
18 | run: |
19 | touch vercel.json
20 | echo '
21 | {
22 | "rewrites": [
23 | {
24 | "source": "/api/:url*",
25 | "destination": "${{ secrets.API_BASE_URL }}/:url*"
26 | },
27 | {
28 | "source": "/(.*)",
29 | "destination": "/"
30 | }
31 | ]
32 | }
33 | ' >> vercel.json
34 | - name: Install Vercel CLI
35 | run: npm install --global vercel@latest
36 | - name: Pull Vercel Environment Information
37 | run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
38 | - name: Build Project Artifacts
39 | run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
40 | - name: Deploy Project Artifacts to Vercel
41 | run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
42 |
--------------------------------------------------------------------------------
/.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 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # 환경변수
27 | .env
28 | .vercel
29 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "endOfLine": "auto",
4 | "jsxSingleQuote": true,
5 | "bracketSameLine": true
6 | }
7 |
--------------------------------------------------------------------------------
/Licence:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Bigtoria
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bigtoria",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview",
10 | "lint": "eslint .",
11 | "prepare": "husky install",
12 | "lint-staged": "lint-staged"
13 | },
14 | "dependencies": {
15 | "@emotion/react": "^11.10.5",
16 | "@emotion/styled": "^11.10.5",
17 | "@mui/icons-material": "^5.11.0",
18 | "@mui/material": "^5.11.3",
19 | "@mui/x-date-pickers": "^5.0.13",
20 | "axios": "^1.2.2",
21 | "dayjs": "^1.11.7",
22 | "react": "^18.2.0",
23 | "react-dom": "^18.2.0",
24 | "react-ga": "^3.3.1",
25 | "react-icons": "^4.7.1",
26 | "react-router-dom": "^6.6.1",
27 | "swr": "^2.0.1"
28 | },
29 | "devDependencies": {
30 | "@types/react": "^18.0.26",
31 | "@types/react-dom": "^18.0.10",
32 | "@typescript-eslint/eslint-plugin": "^5.47.1",
33 | "@typescript-eslint/parser": "^5.47.1",
34 | "@vitejs/plugin-react": "^3.0.0",
35 | "eslint": "^8.31.0",
36 | "eslint-config-prettier": "^8.5.0",
37 | "eslint-plugin-import": "^2.26.0",
38 | "eslint-plugin-prettier": "^4.2.1",
39 | "eslint-plugin-react": "^7.31.11",
40 | "eslint-plugin-simple-import-sort": "^8.0.0",
41 | "husky": "^8.0.0",
42 | "lint-staged": "^13.1.0",
43 | "prettier": "^2.8.1",
44 | "typescript": "^4.9.3",
45 | "vite": "^4.0.0"
46 | },
47 | "lint-staged": {
48 | "**/*.{ts,tsx}": [
49 | "eslint --fix .",
50 | "prettier --write --cache ."
51 | ]
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/public/MaplestoryOTFLight.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_Bigtoria_Off/542fb9c74f19ea54796f73a2ab5300c03b8c2461/public/MaplestoryOTFLight.woff2
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_Bigtoria_Off/542fb9c74f19ea54796f73a2ab5300c03b8c2461/public/favicon.png
--------------------------------------------------------------------------------
/public/icons/close.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/public/icons/hamburger_menu.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/icons/user_profile.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_Bigtoria_Off/542fb9c74f19ea54796f73a2ab5300c03b8c2461/public/share.png
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes } from 'react-router-dom';
2 |
3 | import Header from './components/shared/Header';
4 | import ScrollToTop from './components/shared/ScrollToTop';
5 | import { ROUTES } from './constants/routes';
6 | import NotFound from './pages/404';
7 | import Chat from './pages/Chat';
8 | import Follow from './pages/follow';
9 | import Home from './pages/Home';
10 | import Notification from './pages/Notification';
11 | import Profile from './pages/Profile';
12 | import SignIn from './pages/SignIn';
13 | import SignUp from './pages/SignUp';
14 | import Story from './pages/Story';
15 | import StoryBook from './pages/StoryBook';
16 | import StoryEdit from './pages/StoryEdit';
17 | import RouteChangeTracker from './RouteChangeTracker';
18 |
19 | const App = () => {
20 | RouteChangeTracker();
21 |
22 | return (
23 | <>
24 |
25 |
26 |
27 | } />
28 | } />
29 | } />
30 | } />
31 | } />
32 | } />
33 | } />
34 | } />
35 | } />
36 | } />
37 | } />
38 |
39 | >
40 | );
41 | };
42 |
43 | export default App;
44 |
--------------------------------------------------------------------------------
/src/RouteChangeTracker.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import ReactGA from 'react-ga';
3 | import { useLocation } from 'react-router-dom';
4 |
5 | const RouteChangeTracker = () => {
6 | const location = useLocation();
7 | const [initialized, setInitialized] = useState(false);
8 |
9 | useEffect(() => {
10 | if (import.meta.env.VITE_GOOGLE_ANALYTICS_TRACKING_ID) {
11 | ReactGA.initialize(import.meta.env.VITE_GOOGLE_ANALYTICS_TRACKING_ID);
12 | }
13 | setInitialized(true);
14 | }, []);
15 |
16 | useEffect(() => {
17 | if (initialized) {
18 | ReactGA.pageview(location.pathname + location.search);
19 | }
20 | }, [initialized, location]);
21 | };
22 |
23 | export default RouteChangeTracker;
24 |
--------------------------------------------------------------------------------
/src/apis/auth.ts:
--------------------------------------------------------------------------------
1 | import { isAxiosError } from 'axios';
2 |
3 | import { API_URLS } from '../constants/apiUrls';
4 | import { TOKEN_KEY, USER_ID_KEY } from '../constants/auth';
5 | import { HTTP_STATUS_CODE } from '../constants/http';
6 | import { ROUTES } from '../constants/routes';
7 | import { removeLocalStorage, setLocalStorage } from '../utils/storage';
8 | import http from './instance';
9 |
10 | export const signin = async ({
11 | email,
12 | password,
13 | }: {
14 | email: string;
15 | password: string;
16 | }) => {
17 | try {
18 | const {
19 | data: {
20 | token,
21 | user: { _id: userId },
22 | },
23 | } = await http.post({
24 | url: API_URLS.AUTH.LOGIN,
25 | data: {
26 | email,
27 | password,
28 | },
29 | });
30 |
31 | token && setLocalStorage(TOKEN_KEY, token);
32 | userId && setLocalStorage(USER_ID_KEY, userId);
33 | } catch (error) {
34 | console.error(error);
35 | if (error && isAxiosError(error)) {
36 | return { isSignInFailed: true, errorMessage: error.response?.data };
37 | }
38 | }
39 |
40 | return { isSignInFailed: false, errorMessage: '' };
41 | };
42 |
43 | export const signout = async () => {
44 | try {
45 | const { status } = await http.post({
46 | url: API_URLS.AUTH.LOGOUT,
47 | });
48 |
49 | if (status === HTTP_STATUS_CODE.OK) {
50 | removeLocalStorage(TOKEN_KEY);
51 | removeLocalStorage(USER_ID_KEY);
52 | location.href = ROUTES.HOME;
53 | }
54 | } catch (error) {
55 | console.error(error);
56 | }
57 | };
58 |
59 | export const checkAuth = async () => {
60 | const { data: user } = await http.get({
61 | url: API_URLS.AUTH.CHECK_AUTH,
62 | });
63 |
64 | return user;
65 | };
66 |
--------------------------------------------------------------------------------
/src/apis/follow.ts:
--------------------------------------------------------------------------------
1 | import http from './instance';
2 |
3 | export const removeFollow = async (id: string) => {
4 | try {
5 | return await http.delete({
6 | url: '/follow/delete',
7 | data: {
8 | id,
9 | },
10 | });
11 | } catch (error) {
12 | console.error(error);
13 | }
14 | };
15 |
16 | export const createFollow = async (userId: string) => {
17 | try {
18 | return await http.post({
19 | url: '/follow/create',
20 | data: {
21 | userId,
22 | },
23 | });
24 | } catch (error) {
25 | console.error(error);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/src/apis/getFollowUser.ts:
--------------------------------------------------------------------------------
1 | import { userInfo } from './userInfo';
2 |
3 | export const getFollowingUser = async (followingIdList: string[]) => {
4 | return await Promise.all(
5 | followingIdList.map((followingId) => userInfo(followingId))
6 | );
7 | };
8 |
9 | export const getFollowerUser = async (followerList: string[]) => {
10 | return await Promise.all(
11 | followerList.map((followerId) => userInfo(followerId))
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/src/apis/instance.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance, AxiosRequestConfig, Method } from 'axios';
2 |
3 | import { TOKEN_KEY } from '../constants/auth';
4 | import { HTTP_METHODS } from '../constants/http';
5 | import { getLocalStorage } from '../utils/storage';
6 |
7 | const API_BASE_URL =
8 | import.meta.env.MODE === 'development'
9 | ? import.meta.env.VITE_API_URL
10 | : '/api';
11 |
12 | const axiosInstance: AxiosInstance = axios.create({
13 | baseURL: API_BASE_URL,
14 | });
15 |
16 | const handleRequest = (config: AxiosRequestConfig) => {
17 | const token = getLocalStorage(TOKEN_KEY);
18 |
19 | return token
20 | ? ({
21 | ...config,
22 | headers: {
23 | ...config.headers,
24 | Authorization: `Bearer ${token}`,
25 | },
26 | } as AxiosRequestConfig)
27 | : config;
28 | };
29 |
30 | const createApiMethod =
31 | (axiosInstance: AxiosInstance, methodType: Method) =>
32 | (config: AxiosRequestConfig) => {
33 | return axiosInstance({ ...handleRequest(config), method: methodType });
34 | };
35 |
36 | export default {
37 | get: createApiMethod(axiosInstance, HTTP_METHODS.GET),
38 | post: createApiMethod(axiosInstance, HTTP_METHODS.POST),
39 | put: createApiMethod(axiosInstance, HTTP_METHODS.PUT),
40 | delete: createApiMethod(axiosInstance, HTTP_METHODS.DELETE),
41 | };
42 |
--------------------------------------------------------------------------------
/src/apis/notification.ts:
--------------------------------------------------------------------------------
1 | import { API_URLS } from '../constants/apiUrls';
2 | import { TOKEN_KEY } from '../constants/auth';
3 | import { getLocalStorage } from '../utils/storage';
4 | import http from './instance';
5 |
6 | const { NOTIFICATION } = API_URLS;
7 |
8 | export const getNotificationList = async () => {
9 | const token = getLocalStorage(TOKEN_KEY);
10 | if (!token) return null;
11 |
12 | try {
13 | const { data: notificationList } = await http.get({
14 | url: NOTIFICATION.GET_NOTIFICATIONS,
15 | });
16 |
17 | return notificationList;
18 | } catch (error) {
19 | console.error(error);
20 | return null;
21 | }
22 | };
23 |
24 | export const postNotification = async (
25 | type: 'COMMENT' | 'FOLLOW' | 'LIKE' | 'MESSAGE',
26 | typeId: string,
27 | userId: string,
28 | postId: string | null
29 | ) => {
30 | const { data: notification } = await http.post({
31 | url: API_URLS.NOTIFICATION.CREATE_NOTIFICATION,
32 | data: {
33 | notificationType: type,
34 | notificationTypeId: typeId,
35 | userId,
36 | postId,
37 | },
38 | });
39 |
40 | return notification;
41 | };
42 |
43 | export const checkNotificationSeen = async () => {
44 | try {
45 | const { data } = await http.put({
46 | url: NOTIFICATION.UPDATE_NOTIFICATION,
47 | });
48 |
49 | return data;
50 | } catch (error) {
51 | console.error(error);
52 | return null;
53 | }
54 | };
55 |
--------------------------------------------------------------------------------
/src/apis/search.ts:
--------------------------------------------------------------------------------
1 | import { DATA_LIMIT } from '../constants/apiParams';
2 | import { API_URLS } from '../constants/apiUrls';
3 | import { User } from '../interfaces/user';
4 | import { setUserListImageFirst } from '../utils/setUserListImageFirst';
5 | import http from './instance';
6 |
7 | const { USER, SEARCH } = API_URLS;
8 |
9 | export const getUserList = async (offset = 0) => {
10 | try {
11 | const { data: userList } = await http.get({
12 | url: USER.GET_USERS,
13 | params: {
14 | limit: DATA_LIMIT,
15 | offset,
16 | },
17 | });
18 |
19 | return userList;
20 | } catch (error) {
21 | console.error(error);
22 | }
23 | };
24 |
25 | export const searchUserList = async (keyword: string) => {
26 | try {
27 | const { data: userList } = await http.get({
28 | url: SEARCH.GET_USERS_BY_QUERY(keyword),
29 | });
30 |
31 | const filteredFullNameMatchUsers = userList.filter((u: User) => {
32 | const { fullName } = u;
33 |
34 | if (fullName.toLowerCase().match(keyword.toLowerCase())) return true;
35 | return false;
36 | });
37 |
38 | const imageFirstUsers = setUserListImageFirst(filteredFullNameMatchUsers);
39 |
40 | return imageFirstUsers;
41 | // return filteredFullNameMatchUsers;
42 | } catch (error) {
43 | console.error(error);
44 | }
45 | };
46 |
--------------------------------------------------------------------------------
/src/apis/signup.ts:
--------------------------------------------------------------------------------
1 | import { args } from '../interfaces/signUp';
2 | import http from './instance';
3 |
4 | export const postSignUp = async (values: args) => {
5 | await http.post({
6 | url: '/signup',
7 | data: {
8 | email: values.email,
9 | fullName: values.fullName,
10 | password: values.password,
11 | username: JSON.stringify({
12 | year: values.date.year,
13 | month: values.date.month,
14 | day: values.date.day,
15 | job: values.job,
16 | }),
17 | },
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/src/apis/story.ts:
--------------------------------------------------------------------------------
1 | import { API_URLS } from './../constants/apiUrls';
2 | import http from './instance';
3 |
4 | export const getStoriesOfUser = async (userId: string) => {
5 | const { data: stories } = await http.get({
6 | url: API_URLS.POST.GET_POSTS_OF_SPECIFIC_USER(userId),
7 | });
8 |
9 | return stories;
10 | };
11 |
12 | export const getStoriesOfChannel = async (channelId: string) => {
13 | const { data: stories } = await http.get({
14 | url: API_URLS.POST.GET_POSTS_OF_SPECIFIC_CHANNEL(channelId),
15 | });
16 |
17 | return stories;
18 | };
19 |
20 | export const getStoryDetail = async (storyId: string) => {
21 | const { data: story } = await http.get({
22 | url: API_URLS.POST.GET_POST_DETAIL(storyId),
23 | });
24 |
25 | return story;
26 | };
27 |
28 | export const postStory = async (formData: FormData) => {
29 | const { data: story } = await http.post({
30 | url: API_URLS.POST.CREATE_POST_ON_SPECIFIC_CHANNEL,
31 | headers: {
32 | 'Content-Type': 'multipart/form-data',
33 | },
34 | data: formData,
35 | });
36 |
37 | return story;
38 | };
39 |
40 | export const putStory = async (formData: FormData) => {
41 | const { data: story } = await http.put({
42 | url: API_URLS.POST.UPDATE_POST,
43 | headers: {
44 | 'Content-Type': 'multipart/form-data',
45 | },
46 | data: formData,
47 | });
48 |
49 | return story;
50 | };
51 |
52 | export const deleteStory = async (storyId: string) => {
53 | await http.delete({
54 | url: API_URLS.POST.DELETE_POST,
55 | headers: {
56 | 'Content-Type': 'application/json',
57 | },
58 | data: JSON.stringify({
59 | id: storyId,
60 | }),
61 | });
62 | };
63 |
64 | export const postStoryComment = async (comment: string, storyId: string) => {
65 | const { data: commentData } = await http.post({
66 | url: API_URLS.COMMENT.CREATE_COMMENT,
67 | headers: {
68 | 'Content-Type': 'application/json',
69 | },
70 | data: JSON.stringify({
71 | comment,
72 | postId: storyId,
73 | }),
74 | });
75 |
76 | return commentData;
77 | };
78 |
79 | export const deleteStoryComment = async (commentId: string) => {
80 | await http.delete({
81 | url: API_URLS.COMMENT.DELETE_COMMENT,
82 | headers: {
83 | 'Content-Type': 'application/json',
84 | },
85 | data: JSON.stringify({
86 | id: commentId,
87 | }),
88 | });
89 | };
90 |
91 | export const postStoryLike = async (storyId: string) => {
92 | const { data: like } = await http.post({
93 | url: API_URLS.LIKE.CREATE_LIKE,
94 | headers: {
95 | 'Content-Type': 'application/json',
96 | },
97 | data: JSON.stringify({
98 | postId: storyId,
99 | }),
100 | });
101 |
102 | return like;
103 | };
104 |
105 | export const deleteStoryLike = async (userId: string) => {
106 | await http.delete({
107 | url: API_URLS.LIKE.DELETE_LIKE,
108 | headers: {
109 | 'Content-Type': 'application/json',
110 | },
111 | data: JSON.stringify({
112 | id: userId,
113 | }),
114 | });
115 | };
116 |
--------------------------------------------------------------------------------
/src/apis/userInfo.ts:
--------------------------------------------------------------------------------
1 | import { API_URLS } from './../constants/apiUrls';
2 | import http from './instance';
3 |
4 | export const userInfo = async (userId: string) => {
5 | try {
6 | const { data } = await http.get({
7 | url: API_URLS.USER.GET_USER_INFO(userId),
8 | });
9 | return data;
10 | } catch (error) {
11 | console.error(error);
12 | }
13 | };
14 |
15 | export const postProfileImage = async (formData: FormData) => {
16 | const { data: user } = await http.post({
17 | url: API_URLS.USER.UPDATE_PROFILE_IMAGE,
18 | headers: {
19 | 'Content-Type': 'multipart/form-data',
20 | },
21 | data: formData,
22 | });
23 |
24 | return user;
25 | };
26 |
27 | export const postCoverImage = async (formData: FormData) => {
28 | const { data: user } = await http.post({
29 | url: API_URLS.USER.UPDATE_COVER_IMAGE,
30 | headers: {
31 | 'Content-Type': 'multipart/form-data',
32 | },
33 | data: formData,
34 | });
35 |
36 | return user;
37 | };
38 |
39 | export const putUserInfo = async (fullName: string, username: string) => {
40 | const { data: user } = await http.put({
41 | url: API_URLS.SETTING.UPDATE_MY_INFO,
42 | headers: {
43 | 'Content-Type': 'application/json',
44 | },
45 | data: JSON.stringify({
46 | fullName,
47 | username,
48 | }),
49 | });
50 |
51 | return user;
52 | };
53 |
54 | export const putPassword = async (password: string) => {
55 | await http.put({
56 | url: API_URLS.SETTING.UPDATE_MY_PASSWORD,
57 | headers: {
58 | 'Content-Type': 'application/json',
59 | },
60 | data: JSON.stringify({
61 | password,
62 | }),
63 | });
64 | };
65 |
--------------------------------------------------------------------------------
/src/apis/userList.ts:
--------------------------------------------------------------------------------
1 | import { API_URLS } from './../constants/apiUrls';
2 | import http from './instance';
3 |
4 | export const getUserList = async () => {
5 | try {
6 | const { data } = await http.get({
7 | url: API_URLS.USER.GET_USERS,
8 | });
9 | return data;
10 | } catch (error) {
11 | console.error(error);
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/src/assets/images/defaultImage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_Bigtoria_Off/542fb9c74f19ea54796f73a2ab5300c03b8c2461/src/assets/images/defaultImage.png
--------------------------------------------------------------------------------
/src/assets/images/notFound.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_Bigtoria_Off/542fb9c74f19ea54796f73a2ab5300c03b8c2461/src/assets/images/notFound.png
--------------------------------------------------------------------------------
/src/components/Follow/FollowEmpty.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@mui/material';
2 |
3 | interface Props {
4 | type: string;
5 | }
6 |
7 | const FollowEmpty = ({ type }: Props) => {
8 | return (
9 |
16 | {type === 'following' ? (
17 | 팔로우가 비어있어요...ㅠ.ㅠ
18 | ) : (
19 | 팔로워가 비어있어요...ㅠ.ㅠ
20 | )}
21 |
22 | );
23 | };
24 |
25 | export default FollowEmpty;
26 |
--------------------------------------------------------------------------------
/src/components/Follow/FollowModal.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import AutoStoriesTwoToneIcon from '@mui/icons-material/AutoStoriesTwoTone';
3 | import CakeTwoToneIcon from '@mui/icons-material/CakeTwoTone';
4 | import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined';
5 | import SendTwoToneIcon from '@mui/icons-material/SendTwoTone';
6 | import WorkHistoryTwoToneIcon from '@mui/icons-material/WorkHistoryTwoTone';
7 | import {
8 | Avatar,
9 | Chip,
10 | createTheme,
11 | Dialog,
12 | Divider,
13 | Stack,
14 | ThemeProvider,
15 | } from '@mui/material';
16 | import Box from '@mui/material/Box';
17 | import Button from '@mui/material/Button';
18 | import IconButton from '@mui/material/IconButton';
19 | import { useNavigate, useParams } from 'react-router-dom';
20 |
21 | import { ROUTES } from '../../constants/routes';
22 |
23 | interface Props {
24 | open: boolean;
25 | userInfo: {
26 | image?: string;
27 | user: string;
28 | fullName?: string;
29 | username?: string;
30 | coverImage?: string;
31 | isOnline?: boolean;
32 | };
33 | onClick: () => void;
34 | }
35 |
36 | const FollowModal = ({ open, userInfo, onClick }: Props) => {
37 | const { userId } = useParams();
38 | const navigate = useNavigate();
39 | const { job, year } = userInfo.username && JSON.parse(userInfo.username);
40 |
41 | const handleClickStoryBook = () => {
42 | if (userId && userId === userInfo.user) {
43 | onClick();
44 | }
45 |
46 | navigate(ROUTES.STORY_BOOK_BY_USER_ID(userInfo.user));
47 | };
48 |
49 | return (
50 |
51 |
129 |
130 | );
131 | };
132 |
133 | export default FollowModal;
134 |
135 | const Container = styled(Box)`
136 | display: flex;
137 | flex-direction: column;
138 | width: 300px;
139 | overflow: hidden;
140 | `;
141 |
142 | const ImageContainer = styled.div`
143 | position: relative;
144 | z-index: 2;
145 | margin-bottom: 50px;
146 | `;
147 |
148 | const CoverImageWrapper = styled.div`
149 | position: relative;
150 | `;
151 |
152 | const CoverImage = styled.img<{ src: string | undefined }>`
153 | width: 100%;
154 | height: 200px;
155 | background: ${(props) => (props.src ? `url(${props.src})` : 'lightgray')};
156 | object-fit: cover;
157 | `;
158 |
159 | const ProfileImageWrapper = styled.div`
160 | position: absolute;
161 | display: flex;
162 | flex-direction: column;
163 | align-items: center;
164 | margin-left: -50px;
165 | bottom: -50px;
166 | left: 50%;
167 | `;
168 |
169 | const TextContainer = styled.div`
170 | display: flex;
171 | flex-direction: column;
172 | align-items: center;
173 | padding: 10px;
174 | box-sizing: border-box;
175 | `;
176 |
177 | const theme = createTheme({
178 | typography: {
179 | fontFamily: "'MaplestoryOTFLight', cursive",
180 | },
181 | components: {
182 | MuiPaper: {
183 | styleOverrides: {
184 | root: {
185 | borderRadius: '5%',
186 | },
187 | },
188 | },
189 | MuiButton: {
190 | styleOverrides: {
191 | root: {
192 | borderRadius: '0',
193 | border: '0',
194 | borderTop: '1px solid rgba(237, 108, 2, 0.5)',
195 | },
196 | },
197 | },
198 | },
199 | });
200 |
--------------------------------------------------------------------------------
/src/components/Follow/FollowerButton.tsx:
--------------------------------------------------------------------------------
1 | import FavoriteIcon from '@mui/icons-material/Favorite';
2 | import { Box, Button, Chip, createTheme, ThemeProvider } from '@mui/material';
3 | import { pink } from '@mui/material/colors';
4 | import { MouseEvent, useLayoutEffect, useState } from 'react';
5 |
6 | import { getF4FId } from '../../utils/getF4FId';
7 |
8 | interface Props {
9 | followId?: string;
10 | userId: string;
11 | f4f: string[][];
12 | onClick: (e: MouseEvent) => Promise;
13 | }
14 |
15 | const FollowerButton = ({ followId, userId, f4f, onClick }: Props) => {
16 | const isF4f = getF4FId(f4f).includes(userId); //맞팔중인 아이디 확인
17 | const [isFollower, setIsFollower] = useState(false);
18 |
19 | useLayoutEffect(() => {
20 | if (isF4f) setIsFollower(true);
21 | }, []);
22 |
23 | return (
24 |
25 |
32 | {isFollower ? (
33 |
34 | }
36 | label='맞팔중'
37 | variant='outlined'
38 | color='primary'
39 | />
40 |
41 | ) : (
42 |
62 | )}
63 |
64 |
65 | );
66 | };
67 |
68 | export default FollowerButton;
69 |
70 | const chipTheme = createTheme({
71 | palette: {
72 | primary: {
73 | main: pink[500],
74 | },
75 | },
76 | typography: {
77 | fontFamily: "'MaplestoryOTFLight', cursive",
78 | },
79 | });
80 |
--------------------------------------------------------------------------------
/src/components/Follow/FollowingButton.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button } from '@mui/material';
2 | import { MouseEvent, useState } from 'react';
3 |
4 | interface Props {
5 | isLoading: boolean;
6 | followId: string;
7 | userId: string;
8 | onClick: (e: MouseEvent) => Promise;
9 | }
10 |
11 | const FollowingButton = ({ isLoading, followId, userId, onClick }: Props) => {
12 | const [isFollowing, setIsFollowing] = useState(false);
13 |
14 | return (
15 |
16 |
23 |
44 |
45 |
46 | );
47 | };
48 |
49 | export default FollowingButton;
50 |
--------------------------------------------------------------------------------
/src/components/Follow/FollowingList.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | Badge,
4 | BadgeProps,
5 | List,
6 | ListItem,
7 | ListItemAvatar,
8 | ListItemText,
9 | } from '@mui/material';
10 | import { styled } from '@mui/material/styles';
11 | import { useState } from 'react';
12 |
13 | import { COLORS } from '../../constants/colors';
14 | import useDisplayModeContext from '../../contexts/DisplayModeContext';
15 | import FollowModal from './FollowModal';
16 |
17 | interface Props {
18 | userInfo: {
19 | image?: string;
20 | user: string;
21 | fullName?: string;
22 | username?: string;
23 | coverImage?: string;
24 | isOnline?: boolean;
25 | };
26 | }
27 |
28 | const FollowingList = ({ userInfo }: Props) => {
29 | const [open, setOpen] = useState(false);
30 | const handleClick = () => setOpen(!open);
31 | const { displayMode } = useDisplayModeContext();
32 |
33 | return (
34 |
42 |
43 |
44 |
49 |
58 |
59 |
60 |
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export default FollowingList;
74 |
75 | interface StateBadgeProps extends BadgeProps {
76 | isOnline?: boolean;
77 | }
78 |
79 | const StateBadge = styled(Badge, {
80 | shouldForwardProp: (prop) => prop !== 'isOnline',
81 | })(({ theme, isOnline }) => ({
82 | '& .MuiBadge-badge': {
83 | ...(isOnline
84 | ? { backgroundColor: '#44b700', color: '#44b700' }
85 | : { backgroundColor: '#7b7b7b', color: '#7b7b7b' }),
86 | boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
87 | '&::after': {
88 | position: 'absolute',
89 | width: '100%',
90 | height: '100%',
91 | borderRadius: '50%',
92 | content: '""',
93 | ...(isOnline && {
94 | animation: 'ripple 1s infinite ease-in-out',
95 | border: '1px solid currentColor',
96 | }),
97 | },
98 | },
99 | '@keyframes ripple': {
100 | '0%': {
101 | transform: 'scale(.8)',
102 | opacity: 1,
103 | },
104 | '100%': {
105 | transform: 'scale(2)',
106 | opacity: 0,
107 | },
108 | },
109 | }));
110 |
--------------------------------------------------------------------------------
/src/components/Home/NoResultBox.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from '@mui/material';
2 |
3 | import useDisplayModeContext from '../../contexts/DisplayModeContext';
4 |
5 | const NoResultBox = () => {
6 | const { displayMode } = useDisplayModeContext();
7 |
8 | return (
9 |
17 |
27 | No Results...
28 |
29 |
30 | );
31 | };
32 |
33 | export default NoResultBox;
34 |
--------------------------------------------------------------------------------
/src/components/Home/SearchForm.tsx:
--------------------------------------------------------------------------------
1 | import { HighlightOff } from '@mui/icons-material';
2 | import { Box, IconButton, TextField } from '@mui/material';
3 |
4 | import useDisplayModeContext from '../../contexts/DisplayModeContext';
5 | import useSearchForm from '../../hooks/useSearchForm';
6 |
7 | interface Props {
8 | onSubmit: (keyword: string) => void;
9 | }
10 |
11 | const SearchForm = ({ onSubmit }: Props) => {
12 | const {
13 | value,
14 | error,
15 | handleInputChange,
16 | handleInputClear,
17 | handleFormSubmit,
18 | } = useSearchForm({ onSubmit });
19 | const { displayMode } = useDisplayModeContext();
20 |
21 | return (
22 |
29 |
37 |
54 | {value && (
55 |
65 |
66 |
67 | )}
68 |
69 |
70 | );
71 | };
72 |
73 | export default SearchForm;
74 |
--------------------------------------------------------------------------------
/src/components/Home/UserList.tsx:
--------------------------------------------------------------------------------
1 | import { List } from '@mui/material';
2 |
3 | import { ROUTES } from '../../constants/routes';
4 | import { User } from '../../interfaces/user';
5 | import NoResultBox from './NoResultBox';
6 | import UserProfile from './UserProfile';
7 |
8 | interface Props {
9 | users: User[] | null;
10 | }
11 |
12 | const { STORY_BOOK_BY_USER_ID } = ROUTES;
13 |
14 | const UserList = ({ users }: Props) => {
15 | return (
16 | <>
17 | {!users || users.length === 0 ? (
18 |
19 | ) : (
20 |
24 | {users.map(({ _id, image, fullName, username }) => {
25 | const job = username ? JSON.parse(username).job : '';
26 | const year = username ? JSON.parse(username).year : '';
27 |
28 | return (
29 |
37 | );
38 | })}
39 |
40 | )}
41 | >
42 | );
43 | };
44 |
45 | export default UserList;
46 |
--------------------------------------------------------------------------------
/src/components/Home/UserProfile.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | ListItem,
4 | ListItemAvatar,
5 | ListItemButton,
6 | ListItemText,
7 | Typography,
8 | } from '@mui/material';
9 | import React from 'react';
10 | import { useNavigate } from 'react-router-dom';
11 |
12 | import { COLORS } from '../../constants/colors';
13 |
14 | interface Props {
15 | path: string;
16 | image: string | undefined;
17 | fullName: string;
18 | job: string;
19 | year: string;
20 | }
21 |
22 | const UserProfile = ({ path, image, fullName, job, year }: Props) => {
23 | const navigate = useNavigate();
24 |
25 | return (
26 |
33 | navigate(path)}
35 | sx={{ padding: '18px 20px', borderRadius: 3.5 }}>
36 |
37 |
46 |
47 |
51 |
52 | {year}
53 |
54 |
57 | {job}
58 |
59 |
60 | }>
61 |
62 |
63 | );
64 | };
65 |
66 | export default UserProfile;
67 |
--------------------------------------------------------------------------------
/src/components/Message/MessageBubble.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Avatar } from '@mui/material';
3 |
4 | import { USER_ID_KEY } from '../../constants/auth';
5 | import { Message } from '../../interfaces/message';
6 | import { getLocalStorage } from '../../utils/storage';
7 |
8 | interface Prop {
9 | specificUser: Message;
10 | }
11 |
12 | const MessageBubble = ({ specificUser }: Prop) => {
13 | const loginID = getLocalStorage(USER_ID_KEY);
14 | const isReceiver = specificUser.sender._id !== loginID;
15 | return (
16 |
17 | {isReceiver && }
18 |
19 | {isReceiver && {specificUser.sender.fullName}}
20 | {specificUser.message}
21 |
22 |
23 | );
24 | };
25 |
26 | export default MessageBubble;
27 |
28 | const BubbleWrap = styled.div`
29 | margin: 20px;
30 | display: flex;
31 | `;
32 |
33 | const ContentsWrap = styled.div`
34 | width: 100%;
35 | justify-self: flex-end;
36 | `;
37 |
38 | const MessageAtom = styled.div<{ fromUser: boolean }>`
39 | max-width: 300px;
40 | background-color: #eee;
41 | padding: 8px 12px;
42 | border-radius: 8px;
43 | text-align: ${(props) => (props.fromUser ? null : 'right')};
44 | float: ${(props) => (props.fromUser ? 'left' : 'right')};
45 | `;
46 |
47 | const UserName = styled.div`
48 | font-size: 0.9em;
49 | `;
50 |
--------------------------------------------------------------------------------
/src/components/Message/MessageInputForm.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import SendIcon from '@mui/icons-material/Send';
3 | import { useEffect, useRef } from 'react';
4 |
5 | import http from '../../apis/instance';
6 | import { postNotification } from '../../apis/notification';
7 | import { Message } from '../../interfaces/message';
8 | import MessageBubble from './MessageBubble';
9 |
10 | interface Props {
11 | conversationPartner: string;
12 | specificUsers: Message[];
13 | }
14 |
15 | const MessageInputForm = ({ conversationPartner, specificUsers }: Props) => {
16 | const scrollRef = useRef(null);
17 | const messageInputRef = useRef(null);
18 |
19 | const handleSubmit = (e: React.FormEvent) => {
20 | e.preventDefault();
21 | };
22 |
23 | const handleKeyboardEvent = (e: React.KeyboardEvent) => {
24 | if (e.key === 'Enter') {
25 | sendMessage();
26 | }
27 | };
28 |
29 | const sendMessage = async () => {
30 | if (!messageInputRef.current) return;
31 |
32 | const result = await http.post({
33 | url: '/messages/create',
34 | data: {
35 | message: messageInputRef.current?.value,
36 | receiver: conversationPartner,
37 | },
38 | });
39 |
40 | //send notification
41 | await postNotification(
42 | 'MESSAGE',
43 | result.data._id,
44 | conversationPartner,
45 | null
46 | );
47 |
48 | messageInputRef.current.value = '';
49 | };
50 |
51 | useEffect(() => {
52 | scrollRef.current?.scrollTo({
53 | top: scrollRef.current?.scrollHeight,
54 | });
55 | }, [sendMessage]);
56 |
57 | return (
58 |
59 |
60 | {specificUsers?.map((specificUser) => (
61 |
65 | ))}
66 |
67 |
68 |
76 |
80 |
81 |
82 |
83 |
84 | );
85 | };
86 |
87 | export default MessageInputForm;
88 |
89 | const ChatWrapper = styled.div`
90 | width: 60%;
91 | `;
92 |
93 | const MessageInputFormWrap = styled.form`
94 | background-color: white;
95 |
96 | display: flex;
97 | justify-content: space-between;
98 | align-items: center;
99 | width: 100%;
100 | padding: 1rem 0;
101 | `;
102 |
103 | const MessageInput = styled.textarea`
104 | all: unset;
105 | height: 55px;
106 | width: 60%;
107 | border: 1px solid #e2e5e6;
108 | border-radius: 8px;
109 | padding: 14px;
110 | `;
111 |
112 | const MessageInputSubmitButton = styled.button`
113 | all: unset;
114 | height: 55px;
115 | border: 1px solid #e2e5e6;
116 | border-radius: 8px;
117 | text-align: center;
118 | cursor: pointer;
119 | padding: 14px;
120 | `;
121 |
122 | const ChatList = styled.div`
123 | height: 100%;
124 | overflow: scroll;
125 | overflow-x: hidden;
126 |
127 | ::-webkit-scrollbar {
128 | display: none;
129 | }
130 | `;
131 |
--------------------------------------------------------------------------------
/src/components/Notification/NotificationButton.tsx:
--------------------------------------------------------------------------------
1 | import { Notifications } from '@mui/icons-material';
2 | import { Badge, Box } from '@mui/material';
3 | import { useEffect, useState } from 'react';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { TOKEN_KEY } from '../../constants/auth';
7 | import { ROUTES } from '../../constants/routes';
8 | import { useNotificationsContext } from '../../contexts/NotificationContext';
9 | import { getLocalStorage } from '../../utils/storage';
10 |
11 | const { NOTIFICATION, SIGNIN } = ROUTES;
12 |
13 | interface Props {
14 | onClick: () => void;
15 | }
16 |
17 | const NotificationButton = ({ onClick }: Props) => {
18 | const [invisible, setInvisible] = useState(false);
19 | const { badgeCount } = useNotificationsContext();
20 | const navigate = useNavigate();
21 |
22 | useEffect(() => {
23 | !badgeCount ? setInvisible(true) : setInvisible(false);
24 | }, [badgeCount]);
25 |
26 | const handleClick = async () => {
27 | const token = getLocalStorage(TOKEN_KEY);
28 |
29 | onClick();
30 | !token ? navigate(SIGNIN) : navigate(NOTIFICATION);
31 | };
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default NotificationButton;
43 |
--------------------------------------------------------------------------------
/src/components/Notification/NotificationList.tsx:
--------------------------------------------------------------------------------
1 | import { List } from '@mui/material';
2 | import { useEffect, useState } from 'react';
3 |
4 | import { Notification } from '../../interfaces/notification';
5 | import { isExpiredDate } from '../../utils/calcCreatedToCurrentTime';
6 | import NotificationMsg from './NotificationMsg';
7 |
8 | const FOLLOW = 'follow';
9 |
10 | interface Props {
11 | type: string;
12 | notifications: Notification[];
13 | }
14 |
15 | const NotificationList = ({ type, notifications }: Props) => {
16 | const [followList, setFollowList] = useState([]);
17 | const [postList, setPostList] = useState([]);
18 |
19 | const filterNotification = (notifications: Notification[]) => {
20 | const filteredNotSeenOrUnexpired = notifications.filter(
21 | (n) => !n.seen || (n.seen && !isExpiredDate(n.createdAt || ''))
22 | );
23 |
24 | setPostList(filteredNotSeenOrUnexpired.filter((n) => n.like || n.comment));
25 | setFollowList(filteredNotSeenOrUnexpired.filter((n) => n.follow));
26 | };
27 |
28 | useEffect(() => {
29 | filterNotification(notifications);
30 | }, [notifications]);
31 |
32 | return (
33 |
37 | {type === FOLLOW
38 | ? followList?.map((n, i) => (
39 |
40 | ))
41 | : postList?.map((n, i) => )}
42 |
43 | );
44 | };
45 |
46 | export default NotificationList;
47 |
--------------------------------------------------------------------------------
/src/components/Notification/NotificationMsg.tsx:
--------------------------------------------------------------------------------
1 | import CommentIcon from '@mui/icons-material/Comment';
2 | import ThumbUp from '@mui/icons-material/ThumbUp';
3 | import {
4 | Avatar,
5 | Badge,
6 | ListItem,
7 | ListItemAvatar,
8 | ListItemButton,
9 | ListItemText,
10 | Typography,
11 | } from '@mui/material';
12 | import { useNavigate } from 'react-router-dom';
13 |
14 | import { getStoryDetail } from '../../apis/story';
15 | import { COLORS } from '../../constants/colors';
16 | import { ROUTES } from '../../constants/routes';
17 | import { Notification } from '../../interfaces/notification';
18 | import { calcCreatedToCurrentDate } from '../../utils/calcCreatedToCurrentTime';
19 |
20 | interface Props {
21 | notification: Notification;
22 | }
23 |
24 | interface NOTI_TYPE {
25 | [keyword: string]: {
26 | FRONT: string;
27 | MIDDLE: string;
28 | REAR: string;
29 | };
30 | }
31 |
32 | const NOTI_TYPE = {
33 | LIKE: 'LIKE',
34 | COMMENT: 'COMMENT',
35 | FOLLOW: 'FOLLOW',
36 | };
37 |
38 | const NOTI_MESSAGE: NOTI_TYPE = {
39 | LIKE: {
40 | FRONT: '님이 회원님께 ',
41 | MIDDLE: '좋아요',
42 | REAR: '를 보냈습니다',
43 | },
44 | COMMENT: {
45 | FRONT: '님이 회원님 게시글에 ',
46 | MIDDLE: '댓글',
47 | REAR: '을 보냈습니다',
48 | },
49 | FOLLOW: {
50 | FRONT: '님이 회원님을 ',
51 | MIDDLE: '팔로우',
52 | REAR: '합니다',
53 | },
54 | };
55 |
56 | const { STORY_BOOK_BY_USER_ID, STORY_BY_STORY_ID } = ROUTES;
57 |
58 | const NotificationMsg = ({ notification }: Props) => {
59 | const {
60 | seen,
61 | author: { fullName, image },
62 | like,
63 | post,
64 | follow,
65 | comment,
66 | createdAt,
67 | } = notification;
68 | const navigate = useNavigate();
69 |
70 | const getTotalMsg = (front: string, middle: string, rear: string) => {
71 | return (
72 |
73 | {fullName}
74 | {front}
75 |
76 | {middle}
77 |
78 | {rear}
79 |
80 | );
81 | };
82 |
83 | const getMsgType = () => {
84 | if (like) return NOTI_TYPE.LIKE;
85 | if (comment) return NOTI_TYPE.COMMENT;
86 | return NOTI_TYPE.FOLLOW;
87 | };
88 |
89 | const generateMsg = () => {
90 | const msgType = getMsgType();
91 |
92 | return getTotalMsg(
93 | NOTI_MESSAGE[msgType].FRONT,
94 | NOTI_MESSAGE[msgType].MIDDLE,
95 | NOTI_MESSAGE[msgType].REAR
96 | );
97 | };
98 |
99 | const generateAvatar = () => {
100 | if (like) return ;
101 | if (comment) return ;
102 | if (follow)
103 | return (
104 |
111 | );
112 | };
113 |
114 | const handleListItemClick = async () => {
115 | if ((like || comment) && post)
116 | navigate(STORY_BY_STORY_ID(post), { state: await getStoryDetail(post) });
117 | if (follow) navigate(STORY_BOOK_BY_USER_ID(follow.follower));
118 | };
119 |
120 | return (
121 |
128 |
131 |
138 |
146 | {generateAvatar()}
147 |
148 |
149 |
157 |
158 |
159 | );
160 | };
161 |
162 | export default NotificationMsg;
163 |
--------------------------------------------------------------------------------
/src/components/Notification/TabContainer.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from '@mui/material';
2 | import { useState } from 'react';
3 |
4 | import TabButtonItem from './TabItem';
5 |
6 | interface Props {
7 | onClick: (type: string) => void;
8 | }
9 |
10 | const TAB_TYPE = {
11 | FOLLOW: 'follow',
12 | POST: 'post',
13 | };
14 |
15 | const TAB_VALUE = {
16 | FOLLOW: '팔로우',
17 | POST: '좋아요 댓글',
18 | };
19 |
20 | const { FOLLOW, POST } = TAB_TYPE;
21 |
22 | const TabContainer = ({ onClick }: Props) => {
23 | const [tabValue, setTabValue] = useState(POST);
24 |
25 | return (
26 |
27 | {
32 | setTabValue(POST);
33 | onClick(POST);
34 | }}
35 | />
36 | {
41 | setTabValue(FOLLOW);
42 | onClick(FOLLOW);
43 | }}
44 | />
45 |
46 | );
47 | };
48 |
49 | export default TabContainer;
50 |
--------------------------------------------------------------------------------
/src/components/Notification/TabItem.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@mui/material';
2 |
3 | interface Props {
4 | text: string;
5 | type: string;
6 | curTabValue: string;
7 | onClick: () => void;
8 | }
9 | const TabButtonItem = ({ text, type, curTabValue, onClick }: Props) => {
10 | return (
11 |
18 | );
19 | };
20 |
21 | export default TabButtonItem;
22 |
--------------------------------------------------------------------------------
/src/components/Profile/ImageForm.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Button, Divider, Stack } from '@mui/material';
3 | import { FormEvent, useEffect, useState } from 'react';
4 |
5 | import { postCoverImage, postProfileImage } from '../../apis/userInfo';
6 | import { ERROR_MESSAGES } from '../../constants/errorMessages';
7 | import ImageUpload from '../StoryEdit/ImageUpload';
8 |
9 | interface Props {
10 | type: string;
11 | oldImage: string;
12 | open: boolean;
13 | handleOpen: () => void;
14 | }
15 |
16 | const ImageForm = ({ type, oldImage, open, handleOpen }: Props) => {
17 | const [imageBase64, setImageBase64] = useState(oldImage);
18 | const [imageFile, setImageFile] = useState();
19 | const [error, setError] = useState('');
20 | const [isLoading, setIsLoading] = useState(false);
21 |
22 | useEffect(() => {
23 | setImageBase64(oldImage);
24 | setError('');
25 | }, [open]);
26 |
27 | const encodeFileToBase64 = (fileBlob: File) => {
28 | const reader = new FileReader();
29 | reader.readAsDataURL(fileBlob);
30 | return new Promise((resolve) => {
31 | reader.onload = () => {
32 | if (!reader.result || typeof reader.result !== 'string') return;
33 | const result = reader.result;
34 | setImageBase64(result);
35 | resolve(Promise);
36 | };
37 | });
38 | };
39 |
40 | const handleChange = (imageFile: File) => {
41 | setImageFile(imageFile);
42 | encodeFileToBase64(imageFile);
43 | setError('');
44 | };
45 |
46 | const handleDelete = () => {
47 | setImageFile(null);
48 | setImageBase64('');
49 | };
50 |
51 | const generateFormData = () => {
52 | const formData = new FormData();
53 | formData.append('isCover', type === '커버' ? 'true' : 'false');
54 | if (imageFile) formData.append('image', imageFile);
55 |
56 | return formData;
57 | };
58 |
59 | const validate = () => {
60 | if (oldImage === imageBase64) return '현재 이미지와 일치합니다.';
61 | if (!imageFile) return '이미지를 추가해 주세요.';
62 | };
63 |
64 | const handleSubmit = async (e: FormEvent) => {
65 | e.preventDefault();
66 | setIsLoading(true);
67 |
68 | const error = validate();
69 | if (error) {
70 | setError(error);
71 | setIsLoading(false);
72 | return;
73 | }
74 |
75 | try {
76 | const formData = generateFormData();
77 | type === '프로필'
78 | ? await postProfileImage(formData)
79 | : await postCoverImage(formData);
80 |
81 | alert(`${type} 이미지가 변경되었습니다.`);
82 | handleOpen();
83 | location.reload();
84 | } catch (error) {
85 | console.error(error);
86 | alert(ERROR_MESSAGES.INVOKED_ERROR_POSTING_STORY);
87 | }
88 |
89 | setIsLoading(false);
90 | };
91 |
92 | return (
93 |
130 | );
131 | };
132 |
133 | export default ImageForm;
134 |
135 | const InputWrapper = styled.div`
136 | padding: 5px 18px 12px 18px;
137 | `;
138 |
139 | const ErrorText = styled.small`
140 | color: red;
141 | `;
142 |
--------------------------------------------------------------------------------
/src/components/Profile/PasswordForm.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Button, Stack, TextField } from '@mui/material';
3 | import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
4 |
5 | import { putPassword } from '../../apis/userInfo';
6 | import { ERROR_MESSAGES } from '../../constants/errorMessages';
7 | import { isBlankString } from '../../utils/validations';
8 |
9 | interface Props {
10 | open: boolean;
11 | handleOpen: () => void;
12 | }
13 |
14 | const initialValues = {
15 | oldValue: '',
16 | newValue: '',
17 | newValueCheck: '',
18 | };
19 |
20 | const initialErrors = {
21 | oldValue: '',
22 | newValue: '',
23 | newValueCheck: '',
24 | };
25 |
26 | const PasswordForm = ({ open, handleOpen }: Props) => {
27 | const [values, setValues] = useState(initialValues);
28 | const [errors, setErrors] = useState(initialErrors);
29 | const [isLoading, setIsLoading] = useState(false);
30 | const passwordRegex = /^.{6,15}$/;
31 |
32 | useEffect(() => {
33 | setValues(initialValues);
34 | setErrors(initialValues);
35 | }, [open]);
36 |
37 | const handleChange = (e: ChangeEvent) => {
38 | const { name, value } = e.target;
39 | setValues({ ...values, [name]: value.replace(/\s/g, '') });
40 | };
41 |
42 | const validate = () => {
43 | const { oldValue, newValue, newValueCheck } = values;
44 | const errors = { oldValue: '', newValue: '', newValueCheck: '' };
45 |
46 | if (isBlankString(oldValue))
47 | errors.oldValue = '현재 비밀번호를 입력해 주세요.';
48 | if (isBlankString(newValue))
49 | errors.newValue = '새 비밀번호를 입력해 주세요.';
50 | else if (oldValue === newValue) {
51 | errors.newValue = '현재 비밀번호와 일치합니다.';
52 | } else if (!passwordRegex.test(newValue)) {
53 | errors.newValue = '6자리 이상, 15자리 이하로 입력해주세요';
54 | }
55 |
56 | if (isBlankString(newValueCheck))
57 | errors.newValueCheck = '새 비밀번호 확인을 입력해 주세요.';
58 | else if (newValue !== newValueCheck)
59 | errors.newValueCheck = '비밀번호가 일치하지 않습니다';
60 |
61 | return errors;
62 | };
63 |
64 | const handleSubmit = async (e: FormEvent) => {
65 | e.preventDefault();
66 | setIsLoading(true);
67 |
68 | const newErrors = validate();
69 | const errorValues = Object.values(newErrors);
70 | const isValidate =
71 | errorValues.filter((error) => error === '').length === errorValues.length;
72 | if (!isValidate) {
73 | setErrors(newErrors);
74 | setIsLoading(false);
75 | return;
76 | }
77 |
78 | try {
79 | await putPassword(values.newValue);
80 | alert('비밀번호가 변경되었습니다.');
81 | handleOpen();
82 | location.reload();
83 | } catch (error) {
84 | console.error(error);
85 | alert(ERROR_MESSAGES.INVOKED_ERROR_PUTTING_PASSWORD);
86 | }
87 |
88 | setIsLoading(false);
89 | };
90 |
91 | return (
92 |
159 | );
160 | };
161 |
162 | export default PasswordForm;
163 |
164 | const InputContainer = styled.div`
165 | display: flex;
166 | flex-direction: column;
167 | gap: 15px;
168 | margin: 8px 15px 18px 15px;
169 | `;
170 |
--------------------------------------------------------------------------------
/src/components/Profile/ProfileModal.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { createTheme, Dialog, ThemeProvider, Typography } from '@mui/material';
3 | import { ReactNode } from 'react';
4 |
5 | import { ModalType } from '../../pages/Profile';
6 | import ImageForm from './ImageForm';
7 | import PasswordForm from './PasswordForm';
8 | import TextForm from './TextForm';
9 |
10 | interface Props {
11 | type: ModalType;
12 | user: {
13 | fullName?: string;
14 | username?: string;
15 | image?: string;
16 | coverImage?: string;
17 | };
18 | open: boolean;
19 | handleOpen: () => void;
20 | }
21 |
22 | interface ModalInfo {
23 | title: string;
24 | form: ReactNode;
25 | }
26 |
27 | const ProfileModal = ({ type, user, open, handleOpen }: Props) => {
28 | const modal: { [key: string]: ModalInfo } = {
29 | password: {
30 | title: '비밀번호',
31 | form: ,
32 | },
33 | nickname: {
34 | title: '닉네임',
35 | form: (
36 |
43 | ),
44 | },
45 | job: {
46 | title: '직업',
47 | form: (
48 |
55 | ),
56 | },
57 | coverImage: {
58 | title: '커버 이미지',
59 | form: (
60 |
66 | ),
67 | },
68 | profileImage: {
69 | title: '프로필 이미지',
70 | form: (
71 |
77 | ),
78 | },
79 | };
80 |
81 | return (
82 |
83 |
95 |
96 | );
97 | };
98 |
99 | export default ProfileModal;
100 |
101 | const Container = styled.div`
102 | display: flex;
103 | flex-direction: column;
104 | width: 300px;
105 | overflow: hidden;
106 | `;
107 |
108 | const TitleWrapper = styled.div`
109 | padding-top: 10px;
110 | padding-left: 10px;
111 | `;
112 |
113 | const theme = createTheme({
114 | typography: {
115 | fontFamily: "'MaplestoryOTFLight', cursive",
116 | },
117 | components: {
118 | MuiPaper: {
119 | styleOverrides: {
120 | root: {
121 | borderRadius: '10px',
122 | },
123 | },
124 | },
125 | MuiButton: {
126 | styleOverrides: {
127 | root: {
128 | borderRadius: '0',
129 | border: '0',
130 | borderTop: '1px solid rgba(237, 108, 2, 0.5)',
131 | },
132 | },
133 | },
134 | },
135 | });
136 |
--------------------------------------------------------------------------------
/src/components/Profile/TextForm.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Button, Stack, TextField } from '@mui/material';
3 | import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react';
4 |
5 | import { putUserInfo } from '../../apis/userInfo';
6 | import { getUserList } from '../../apis/userList';
7 | import { ERROR_MESSAGES } from '../../constants/errorMessages';
8 | import { User } from '../../interfaces/user';
9 |
10 | interface Props {
11 | type: string;
12 | fullName: string;
13 | username: string;
14 | open: boolean;
15 | placeholder: string;
16 | handleOpen: () => void;
17 | }
18 |
19 | const TextForm = ({
20 | type,
21 | fullName,
22 | username,
23 | open,
24 | placeholder,
25 | handleOpen,
26 | }: Props) => {
27 | const [initialValue, job, year, month, day] = useMemo(() => {
28 | const { job, year, month, day } = JSON.parse(username);
29 |
30 | let initialValue = '';
31 | if (type === '닉네임') {
32 | initialValue = fullName;
33 | } else if (type === '직업') {
34 | initialValue = job;
35 | }
36 |
37 | return [initialValue, job, year, month, day];
38 | }, [username]);
39 |
40 | const [value, setValue] = useState(initialValue);
41 | const [error, setError] = useState('');
42 | const [isLoading, setIsLoading] = useState(false);
43 |
44 | useEffect(() => {
45 | setError('');
46 | }, [open]);
47 |
48 | const handleChange = (e: ChangeEvent) => {
49 | setValue(e.target.value.replace(/\s/g, ''));
50 | };
51 |
52 | const checkDuplicate = async () => {
53 | const userList = await getUserList();
54 | const nameList = userList.map((user: User) => user.fullName);
55 | return nameList.includes(value);
56 | };
57 |
58 | const validateNickname = async () => {
59 | const nicknameRegex = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]{2,8}$/;
60 | const koreanRegex = /^[A-Za-z0-9가-힣]{2,8}$/;
61 | let error = '';
62 |
63 | if (!value) error = '닉네임을 입력해 주세요';
64 | else if (value === initialValue) error = '현재 닉네임과 같습니다';
65 | else if (await checkDuplicate()) {
66 | error = '중복된 닉네임 입니다. 다른 닉네임을 입력해 주세요.';
67 | } else if (!nicknameRegex.test(value))
68 | error = '영어, 한글, 숫자 (2~8자리)로 입력해 주세요.';
69 | else if (!koreanRegex.test(value))
70 | error =
71 | '한글은 완성된 단어로 입력해 주세요(자음과 모음은 독립적으로 사용이 불가능합니다).';
72 |
73 | return error;
74 | };
75 |
76 | const validateJob = () => {
77 | const jobRegex = /^[가-힣]{2,6}$/;
78 | let error = '';
79 |
80 | if (!value) error = '직업을 입력해 주세요.';
81 | else if (value === initialValue) error = '현재 직업과 같습니다';
82 | else if (!jobRegex.test(value)) error = '한글만 입력가능합니다.';
83 |
84 | return error;
85 | };
86 |
87 | const handleSubmit = async (e: FormEvent) => {
88 | e.preventDefault();
89 | setIsLoading(true);
90 |
91 | let newError = '';
92 | if (type === '닉네임') {
93 | newError = await validateNickname();
94 | } else if (type === '직업') {
95 | newError = validateJob();
96 | }
97 |
98 | if (newError) {
99 | setError(newError);
100 | setIsLoading(false);
101 | return;
102 | }
103 |
104 | try {
105 | if (error) {
106 | setIsLoading(false);
107 | return;
108 | }
109 | if (type === '닉네임') {
110 | await putUserInfo(
111 | value,
112 | JSON.stringify({
113 | job,
114 | year,
115 | month,
116 | day,
117 | })
118 | );
119 | } else if (type === '직업') {
120 | await putUserInfo(
121 | fullName,
122 | JSON.stringify({
123 | job: value,
124 | year,
125 | month,
126 | day,
127 | })
128 | );
129 | }
130 | alert(`${type}이 변경되었습니다.`);
131 | handleOpen();
132 | location.reload();
133 | } catch (error) {
134 | console.error(error);
135 | alert(ERROR_MESSAGES.INVOKED_ERROR_PUTTING_NICKNAME);
136 | }
137 |
138 | setIsLoading(false);
139 | };
140 |
141 | return (
142 |
183 | );
184 | };
185 |
186 | export default TextForm;
187 |
188 | const TitleWrapper = styled.div`
189 | margin: 15px;
190 | `;
191 |
--------------------------------------------------------------------------------
/src/components/SignIn/SignInForm.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | CircularProgress,
4 | Grid,
5 | TextField,
6 | Typography,
7 | } from '@mui/material';
8 |
9 | import SignInButton from '../shared/SignInButton';
10 |
11 | interface Props {
12 | values: {
13 | email: string;
14 | password: string;
15 | };
16 | errors: {
17 | email: string;
18 | password: string;
19 | };
20 | isLoading: boolean;
21 | onSubmit: (event: React.FormEvent) => Promise;
22 | onChange: (
23 | event: React.ChangeEvent
24 | ) => void;
25 | onClick: () => void;
26 | }
27 |
28 | const SignInForm = ({
29 | values,
30 | errors,
31 | isLoading,
32 | onSubmit,
33 | onChange,
34 | onClick,
35 | }: Props) => {
36 | return (
37 |
46 |
47 | 로그인
48 |
49 |
61 |
73 |
74 |
82 | 로그인
83 | {isLoading && (
84 |
89 | )}
90 |
91 |
99 |
104 | 로그인 없이 접속
105 |
106 | {isLoading && (
107 |
112 | )}
113 |
114 |
115 |
116 | );
117 | };
118 |
119 | export default SignInForm;
120 |
--------------------------------------------------------------------------------
/src/components/SignIn/SignInLinks.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Link } from '@mui/material';
2 |
3 | import { COLORS } from '../../constants/colors';
4 |
5 | interface Props {
6 | onClick: () => void;
7 | }
8 |
9 | const SignInLinks = ({ onClick }: Props) => {
10 | return (
11 |
14 |
21 | 아직도 계정 없음?
22 |
23 |
24 | );
25 | };
26 |
27 | export default SignInLinks;
28 |
--------------------------------------------------------------------------------
/src/components/SignUp/SignUpButton.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, CircularProgress } from '@mui/material';
2 |
3 | interface Props {
4 | isLoading: boolean;
5 | }
6 |
7 | const SignUpButton = ({ isLoading }: Props) => {
8 | return (
9 |
10 |
16 |
24 | {isLoading && (
25 |
36 | )}
37 |
38 |
39 | );
40 | };
41 |
42 | export default SignUpButton;
43 |
--------------------------------------------------------------------------------
/src/components/SignUp/SignUpInput.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, TextField } from '@mui/material';
2 | import { ChangeEventHandler } from 'react';
3 |
4 | interface Props {
5 | placeholder: string;
6 | innerText?: string;
7 | type: string;
8 | name: string;
9 | errorMsg?: string;
10 | value: string;
11 | maxLength: string;
12 | isName?: boolean;
13 | onChange?: ChangeEventHandler;
14 | duplicate?: () => void;
15 | }
16 |
17 | const SignUpInput = ({
18 | placeholder,
19 | innerText,
20 | type,
21 | name,
22 | errorMsg,
23 | value,
24 | maxLength,
25 | isName,
26 | duplicate,
27 | onChange,
28 | }: Props) => {
29 | return (
30 |
37 |
50 | {isName && (
51 |
59 | )}
60 |
61 | );
62 | };
63 |
64 | export default SignUpInput;
65 |
--------------------------------------------------------------------------------
/src/components/SignUp/SignUpSelector.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | FormControl,
4 | FormHelperText,
5 | InputLabel,
6 | MenuItem,
7 | Select,
8 | SelectChangeEvent,
9 | } from '@mui/material';
10 | import { ReactNode } from 'react';
11 |
12 | interface Props {
13 | name: string;
14 | errorMsg?: string;
15 | onChange?: (event: SelectChangeEvent, child: ReactNode) => void;
16 | }
17 |
18 | const SignUpSelector = ({ name, errorMsg, onChange }: Props) => {
19 | const getFullYear = () => {
20 | return Array(new Date().getFullYear() - 1980)
21 | .fill(0)
22 | .map((_, i) => new Date().getFullYear() - i);
23 | };
24 |
25 | return (
26 |
33 |
34 | Age
35 |
50 |
51 | {errorMsg && (
52 | {errorMsg}
53 | )}
54 |
55 | );
56 | };
57 |
58 | export default SignUpSelector;
59 |
--------------------------------------------------------------------------------
/src/components/Story/CommentForm.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import SendIcon from '@mui/icons-material/Send';
3 | import { IconButton, TextField } from '@mui/material';
4 | import { ChangeEvent, FormEvent } from 'react';
5 |
6 | import { TOKEN_KEY } from '../../constants/auth';
7 | import { getLocalStorage } from '../../utils/storage';
8 |
9 | interface Props {
10 | comment: string;
11 | isLoading: boolean;
12 | handleChange: (e: ChangeEvent) => void;
13 | handleSubmit: (e: FormEvent) => void;
14 | }
15 |
16 | const CommentForm = ({
17 | comment,
18 | isLoading,
19 | handleChange,
20 | handleSubmit,
21 | }: Props) => {
22 | const hasToken = getLocalStorage(TOKEN_KEY) ? true : false;
23 |
24 | return (
25 |
43 | );
44 | };
45 |
46 | export default CommentForm;
47 |
48 | const Form = styled.form`
49 | display: flex;
50 | align-items: center;
51 | gap: 5px;
52 | `;
53 |
--------------------------------------------------------------------------------
/src/components/Story/CommentList.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import {
3 | Avatar,
4 | Button,
5 | IconButton,
6 | List,
7 | ListItem,
8 | ListItemAvatar,
9 | ListItemText,
10 | } from '@mui/material';
11 | import { useNavigate } from 'react-router-dom';
12 |
13 | import { COLORS } from '../../constants/colors';
14 | import { ROUTES } from '../../constants/routes';
15 | import useFetchUser from '../../hooks/useFetchUser';
16 | import { Comment } from '../../interfaces/comment';
17 |
18 | interface Props {
19 | comments: Comment[];
20 | handleDelete: (commentId: string) => void;
21 | }
22 |
23 | const CommentList = ({ comments, handleDelete }: Props) => {
24 | const { user, isLoading } = useFetchUser();
25 | const navigate = useNavigate();
26 |
27 | return (
28 |
29 | {comments.map((comment) => (
30 |
34 | }
35 | sx={{ padding: '15px 0', alignItems: 'flex-start' }}>
36 |
39 | navigate(ROUTES.STORY_BOOK_BY_USER_ID(comment.author._id))
40 | }>
41 |
42 |
43 |
44 |
46 | navigate(ROUTES.STORY_BOOK_BY_USER_ID(comment.author._id))
47 | }>
48 | {comment.author.fullName}
49 |
50 | {comment.comment}
51 |
52 | {!isLoading && user && user._id === comment.author._id && (
53 |
60 | )}
61 |
62 | ))}
63 |
64 | );
65 | };
66 |
67 | export default CommentList;
68 |
69 | const NameWrapper = styled.span`
70 | font-weight: 600;
71 | cursor: pointer;
72 | `;
73 |
74 | const ContentWrapper = styled.div`
75 | white-space: pre-wrap;
76 | `;
77 |
--------------------------------------------------------------------------------
/src/components/Story/LikeButton.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import FavoriteIcon from '@mui/icons-material/Favorite';
3 | import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
4 | import { IconButton } from '@mui/material';
5 |
6 | import { COLORS } from '../../constants/colors';
7 | import useLike from '../../hooks/useLike';
8 | import { Like } from '../../interfaces/like';
9 | import { User } from '../../interfaces/user';
10 |
11 | interface Props {
12 | user?: User;
13 | authorId: string;
14 | likes: Like[];
15 | storyId: string;
16 | }
17 |
18 | const LikeButton = ({ user, authorId, likes, storyId }: Props) => {
19 | const { isLike, likeCount, handleClick } = useLike(
20 | user?._id || '',
21 | authorId,
22 | likes,
23 | storyId
24 | );
25 |
26 | return (
27 |
28 | {isLike ? (
29 |
32 | ) : (
33 |
34 | )}
35 | {likeCount}
36 |
37 | );
38 | };
39 |
40 | export default LikeButton;
41 |
42 | const Count = styled.span<{ isLike: boolean }>`
43 | color: ${({ isLike }) => isLike && COLORS.SUB};
44 | `;
45 |
--------------------------------------------------------------------------------
/src/components/Story/StoryComment.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@mui/material';
2 |
3 | import { useCommentForm } from '../../hooks/useComment';
4 | import { Comment } from '../../interfaces/comment';
5 | import CommentForm from './CommentForm';
6 | import CommentList from './CommentList';
7 |
8 | interface Props {
9 | storyAuthorId: string;
10 | comments: Comment[];
11 | fetchComment: () => void;
12 | }
13 |
14 | const StoryComment = ({ storyAuthorId, comments, fetchComment }: Props) => {
15 | const { comment, isLoading, handleChange, handleDelete, handleSubmit } =
16 | useCommentForm(storyAuthorId);
17 |
18 | return (
19 |
20 | {
23 | await handleDelete(comment);
24 | fetchComment();
25 | }}
26 | />
27 | {
32 | await handleSubmit(e);
33 | fetchComment();
34 | }}
35 | />
36 |
37 | );
38 | };
39 |
40 | export default StoryComment;
41 |
--------------------------------------------------------------------------------
/src/components/Story/StoryInfo.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Avatar, Button, Typography } from '@mui/material';
3 | import { Box } from '@mui/system';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { COLORS } from '../../constants/colors';
7 | import { ROUTES } from '../../constants/routes';
8 | import useFetchUser from '../../hooks/useFetchUser';
9 | import { useDeleteStory } from '../../hooks/useStory';
10 | import { StoryData } from '../../interfaces/story';
11 | import LikeButton from './LikeButton';
12 |
13 | interface Props {
14 | story: StoryData;
15 | }
16 |
17 | const StoryInfo = ({ story }: Props) => {
18 | const navigate = useNavigate();
19 | const { handleDelete } = useDeleteStory();
20 | const { user, isLoading } = useFetchUser();
21 |
22 | const { storyTitle, year, month, day, content } = JSON.parse(story.title);
23 |
24 | return (
25 | <>
26 |
27 | {storyTitle}
28 |
29 |
30 | {year}년 {month}월 {day}일
31 |
32 | {!isLoading && user?._id === story.author._id && (
33 |
34 |
44 |
51 |
52 | )}
53 |
54 |
55 |
57 | navigate(ROUTES.STORY_BOOK_BY_USER_ID(story.author._id))
58 | }>
59 |
60 | {story.author.fullName}
61 |
62 |
63 |
64 |
65 | {content && {content}}
66 | {story.image && (
67 |
68 |
69 |
70 | )}
71 |
77 |
78 | >
79 | );
80 | };
81 |
82 | export default StoryInfo;
83 |
84 | const DateContainer = styled(Box)`
85 | display: flex;
86 | justify-content: space-between;
87 | align-items: center;
88 | `;
89 |
90 | const Profile = styled.span`
91 | display: inline-flex;
92 | align-items: center;
93 | gap: 10px;
94 | cursor: pointer;
95 | `;
96 |
97 | const StoryContainer = styled(Box)`
98 | display: flex;
99 | flex-direction: column;
100 | align-items: center;
101 | padding-bottom: 20px;
102 | `;
103 |
104 | const StoryImageWrapper = styled.div`
105 | display: flex;
106 | padding: 15px 0;
107 | `;
108 |
109 | const StoryImage = styled.img`
110 | width: 100%;
111 | object-fit: contain;
112 | cursor: pointer;
113 | `;
114 |
115 | const StoryContentWrapper = styled.div`
116 | width: 100%;
117 | margin: 15px 0;
118 | line-height: 1.5rem;
119 | word-break: keep-all;
120 | white-space: pre-wrap;
121 | `;
122 |
--------------------------------------------------------------------------------
/src/components/StoryBook/Empty.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Typography } from '@mui/material';
3 | import { ReactNode } from 'react';
4 |
5 | interface Props {
6 | children: ReactNode;
7 | }
8 |
9 | const Empty = ({ children }: Props) => {
10 | return {children};
11 | };
12 |
13 | export default Empty;
14 |
15 | const CustomTypography = styled(Typography)`
16 | position: absolute;
17 | top: 50%;
18 | left: 50%;
19 | transform: translate(-50%, 50%);
20 | `;
21 |
--------------------------------------------------------------------------------
/src/components/StoryBook/FollowButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps, styled } from '@mui/material';
2 | import { useEffect, useState } from 'react';
3 | import { useParams } from 'react-router-dom';
4 |
5 | import { createFollow, removeFollow } from '../../apis/follow';
6 | import { postNotification } from '../../apis/notification';
7 | import { userInfo } from '../../apis/userInfo';
8 | import { USER_ID_KEY } from '../../constants/auth';
9 | import { COLORS } from '../../constants/colors';
10 | import useDisplayModeContext from '../../contexts/DisplayModeContext';
11 | import { Follow as Following } from '../../interfaces/user';
12 | import { getLocalStorage } from '../../utils/storage';
13 |
14 | const FollowButton = () => {
15 | const [isFollowing, setIsFollowing] = useState(false);
16 | const [canBeRendered, setCanBeRendered] = useState(false);
17 | const [followId, setFollowId] = useState('');
18 | const { userId } = useParams();
19 | const { displayMode } = useDisplayModeContext();
20 | const isDarkAndIsFollowing = displayMode === 'dark' && isFollowing;
21 |
22 | const storedUserId = getLocalStorage(USER_ID_KEY);
23 | const isMyStoryBook = userId === storedUserId;
24 |
25 | const handleClick = async () => {
26 | if (!userId) return;
27 |
28 | const result = isFollowing
29 | ? await removeFollow(followId)
30 | : await createFollow(userId);
31 |
32 | if (result) {
33 | const {
34 | data: { _id: newFollowId, user: userId },
35 | }: { data: { _id: string; user: string } } = result;
36 | setFollowId(newFollowId);
37 |
38 | //follow notification 보내기
39 | !isFollowing &&
40 | (await postNotification('FOLLOW', newFollowId, userId, null));
41 | }
42 |
43 | isFollowing ? setIsFollowing(false) : setIsFollowing(true);
44 | };
45 |
46 | useEffect(() => {
47 | const initializeStatusAboutFollow = async () => {
48 | const { following: followings }: { following: Following[] } =
49 | await userInfo(storedUserId);
50 | const followingDiscovered = followings.find(
51 | (following) => following.user === userId
52 | );
53 |
54 | if (followingDiscovered) {
55 | setFollowId(followingDiscovered._id);
56 | setIsFollowing(true);
57 | }
58 |
59 | setCanBeRendered(true);
60 | };
61 |
62 | userId && storedUserId && initializeStatusAboutFollow();
63 | }, []);
64 |
65 | return (
66 |
67 | {!isMyStoryBook && canBeRendered && (
68 |
77 | {isFollowing ? '팔로잉' : '팔로우'}
78 |
79 | )}
80 |
81 | );
82 | };
83 |
84 | export default FollowButton;
85 |
86 | interface StyledButtonProps extends ButtonProps {
87 | isFollowing?: boolean;
88 | }
89 |
90 | const CustomButton = styled(Button, {
91 | shouldForwardProp: (prop) => prop !== 'isFollowing',
92 | })(({ isFollowing }) => ({
93 | ...(isFollowing && {
94 | color: 'rgba(0, 0, 0, 0.26)',
95 | border: '1px solid rgba(0, 0, 0, 0.12)',
96 | }),
97 | }));
98 |
--------------------------------------------------------------------------------
/src/components/StoryBook/Loading.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { CircularProgress } from '@mui/material';
3 |
4 | const Loading = () => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
12 | export default Loading;
13 |
14 | const LoadingContainer = styled.div`
15 | position: absolute;
16 | top: 50%;
17 | left: 50%;
18 | transform: translate(-50%, 50%);
19 | `;
20 |
--------------------------------------------------------------------------------
/src/components/StoryBook/StoriesByYear.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { List, ListProps, styled as muiStyled } from '@mui/material';
3 |
4 | import { COLORS } from '../../constants/colors';
5 | import useDisplayModeContext from '../../contexts/DisplayModeContext';
6 | import { StoriesWithYear } from '../../interfaces/story';
7 | import StoryCard from './StoryCard';
8 |
9 | const StoriesByYear = ({ year, stories }: StoriesWithYear) => {
10 | const { displayMode } = useDisplayModeContext();
11 |
12 | return (
13 |
14 | {year}
15 |
16 | {stories.map((story) => {
17 | const { storyTitle, month } = JSON.parse(story.title);
18 |
19 | return (
20 |
21 | {story.isFirstInSameMonths && `${month}월`}
22 |
29 |
30 | );
31 | })}
32 |
33 |
34 | );
35 | };
36 |
37 | export default StoriesByYear;
38 |
39 | interface StyledListProps extends ListProps {
40 | displayMode: string;
41 | }
42 |
43 | const Container = styled.div`
44 | margin-top: 0.3rem;
45 | margin-bottom: 1rem;
46 | border-radius: 1rem;
47 | background-color: ${COLORS.SUB};
48 | box-shadow: 2px 2px 3px 2px ${COLORS.HEADER_BORDER};
49 |
50 | display: flex;
51 | align-items: center;
52 | `;
53 |
54 | const Year = styled.div`
55 | width: 3.25rem;
56 | height: 100%;
57 | text-align: center;
58 | color: white;
59 | `;
60 |
61 | const CardsContainer = muiStyled(List, {
62 | shouldForwardProp: (prop) => prop !== 'displayMode',
63 | })(({ displayMode }) => ({
64 | flex: 1,
65 | display: 'flex',
66 | flexWrap: 'nowrap',
67 | alignItems: 'flex-end',
68 | overflowX: 'auto',
69 | padding: '1rem',
70 | backgroundColor: displayMode === 'dark' ? 'black' : 'white',
71 | transition: 'background-color 0.2s ease-out',
72 |
73 | '&::-webkit-scrollbar': {
74 | height: '0.15rem',
75 | },
76 |
77 | '&::-webkit-scrollbar-thumb': {
78 | backgroundColor: COLORS.SUB,
79 | borderRadius: '1rem',
80 | },
81 | }));
82 |
83 | const CardContainer = styled.div`
84 | display: flex;
85 | flex-direction: column;
86 | align-items: center;
87 | justify-content: center;
88 | `;
89 |
--------------------------------------------------------------------------------
/src/components/StoryBook/StoryAddButton.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { FaPen } from 'react-icons/fa';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import { TOKEN_KEY } from '../../constants/auth';
6 | import { ROUTES } from '../../constants/routes';
7 | import { getLocalStorage } from '../../utils/storage';
8 |
9 | interface Props {
10 | onClick: () => void;
11 | }
12 |
13 | const StoryAddButton = ({ onClick }: Props) => {
14 | const navigate = useNavigate();
15 |
16 | const handleClick = async () => {
17 | const token = getLocalStorage(TOKEN_KEY);
18 |
19 | onClick();
20 | token && navigate(ROUTES.STORY_CREATE);
21 | !token && navigate(ROUTES.SIGNIN);
22 | };
23 |
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default StoryAddButton;
32 |
33 | const Container = styled.div`
34 | display: flex;
35 | align-items: center;
36 | justify-content: center;
37 | width: 1.5rem;
38 | cursor: pointer;
39 | `;
40 |
--------------------------------------------------------------------------------
/src/components/StoryBook/StoryBookTitle.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import FollowButton from './FollowButton';
4 |
5 | interface Props {
6 | fullName: string;
7 | onClick: () => void;
8 | }
9 |
10 | const StoryBookTitle = ({ fullName, onClick }: Props) => {
11 | return (
12 |
13 |
14 | {fullName}님의 스토리북
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default StoryBookTitle;
22 |
23 | const Container = styled.div`
24 | display: flex;
25 | align-items: center;
26 | gap: 1rem;
27 | `;
28 |
29 | const Title = styled.h1`
30 | font-size: 1.2rem;
31 | flex: 1;
32 | white-space: nowrap;
33 | overflow: hidden;
34 | text-overflow: ellipsis;
35 | cursor: pointer;
36 | `;
37 |
--------------------------------------------------------------------------------
/src/components/StoryBook/StoryCard.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import {
3 | CardContent,
4 | CardMedia,
5 | ListItemButton,
6 | Typography,
7 | } from '@mui/material';
8 | import { useNavigate } from 'react-router-dom';
9 |
10 | import defaultImage from '../../assets/images/defaultImage.png';
11 | import { COLORS } from '../../constants/colors';
12 | import useLazyLoadImage from '../../hooks/useLazyLoadImage';
13 | import { Story } from '../../interfaces/story';
14 | import Loading from './Loading';
15 |
16 | interface Props {
17 | story: Story;
18 | title: string;
19 | storyId: string;
20 | image?: string;
21 | lazy?: boolean;
22 | }
23 |
24 | const StoryCard = ({ story, title, storyId, image, lazy = false }: Props) => {
25 | const { loaded, imageRef } = useLazyLoadImage(lazy);
26 | const navigate = useNavigate();
27 |
28 | const handleClick = () => {
29 | navigate(`/story/${storyId}`, { state: story });
30 | };
31 |
32 | return (
33 |
44 |
55 | {!loaded && }
56 |
60 |
68 | {title}
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | export default StoryCard;
76 |
77 | const CustomCard = styled(ListItemButton)`
78 | animation: cardSmoothAppear 0.5s;
79 |
80 | @keyframes cardSmoothAppear {
81 | from {
82 | opacity: 0;
83 | transform: translateY(2.5%);
84 | }
85 | to {
86 | opacity: 1;
87 | transform: translateY(0);
88 | }
89 | }
90 | `;
91 |
--------------------------------------------------------------------------------
/src/components/StoryEdit/DatePicker.tsx:
--------------------------------------------------------------------------------
1 | import 'dayjs/locale/ko';
2 |
3 | import { TextField } from '@mui/material';
4 | import {
5 | koKR,
6 | LocalizationProvider,
7 | MobileDatePicker,
8 | PickersDay,
9 | pickersDayClasses,
10 | PickersDayProps,
11 | } from '@mui/x-date-pickers';
12 | import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
13 | import dayjs, { Dayjs } from 'dayjs';
14 |
15 | import { COLORS } from '../../constants/colors';
16 |
17 | interface Props {
18 | text?: string;
19 | value: Dayjs | null;
20 | onChange: (newValue: Dayjs | null) => void;
21 | }
22 |
23 | const renderWeekPickerDay = (
24 | date: Dayjs,
25 | selectedDates: Array,
26 | pickersDayProps: PickersDayProps
27 | ) => {
28 | return (
29 |
37 | );
38 | };
39 |
40 | const DatePicker = ({ value, text = 'date', onChange }: Props) => {
41 | return (
42 |
48 | }
57 | maxDate={dayjs(new Date())}
58 | />
59 |
60 | );
61 | };
62 |
63 | export default DatePicker;
64 |
--------------------------------------------------------------------------------
/src/components/StoryEdit/ImageUpload.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Box, Button } from '@mui/material';
3 | import { ChangeEvent, DragEvent, useRef, useState } from 'react';
4 |
5 | interface Props {
6 | src: string;
7 | onChange: (file: File) => void;
8 | onDelete: () => void;
9 | }
10 |
11 | const ImageUpload = ({ src, onChange, onDelete }: Props) => {
12 | const [currentImage, setCurrentImage] = useState();
13 | const [dragging, setDragging] = useState(false);
14 | const inputRef = useRef(null);
15 |
16 | const handleFileChange = (e: ChangeEvent) => {
17 | const file = e.target.files?.[0];
18 | if (file) {
19 | setCurrentImage(file);
20 | onChange(file);
21 | }
22 | };
23 |
24 | const handleFileDelete = () => {
25 | if (confirm('사진을 삭제하시겠습니까?')) {
26 | setCurrentImage(null);
27 | onDelete();
28 | }
29 | };
30 |
31 | const handleFileChoose = () => {
32 | if (inputRef.current) {
33 | inputRef.current.click();
34 | }
35 | };
36 |
37 | const handleDragEnter = (e: DragEvent) => {
38 | e.preventDefault();
39 | e.stopPropagation();
40 |
41 | if (e.dataTransfer.items?.length > 0) {
42 | setDragging(true);
43 | }
44 | };
45 | const handleDragLeave = (e: DragEvent) => {
46 | e.preventDefault();
47 | e.stopPropagation();
48 |
49 | setDragging(false);
50 | };
51 | const handleDragOver = (e: DragEvent) => {
52 | e.preventDefault();
53 | e.stopPropagation();
54 | };
55 | const handleFileDrop = (e: DragEvent) => {
56 | e.preventDefault();
57 | e.stopPropagation();
58 |
59 | const file = e.dataTransfer.files?.[0];
60 | if (file) {
61 | setCurrentImage(file);
62 | onChange(file);
63 | }
64 | };
65 |
66 | return (
67 | <>
68 |
77 |
85 | {src ? (
86 |
87 | ) : (
88 |
89 | 클릭하여 이미지를 추가하거나
90 |
91 | 이미지를 드래그하세요
92 |
93 | )}
94 |
95 | {(src || currentImage) && (
96 |
99 | )}
100 | >
101 | );
102 | };
103 |
104 | export default ImageUpload;
105 |
106 | const UploadContainer = styled(Box)`
107 | display: flex;
108 | justify-content: center;
109 | align-items: center;
110 | width: 100%;
111 | height: 300px;
112 | border: 1px dashed grey;
113 | border-radius: 5px;
114 | cursor: pointer;
115 | `;
116 |
117 | const ImagePlaceholder = styled.div`
118 | color: grey;
119 | text-align: center;
120 | line-height: 1.4rem;
121 | `;
122 |
123 | const ImagePreview = styled.img`
124 | max-width: 100%;
125 | height: 300px;
126 | object-fit: contain;
127 | `;
128 |
--------------------------------------------------------------------------------
/src/components/StoryEdit/StoryEditForm.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Box, TextField } from '@mui/material';
3 | import { useLocation } from 'react-router-dom';
4 |
5 | import { useStoryForm } from '../../hooks/useStory';
6 | import DatePicker from './DatePicker';
7 | import ImageUpload from './ImageUpload';
8 | import SubmitButton from './SubmitButton';
9 |
10 | const StoryEditForm = () => {
11 | const { state } = useLocation();
12 |
13 | const {
14 | values,
15 | date,
16 | imageBase64,
17 | isLoading,
18 | errors,
19 | handleChange,
20 | handleDateChange,
21 | handleImageChange,
22 | handleImageDelete,
23 | handleSubmit,
24 | } = useStoryForm(state);
25 |
26 | const image = values.imageURL || imageBase64;
27 |
28 | return (
29 |
78 | );
79 | };
80 | export default StoryEditForm;
81 |
82 | const Section = styled(Box)`
83 | display: flex;
84 | align-items: center;
85 | padding-bottom: 20px;
86 | `;
87 |
88 | const InputDiv = styled(Box)`
89 | width: 100%;
90 | text-align: right;
91 | `;
92 |
--------------------------------------------------------------------------------
/src/components/StoryEdit/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@mui/material';
2 |
3 | interface Props {
4 | isLoading: boolean;
5 | }
6 |
7 | const SubmitButton = ({ isLoading = false }: Props) => {
8 | return (
9 |
18 | );
19 | };
20 |
21 | export default SubmitButton;
22 |
--------------------------------------------------------------------------------
/src/components/shared/DarkModeSwitch.tsx:
--------------------------------------------------------------------------------
1 | import { styled, Switch } from '@mui/material';
2 |
3 | import { DisplayMode } from '../../interfaces/displayMode';
4 |
5 | interface Props extends DisplayMode {
6 | onClick: () => void;
7 | }
8 |
9 | const MaterialUISwitch = styled(Switch)(({ theme }) => ({
10 | width: 62,
11 | height: 34,
12 | padding: 7,
13 | '& .MuiSwitch-switchBase': {
14 | margin: 1,
15 | padding: 0,
16 | transform: 'translateX(6px)',
17 | '&.Mui-checked': {
18 | color: '#fff',
19 | transform: 'translateX(22px)',
20 | '& .MuiSwitch-thumb:before': {
21 | backgroundImage: `url('data:image/svg+xml;utf8,')`,
24 | },
25 | '& + .MuiSwitch-track': {
26 | opacity: 1,
27 | backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
28 | },
29 | },
30 | },
31 | '& .MuiSwitch-thumb': {
32 | backgroundColor: '#001e3c',
33 | width: 32,
34 | height: 32,
35 | '&:before': {
36 | content: "''",
37 | position: 'absolute',
38 | width: '100%',
39 | height: '100%',
40 | left: 0,
41 | top: 0,
42 | backgroundRepeat: 'no-repeat',
43 | backgroundPosition: 'center',
44 | backgroundImage: `url('data:image/svg+xml;utf8,')`,
47 | },
48 | },
49 | '& .MuiSwitch-track': {
50 | opacity: 1,
51 | backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
52 | borderRadius: 20 / 2,
53 | },
54 | }));
55 |
56 | const label = { inputProps: { 'aria-label': 'dark_mode' } };
57 |
58 | const DarkModeSwitch = ({ displayMode, onClick }: Props) => {
59 | const checked = displayMode === 'dark';
60 |
61 | return ;
62 | };
63 |
64 | export default DarkModeSwitch;
65 |
--------------------------------------------------------------------------------
/src/components/shared/Header.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Avatar } from '@mui/material';
3 | import { useEffect, useState } from 'react';
4 | import { FaArrowRight, FaHamburger } from 'react-icons/fa';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | import { checkAuth } from '../../apis/auth';
8 | import { TOKEN_KEY, USER_ID_KEY } from '../../constants/auth';
9 | import { COLORS } from '../../constants/colors';
10 | import { ROUTES } from '../../constants/routes';
11 | import useDisplayModeContext from '../../contexts/DisplayModeContext';
12 | import useIsOverByScroll from '../../hooks/useIsOverByScroll';
13 | import { getLocalStorage, removeLocalStorage } from '../../utils/storage';
14 | import NotificationButton from '../Notification/NotificationButton';
15 | import StoryAddButton from '../StoryBook/StoryAddButton';
16 | import DarkModeSwitch from './DarkModeSwitch';
17 |
18 | const Header = () => {
19 | const hasToken = getLocalStorage(TOKEN_KEY) ? true : false;
20 | const [click, setClick] = useState(false);
21 | const handleClick = () => setClick(!click);
22 | const navigate = useNavigate();
23 | const token = getLocalStorage(TOKEN_KEY);
24 | const { ref, isOverByScroll } = useIsOverByScroll();
25 | const [user, setUser] = useState({
26 | image: '',
27 | fullName: '',
28 | _id: '',
29 | });
30 | const { displayMode, toggleDisplayMode } = useDisplayModeContext();
31 |
32 | useEffect(() => {
33 | const fetchUser = async () => {
34 | const userInfo = await checkAuth();
35 | setUser({
36 | image: userInfo.image,
37 | fullName: userInfo.fullName,
38 | _id: userInfo._id,
39 | });
40 | };
41 |
42 | fetchUser();
43 | }, [token]);
44 |
45 | const handleClickDarkModeSwitch = () => {
46 | toggleDisplayMode();
47 | };
48 |
49 | const handleClickProfileButton = () => {
50 | token ? navigate(ROUTES.PROFILE) : navigate(ROUTES.SIGNIN);
51 | };
52 |
53 | const handleClickHamburgerClose = () => {
54 | setClick(false);
55 | };
56 |
57 | const handleClickWatchStoriesButton = () => {
58 | navigate(ROUTES.HOME);
59 | };
60 |
61 | const handleClickMyStoryButton = async () => {
62 | if (token) {
63 | navigate(ROUTES.STORY_BOOK_BY_USER_ID(user._id));
64 | return;
65 | }
66 |
67 | navigate(ROUTES.SIGNIN);
68 | };
69 |
70 | const handleClickFollowListButton = async () => {
71 | if (token) {
72 | navigate(ROUTES.FOLLOW_BY_USER_ID(user._id));
73 | return;
74 | }
75 |
76 | navigate(ROUTES.SIGNIN);
77 | };
78 |
79 | const handleClickAuthButton = () => {
80 | if (token) {
81 | removeLocalStorage(TOKEN_KEY);
82 | removeLocalStorage(USER_ID_KEY);
83 | location.href = ROUTES.HOME;
84 | return;
85 | }
86 |
87 | navigate(ROUTES.SIGNIN);
88 | };
89 |
90 | const handleClickLogo = () => {
91 | setClick(false);
92 | navigate(ROUTES.HOME);
93 | };
94 |
95 | return (
96 |
100 | Bigtoria
101 |
102 |
103 | {click ? : }
104 |
105 | {hasToken && (
106 | <>
107 |
108 |
109 | >
110 | )}
111 |
115 |
116 |
117 |
118 |
123 | {user.fullName && {user.fullName}
}
124 |
125 |
126 | 스토리 구경하기
127 |
128 | 내 스토리
129 | {token && (
130 | 팔로우 목록
131 | )}
132 |
133 | {token ? '로그아웃' : '로그인'}
134 |
135 |
136 |
137 | );
138 | };
139 |
140 | export default Header;
141 |
142 | const Container = styled.header<{
143 | isOverByScroll: boolean;
144 | displayMode: string;
145 | }>`
146 | background-color: ${({ displayMode }) =>
147 | displayMode === 'dark' ? `${COLORS.DARK_MODE_HEADER}` : `${COLORS.MAIN}`};
148 | position: sticky;
149 | top: 0;
150 | padding: 1.2rem 1rem;
151 | display: flex;
152 | flex-direction: row-reverse;
153 | align-items: center;
154 | justify-content: space-between;
155 | z-index: 999;
156 | box-shadow: ${({ isOverByScroll }) =>
157 | isOverByScroll && `0px 4px 4px -4px ${COLORS.STORY_CARD_BORDER}`};
158 | transition: all 0.5s ease-out;
159 | `;
160 |
161 | const ButtonsContainer = styled.div`
162 | display: flex;
163 | align-items: center;
164 | gap: 1rem;
165 | `;
166 |
167 | const Hamburger = styled.nav<{ click: boolean; displayMode: string }>`
168 | width: 100%;
169 | display: flex;
170 | gap: 1rem;
171 | flex-direction: column;
172 | align-items: center;
173 | height: 100vh;
174 | position: absolute;
175 | top: 4.5rem;
176 | left: ${({ click }) => (click ? 0 : '-100%')};
177 | opacity: ${({ click }) => (click ? 1 : 0)};
178 | animation-name: slide;
179 | animation-duration: 0.5s;
180 | transition: all 0.5s ease;
181 | background: ${({ displayMode }) =>
182 | displayMode === 'dark' ? 'black' : 'white'};
183 | z-index: 999;
184 | padding-top: 4rem;
185 | `;
186 |
187 | const HamburgerButton = styled.div`
188 | width: 1.5rem;
189 | height: 1.5rem;
190 | display: flex;
191 | transform: scaleX(-1);
192 | justify-content: center;
193 | align-items: center;
194 | cursor: pointer;
195 | `;
196 |
197 | const Logo = styled.h1`
198 | margin: 0;
199 | font-size: 1.25rem;
200 | cursor: pointer;
201 | `;
202 |
203 | const NavLinks = styled.div`
204 | font-size: 1rem;
205 | padding: 1.5rem 0;
206 | font-weight: bold;
207 | cursor: pointer;
208 | p {
209 | text-align: center;
210 | }
211 | `;
212 |
--------------------------------------------------------------------------------
/src/components/shared/ScrollToTop.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | const ScrollToTop = () => {
5 | const { pathname } = useLocation();
6 |
7 | useEffect(() => {
8 | window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
9 | }, [pathname]);
10 |
11 | return null;
12 | };
13 |
14 | export default ScrollToTop;
15 |
--------------------------------------------------------------------------------
/src/components/shared/SignInButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@mui/material';
2 | import { ReactNode } from 'react';
3 |
4 | interface Props {
5 | onNavigate?: () => void;
6 | disabled?: boolean;
7 | variant?: 'contained' | 'outlined' | 'text';
8 | type?: 'submit' | 'reset' | 'button';
9 | children: ReactNode;
10 | }
11 |
12 | const INIT_SIGN_IN_BUTTON_VARIANT = 'contained';
13 | const INIT_SIGN_IN_BUTTON_TYPE = 'submit';
14 |
15 | const SignInButton = ({
16 | onNavigate,
17 | disabled,
18 | variant = INIT_SIGN_IN_BUTTON_VARIANT,
19 | type = INIT_SIGN_IN_BUTTON_TYPE,
20 | children,
21 | }: Props) => {
22 | return (
23 |
32 | );
33 | };
34 |
35 | export default SignInButton;
36 |
--------------------------------------------------------------------------------
/src/constants/apiParams.ts:
--------------------------------------------------------------------------------
1 | export const DATA_LIMIT = 5;
2 | export const CHANNEL_ID = '63b6822ade9d2a22cc1d45c3';
3 |
--------------------------------------------------------------------------------
/src/constants/apiUrls.ts:
--------------------------------------------------------------------------------
1 | export const API_URLS = {
2 | AUTH: {
3 | LOGIN: '/login',
4 | LOGOUT: '/logout',
5 | SIGNUP: '/signup',
6 | CHECK_AUTH: '/auth-user',
7 | },
8 | USER: {
9 | GET_USERS: '/users/get-users',
10 | GET_CURRENT_CONNECTED_USERS: '/users/online-users',
11 | GET_USER_INFO: (userId: string) => `/users/${userId}`,
12 | UPDATE_PROFILE_IMAGE: '/users/upload-photo',
13 | UPDATE_COVER_IMAGE: '/users/upload-photo',
14 | },
15 | SETTING: {
16 | UPDATE_MY_INFO: '/settings/update-user',
17 | UPDATE_MY_PASSWORD: '/settings/update-password',
18 | },
19 | CHANNEL: {
20 | GET_CHANNELS: '/channels',
21 | GET_CHANNEL_INFO: (channelName: string) => `/channel/${channelName}`,
22 | },
23 | POST: {
24 | GET_POSTS_OF_SPECIFIC_CHANNEL: (channelId: string) =>
25 | `/posts/channel/${channelId}`,
26 | GET_POSTS_OF_SPECIFIC_USER: (authorId: string) =>
27 | `/posts/author/${authorId}`,
28 | CREATE_POST_ON_SPECIFIC_CHANNEL: '/posts/create',
29 | GET_POST_DETAIL: (postId: string) => `/posts/${postId}`,
30 | UPDATE_POST: '/posts/update',
31 | DELETE_POST: '/posts/delete',
32 | },
33 | LIKE: {
34 | CREATE_LIKE: '/likes/create',
35 | DELETE_LIKE: '/likes/delete',
36 | },
37 | COMMENT: {
38 | CREATE_COMMENT: '/comments/create',
39 | DELETE_COMMENT: '/comments/delete',
40 | },
41 | NOTIFICATION: {
42 | GET_NOTIFICATIONS: '/notifications',
43 | UPDATE_NOTIFICATION: '/notifications/seen',
44 | CREATE_NOTIFICATION: '/notifications/create',
45 | },
46 | FOLLOW: {
47 | CREATE_FOLLOW: '/follow/create',
48 | DELETE_FOLLOW: '/follow/delete',
49 | },
50 | MESSAGE: {
51 | GET_MY_MESSAGES: '/messages/conversations',
52 | GET_MESSAGES_WITH_SPECIFIC_USER: '/messages',
53 | CREATE_MESSAGE: '/messages/create',
54 | UPDATE_MESSAGE: '/messages/update-seen',
55 | },
56 | SEARCH: {
57 | GET_USERS_BY_QUERY: (query: string) => `/search/users/${query}`,
58 | GET_RESULTS_BY_QUERY: (query: string) => `/search/all/${query}`,
59 | },
60 | };
61 |
--------------------------------------------------------------------------------
/src/constants/auth.ts:
--------------------------------------------------------------------------------
1 | export const TOKEN_KEY = 'bigtoria_auth_token';
2 | export const USER_ID_KEY = 'bigtoria_user_id';
3 | export const DISPLAY_MODE = 'bigtoria_display_mode';
4 |
--------------------------------------------------------------------------------
/src/constants/colors.ts:
--------------------------------------------------------------------------------
1 | export const COLORS = {
2 | MAIN: '#f5f5f8',
3 | SUB: '#F99B0F',
4 | STORY_CARD_BORDER: 'rgba(0, 0, 0, 0.2)',
5 | HEADER_BORDER: 'rgba(0, 0, 0, 0.06)',
6 | ERROR: '#D32F2F',
7 | DARK_MODE_HEADER: '#1e1e1e',
8 | MUI_WARNING: '#ed6c02',
9 | MUI_PRIMARY_LIGHT: '#03a9f4',
10 | MUI_TYPOGRAPHY: 'rgba(0, 0, 0, 0.6)',
11 | MUI_LABEL: '#666666',
12 | MUI_LIGHT_HOVER: '#f5f5f5',
13 | };
14 |
--------------------------------------------------------------------------------
/src/constants/errorMessages.ts:
--------------------------------------------------------------------------------
1 | export const ERROR_MESSAGES = {
2 | INVOKED_ERROR_GETTING_STORIES: '스토리북 조회 중 에러가 발생했습니다.',
3 | CHECK_EMAIL_OR_PASSWORD:
4 | '이메일 혹은 비밀번호가 계정과 일치하지 않습니다. 다시 확인해주세요.',
5 | INVOKED_ERROR_GETTING_STORY: '스토리 조회 중 에러가 발생했습니다.',
6 | INVOKED_ERROR_POSTING_STORY: '스토리 작성 중 에러가 발생했습니다.',
7 | INVOKED_ERROR_DELETING_STORY: '스토리 삭제 중 에러가 발생했습니다.',
8 | INVOKED_ERROR_GETTING_COMMENT: '댓글 조회 중 에러가 발생했습니다.',
9 | INVOKED_ERROR_POSTING_COMMENT: '댓글 작성 중 에러가 발생했습니다.',
10 | INVOKED_ERROR_DELETING_COMMENT: '댓글 삭제 중 에러가 발생했습니다.',
11 | INVOKED_ERROR_GETTING_USER: '유저 정보 조회 중 에러가 발생했습니다.',
12 | INVOKED_ERROR_POSTING_LIKE: '좋아요 처리 중 에러가 발생했습니다.',
13 | INVOKED_ERROR_PUTTING_PASSWORD: '비밀번호 변경 중 에러가 발생했습니다.',
14 | INVOKED_ERROR_PUTTING_NICKNAME: '닉네임 변경 중 에러가 발생했습니다.',
15 | };
16 |
--------------------------------------------------------------------------------
/src/constants/http.ts:
--------------------------------------------------------------------------------
1 | import { Method } from 'axios';
2 |
3 | export const HTTP_METHODS: Record = {
4 | GET: 'GET',
5 | POST: 'POST',
6 | PUT: 'PUT',
7 | DELETE: 'DELETE',
8 | };
9 |
10 | export const HTTP_STATUS_CODE = {
11 | OK: 200,
12 | };
13 |
--------------------------------------------------------------------------------
/src/constants/routes.ts:
--------------------------------------------------------------------------------
1 | export const ROUTES = {
2 | HOME: '/',
3 | NOT_FOUND: '/not-found',
4 | STORY_BOOK: '/story-book/:userId',
5 | SIGNUP: '/signup',
6 | FOLLOW: '/follow/:userId',
7 | FOLLOWER: '/follower/:userId',
8 | SIGNIN: '/signin',
9 | NOTIFICATION: '/notification',
10 | STORY: '/story/:storyId',
11 | STORY_EDIT: '/story/edit/:storyId',
12 | STORY_CREATE: '/story/edit/new',
13 | CHAT: '/chat',
14 | PROFILE: '/profile',
15 | FOLLOW_BY_USER_ID: (userId: string) => `/follow/${userId}`,
16 | FOLLOWER_BY_USER_ID: (userId: string) => `/follower/${userId}`,
17 | STORY_BOOK_BY_USER_ID: (userId: string) => `/story-book/${userId}`,
18 | STORY_EDIT_BY_STORY_ID: (storyId: string) => `/story/edit/${storyId}`,
19 | STORY_BY_STORY_ID: (storyId: string) => `/story/${storyId}`,
20 | };
21 |
--------------------------------------------------------------------------------
/src/contexts/DisplayModeContext.tsx:
--------------------------------------------------------------------------------
1 | import { createTheme, ThemeProvider } from '@mui/material';
2 | import { createContext, ReactNode, useContext, useMemo, useState } from 'react';
3 |
4 | import { DISPLAY_MODE } from '../constants/auth';
5 | import { COLORS } from '../constants/colors';
6 | import { DisplayMode } from '../interfaces/displayMode';
7 | import { changeColorTheme } from '../utils/helpers';
8 | import { getLocalStorage, setLocalStorage } from '../utils/storage';
9 |
10 | interface DisplayModeContextProps extends DisplayMode {
11 | toggleDisplayMode: () => void;
12 | }
13 |
14 | const storedDisplayMode = getLocalStorage(DISPLAY_MODE);
15 | const osDisplayMode = window.matchMedia('(prefers-color-scheme: dark)').matches
16 | ? 'dark'
17 | : 'light';
18 | const initialDisplayMode = storedDisplayMode
19 | ? storedDisplayMode
20 | : osDisplayMode;
21 | changeColorTheme(initialDisplayMode);
22 |
23 | const initialState = {
24 | displayMode: initialDisplayMode,
25 | // eslint-disable-next-line @typescript-eslint/no-empty-function
26 | toggleDisplayMode: () => {},
27 | };
28 |
29 | const DisplayModeContext = createContext(initialState);
30 |
31 | const useDisplayModeContext = () => useContext(DisplayModeContext);
32 |
33 | export const DisplayModeProvider = ({ children }: { children: ReactNode }) => {
34 | const [displayMode, setDisplayMode] = useState<'light' | 'dark'>(
35 | initialState.displayMode
36 | );
37 | const context = useMemo(
38 | () => ({
39 | toggleDisplayMode: () => {
40 | setDisplayMode((previousDisplayMode) => {
41 | const nextDisplayMode =
42 | previousDisplayMode === 'light' ? 'dark' : 'light';
43 | changeColorTheme(nextDisplayMode);
44 | setLocalStorage(DISPLAY_MODE, nextDisplayMode);
45 |
46 | return nextDisplayMode;
47 | });
48 | },
49 | displayMode,
50 | }),
51 | [displayMode]
52 | );
53 |
54 | const themeByDisplayMode = useMemo(
55 | () =>
56 | createTheme({
57 | palette: { mode: displayMode === 'dark' ? 'dark' : 'light' },
58 | typography: {
59 | fontFamily: "'MaplestoryOTFLight', cursive",
60 | },
61 | components: {
62 | MuiListItemButton: {
63 | styleOverrides: {
64 | root: {
65 | backgroundColor:
66 | displayMode === 'dark' ? COLORS.DARK_MODE_HEADER : 'white',
67 | '&:hover': {
68 | backgroundColor:
69 | displayMode === 'dark' ? 'black' : COLORS.MUI_LIGHT_HOVER,
70 | },
71 | },
72 | },
73 | },
74 | MuiTextField: {
75 | styleOverrides: {
76 | root: {
77 | '& label': {
78 | color: COLORS.MUI_LABEL,
79 | },
80 | },
81 | },
82 | },
83 | MuiButton: {
84 | styleOverrides: {
85 | text: {
86 | color: COLORS.SUB,
87 | },
88 | },
89 | },
90 | },
91 | }),
92 | [displayMode]
93 | );
94 |
95 | return (
96 |
97 | {children}
98 |
99 | );
100 | };
101 |
102 | export default useDisplayModeContext;
103 |
--------------------------------------------------------------------------------
/src/contexts/NotificationContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | ReactNode,
4 | useContext,
5 | useEffect,
6 | useState,
7 | } from 'react';
8 | import useSWR from 'swr';
9 |
10 | import { getNotificationList } from '../apis/notification';
11 | import { TOKEN_KEY } from '../constants/auth';
12 | import { Notification } from '../interfaces/notification';
13 | import { getLocalStorage } from '../utils/storage';
14 |
15 | interface Props {
16 | children: ReactNode;
17 | }
18 |
19 | interface State {
20 | badgeCount: number;
21 | notifications: Notification[];
22 | }
23 |
24 | const initialState = {
25 | badgeCount: 0,
26 | notifications: [],
27 | };
28 |
29 | export const INTERVAL_TIME = 1000;
30 |
31 | const NotificationsContext = createContext(initialState);
32 |
33 | export const useNotificationsContext = () => useContext(NotificationsContext);
34 |
35 | const NotificationsProvider = ({ children }: Props) => {
36 | const [badgeCount, setBadgeCount] = useState(0);
37 |
38 | const changeBadgeCount = () => {
39 | const token = getLocalStorage(TOKEN_KEY);
40 | if (!token) {
41 | setBadgeCount(0);
42 | return;
43 | }
44 |
45 | const unSeenNotificationCount = notifications.filter(
46 | (notification: Notification) => {
47 | const { seen, like, follow, comment } = notification;
48 |
49 | if (!seen && (like || follow || comment)) return true;
50 | return false;
51 | }
52 | ).length;
53 |
54 | setBadgeCount(unSeenNotificationCount);
55 | };
56 |
57 | const { data: notifications, isLoading } = useSWR(
58 | 'notification',
59 | getNotificationList,
60 | {
61 | refreshInterval: INTERVAL_TIME,
62 | }
63 | );
64 |
65 | useEffect(() => {
66 | if (!isLoading && notifications) {
67 | changeBadgeCount();
68 | }
69 | }, [notifications]);
70 |
71 | return (
72 |
73 | {children}
74 |
75 | );
76 | };
77 |
78 | export default NotificationsProvider;
79 |
--------------------------------------------------------------------------------
/src/hooks/useCheckAuthToken.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import { TOKEN_KEY } from '../constants/auth';
5 | import { ROUTES } from '../constants/routes';
6 | import { getLocalStorage } from '../utils/storage';
7 |
8 | const useCheckAuthToken = () => {
9 | const navigate = useNavigate();
10 | const token = getLocalStorage(TOKEN_KEY);
11 |
12 | useEffect(() => {
13 | token && navigate(ROUTES.HOME);
14 | });
15 | };
16 |
17 | export default useCheckAuthToken;
18 |
--------------------------------------------------------------------------------
/src/hooks/useComment.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, FormEvent, useState } from 'react';
2 | import { useNavigate, useParams } from 'react-router-dom';
3 |
4 | import { deleteStoryComment, postStoryComment } from '../apis/story';
5 | import { ERROR_MESSAGES } from '../constants/errorMessages';
6 | import { ROUTES } from '../constants/routes';
7 | import { isBlankString } from '../utils/validations';
8 | import { postNotification } from './../apis/notification';
9 |
10 | export const useCommentForm = (storyAuthorId: string) => {
11 | const [comment, setComment] = useState('');
12 | const [isLoading, setIsLoading] = useState(false);
13 | const { storyId } = useParams();
14 | const navigate = useNavigate();
15 |
16 | const handleChange = (e: ChangeEvent) => {
17 | setComment(e.target.value);
18 | };
19 |
20 | const handleDelete = async (commentId: string) => {
21 | if (confirm('댓글을 삭제하시겠습니까?')) {
22 | try {
23 | if (!commentId) {
24 | navigate(ROUTES.NOT_FOUND);
25 | return;
26 | }
27 | await deleteStoryComment(commentId);
28 | } catch (error) {
29 | console.error(error);
30 | alert(ERROR_MESSAGES.INVOKED_ERROR_DELETING_COMMENT);
31 | }
32 | }
33 | };
34 |
35 | const handleSubmit = async (e: FormEvent) => {
36 | e.preventDefault();
37 | setIsLoading(true);
38 |
39 | if (isBlankString(comment)) {
40 | alert('댓글 내용을 입력해 주세요.');
41 | setIsLoading(false);
42 | return;
43 | }
44 |
45 | try {
46 | if (!storyId) {
47 | navigate(ROUTES.NOT_FOUND);
48 | return;
49 | }
50 | const { _id, author, post } = await postStoryComment(comment, storyId);
51 |
52 | //게시글 작성자(storyAuthorId)에게 알림 보내기
53 | if (author._id !== storyAuthorId) {
54 | await postNotification('COMMENT', _id, storyAuthorId, post);
55 | }
56 | } catch (error) {
57 | console.error(error);
58 | alert(ERROR_MESSAGES.INVOKED_ERROR_POSTING_COMMENT);
59 | } finally {
60 | setComment('');
61 | }
62 |
63 | setIsLoading(false);
64 | };
65 |
66 | return { comment, isLoading, handleChange, handleDelete, handleSubmit };
67 | };
68 |
69 | export const useDeleteComment = () => {
70 | const navigate = useNavigate();
71 |
72 | const handleDelete = async (commentId: string) => {
73 | if (!confirm('댓글을 삭제하시겠습니까?')) return;
74 | try {
75 | if (!commentId) {
76 | navigate(ROUTES.NOT_FOUND);
77 | return;
78 | }
79 | await deleteStoryComment(commentId);
80 | } catch (error) {
81 | console.error(error);
82 | alert(ERROR_MESSAGES.INVOKED_ERROR_DELETING_COMMENT);
83 | }
84 | };
85 |
86 | return { handleDelete };
87 | };
88 |
--------------------------------------------------------------------------------
/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import useTimeoutFn from './useTimeoutFn';
4 |
5 | interface Props {
6 | fn: () => void;
7 | ms: number;
8 | deps: string[];
9 | }
10 |
11 | const useDebounce = ({ fn, ms, deps }: Props) => {
12 | const [run, clear] = useTimeoutFn({ fn, ms });
13 |
14 | useEffect(run, deps);
15 |
16 | return clear;
17 | };
18 |
19 | export default useDebounce;
20 |
--------------------------------------------------------------------------------
/src/hooks/useFetchStories.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState } from 'react';
2 | import { useNavigate, useParams } from 'react-router-dom';
3 |
4 | import { ERROR_MESSAGES } from '../constants/errorMessages';
5 | import {
6 | StoriesWithYear,
7 | Story,
8 | StoryDate,
9 | StoryMonth,
10 | StoryYear,
11 | } from '../interfaces/story';
12 | import { getStoriesOfUser } from './../apis/story';
13 | import { userInfo } from './../apis/userInfo';
14 | import { ROUTES } from './../constants/routes';
15 | import { User } from './../interfaces/user';
16 |
17 | const TOTAL_MONTHS_NUMBER = 12;
18 |
19 | const useFetchStories = () => {
20 | const [currentUserInfo, setCurrentUserInfo] = useState();
21 | const [stories, setStories] = useState([]);
22 | const [isLoading, setIsLoading] = useState(false);
23 | const { userId } = useParams();
24 | const navigate = useNavigate();
25 | const storiesByYear = useMemo(() => {
26 | const yearsSet = new Set();
27 | const storiesWithYear: StoriesWithYear[] = [];
28 |
29 | stories.forEach((story: Story) => {
30 | const { year }: StoryYear = JSON.parse(story.title);
31 | if (!year) return;
32 | yearsSet.add(year);
33 | });
34 |
35 | yearsSet.forEach((year) => {
36 | const months = new Array(TOTAL_MONTHS_NUMBER).fill(false);
37 |
38 | const storiesFilteredByYear: Story[] = stories.filter((story: Story) => {
39 | const { year: yearOfStory }: StoryYear = JSON.parse(story.title);
40 |
41 | return year === yearOfStory;
42 | });
43 |
44 | storiesFilteredByYear.sort((storyA, storyB) => {
45 | const {
46 | year: yearA,
47 | month: monthA,
48 | day: dayA,
49 | }: StoryDate = JSON.parse(storyA.title);
50 | const {
51 | year: yearB,
52 | month: monthB,
53 | day: dayB,
54 | }: StoryDate = JSON.parse(storyB.title);
55 |
56 | return (
57 | +new Date(
58 | `${String(yearA)}-${String(monthA).padStart(2, '0')}-${String(
59 | dayA
60 | ).padStart(2, '0')}`
61 | ) -
62 | +new Date(
63 | `${String(yearB)}-${String(monthB).padStart(2, '0')}-${String(
64 | dayB
65 | ).padStart(2, '0')}`
66 | )
67 | );
68 | });
69 |
70 | storiesFilteredByYear.forEach((story) => {
71 | const { month }: StoryMonth = JSON.parse(story.title);
72 | if (!months[Number(month) - 1]) {
73 | months[Number(month) - 1] = true;
74 | story.isFirstInSameMonths = true;
75 | }
76 | });
77 |
78 | storiesWithYear.push({
79 | year,
80 | stories: storiesFilteredByYear,
81 | });
82 | });
83 |
84 | storiesWithYear.sort(
85 | (storiesA, storiesB) => +storiesB.year - +storiesA.year
86 | );
87 |
88 | return storiesWithYear;
89 | }, [stories]);
90 |
91 | useEffect(() => {
92 | const fetchStoriesAndUser = async () => {
93 | setIsLoading(true);
94 | try {
95 | if (userId) {
96 | const fetchedStories = await getStoriesOfUser(userId);
97 | const fetchedUserInfo = await userInfo(userId);
98 | setStories(fetchedStories);
99 | setCurrentUserInfo(fetchedUserInfo);
100 | } else {
101 | navigate(ROUTES.NOT_FOUND);
102 | }
103 | } catch (error) {
104 | console.error(error);
105 | alert(ERROR_MESSAGES.INVOKED_ERROR_GETTING_STORIES);
106 | } finally {
107 | setIsLoading(false);
108 | }
109 | };
110 |
111 | fetchStoriesAndUser();
112 | }, [userId]);
113 |
114 | return { storiesByYear, currentUserInfo, isLoading };
115 | };
116 |
117 | export default useFetchStories;
118 |
--------------------------------------------------------------------------------
/src/hooks/useFetchUser.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { checkAuth } from '../apis/auth';
4 | import { ERROR_MESSAGES } from '../constants/errorMessages';
5 | import { User } from '../interfaces/user';
6 |
7 | const useFetchUser = () => {
8 | const [user, setUser] = useState();
9 | const [isLoading, setIsLoading] = useState(false);
10 |
11 | useEffect(() => {
12 | const fetchUser = async () => {
13 | setIsLoading(true);
14 | try {
15 | const user = await checkAuth();
16 | setUser(user);
17 | } catch (error) {
18 | console.error(error);
19 | alert(ERROR_MESSAGES.INVOKED_ERROR_GETTING_USER);
20 | }
21 | setIsLoading(false);
22 | };
23 |
24 | fetchUser();
25 | }, []);
26 |
27 | return { user, isLoading };
28 | };
29 |
30 | export default useFetchUser;
31 |
--------------------------------------------------------------------------------
/src/hooks/useGetFollow.ts:
--------------------------------------------------------------------------------
1 | import { MouseEvent, useState } from 'react';
2 | import { useNavigate, useParams } from 'react-router-dom';
3 |
4 | import { createFollow, removeFollow } from '../apis/follow';
5 | import { getFollowingUser } from '../apis/getFollowUser';
6 | import { postNotification } from '../apis/notification';
7 | import { userInfo } from '../apis/userInfo';
8 | import { List } from '../interfaces/followList';
9 | import { getChangedIndex } from '../utils/getChangedIndex';
10 | import { ROUTES } from './../constants/routes';
11 |
12 | const BUTTON_MESSAGE = {
13 | FOLLOW: '팔로우',
14 | DELETE: '언팔',
15 | };
16 |
17 | const useGetFollow = () => {
18 | const { userId } = useParams();
19 | const [loading, setLoading] = useState(false);
20 | const [followLoading, setFollowLoading] = useState(false);
21 | const [followingList, setFollowingList] = useState([]);
22 | const navigate = useNavigate();
23 |
24 | const getUserInfo = async () => {
25 | try {
26 | setLoading(true);
27 | const infoList: List[] = [];
28 | if (userId) {
29 | const res = await userInfo(userId);
30 | res.following.map(({ _id, user }: List) => {
31 | infoList.push({ _id, user });
32 | });
33 | }
34 | if (userId) {
35 | const res = await getFollowingUser(infoList.map((data) => data.user));
36 | res.map(
37 | ({ fullName, image, isOnline, coverImage, username }, index) => {
38 | infoList[index].fullName = fullName;
39 | infoList[index].image = image;
40 | infoList[index].isOnline = isOnline;
41 | infoList[index].coverImage = coverImage;
42 | infoList[index].username = username;
43 | }
44 | );
45 | }
46 | setFollowingList(infoList);
47 | } catch (error) {
48 | navigate(ROUTES.NOT_FOUND);
49 | console.error(error);
50 | } finally {
51 | setLoading(false);
52 | }
53 | };
54 |
55 | const handleClick = async (e: MouseEvent) => {
56 | const { currentTarget } = e;
57 |
58 | if (currentTarget.dataset && currentTarget.firstChild) {
59 | try {
60 | setFollowLoading(true);
61 | const { followid, userid } = currentTarget.dataset;
62 | const text = currentTarget.textContent;
63 | if (text === BUTTON_MESSAGE.DELETE) {
64 | if (followid && userid) {
65 | await removeFollow(followid);
66 | }
67 | } else {
68 | if (userid) {
69 | const res = await createFollow(userid);
70 | res &&
71 | (await postNotification('FOLLOW', res.data._id, userid, null));
72 | const changedIndex = getChangedIndex(followingList, followid);
73 | const infoList = [...followingList];
74 | infoList[changedIndex]._id = res?.data._id;
75 | setFollowingList(infoList);
76 | }
77 | }
78 | } catch (error) {
79 | console.error(error);
80 | } finally {
81 | setFollowLoading(false);
82 | }
83 | }
84 | };
85 |
86 | return {
87 | followingList,
88 | loading,
89 | followLoading,
90 | getUserInfo,
91 | handleClick,
92 | };
93 | };
94 |
95 | export default useGetFollow;
96 |
--------------------------------------------------------------------------------
/src/hooks/useGetFollower.ts:
--------------------------------------------------------------------------------
1 | import { MouseEvent, useState } from 'react';
2 | import { useNavigate, useParams } from 'react-router-dom';
3 |
4 | import { createFollow } from '../apis/follow';
5 | import { getFollowerUser } from '../apis/getFollowUser';
6 | import { userInfo } from '../apis/userInfo';
7 | import { FollowerList, List } from '../interfaces/followList';
8 | import { ROUTES } from './../constants/routes';
9 |
10 | const useGetFollower = () => {
11 | const { userId } = useParams();
12 | const [loading, setLoading] = useState(false);
13 | const [followerList, setFollowerList] = useState([]);
14 | const [f4f, setF4f] = useState([]); // 맞팔인지 확인
15 | const navigate = useNavigate();
16 |
17 | const getUserInfo = async () => {
18 | try {
19 | setLoading(true);
20 | const infoList: FollowerList[] = [];
21 | const f4fIdList: string[][] = [[], []];
22 | if (userId) {
23 | const res = await userInfo(userId);
24 | res.followers.map(({ _id, follower }: FollowerList) => {
25 | infoList.push({ _id, follower });
26 | f4fIdList[0].push(follower);
27 | });
28 | res.following.map(({ user }: List) => {
29 | f4fIdList[1].push(user);
30 | });
31 | }
32 | if (userId) {
33 | const res = await getFollowerUser(
34 | infoList.map((data) => data.follower)
35 | );
36 | res.map(
37 | ({ fullName, image, isOnline, coverImage, username }, index) => {
38 | infoList[index].fullName = fullName;
39 | infoList[index].image = image;
40 | infoList[index].isOnline = isOnline;
41 | infoList[index].coverImage = coverImage;
42 | infoList[index].username = username;
43 | }
44 | );
45 | }
46 | setF4f(f4fIdList);
47 | setFollowerList(infoList);
48 | } catch (error) {
49 | navigate(ROUTES.NOT_FOUND);
50 | console.error(error);
51 | } finally {
52 | setLoading(false);
53 | }
54 | };
55 |
56 | const handleClick = async (e: MouseEvent) => {
57 | const { currentTarget } = e;
58 | try {
59 | const { userid } = currentTarget.dataset;
60 | if (userid) {
61 | await createFollow(userid);
62 | }
63 | } catch (error) {
64 | console.error(error);
65 | }
66 | };
67 |
68 | return {
69 | followerList,
70 | loading,
71 | f4f,
72 | getUserInfo,
73 | handleClick,
74 | };
75 | };
76 |
77 | export default useGetFollower;
78 |
--------------------------------------------------------------------------------
/src/hooks/useInfiniteScroll.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { getUserList, searchUserList } from '../apis/search';
4 | import { DATA_LIMIT } from '../constants/apiParams';
5 | import { User } from '../interfaces/user';
6 | import useIntersectionObserver from './useIntersectionObserver';
7 |
8 | const useInfiniteScroll = () => {
9 | const [data, setData] = useState(null);
10 | const [searchedData, setSearchedData] = useState(null);
11 | const [isLoaded, setIsLoaded] = useState(false);
12 | const [isSearched, setIsSearched] = useState(false);
13 | const [isAllRendered, setIsAllRendered] = useState(false);
14 | const [offset, setOffset] = useState(0);
15 |
16 | const initAllStateAndGetDataWithAPI = async () => {
17 | setIsLoaded(true);
18 |
19 | //initialize
20 | setIsAllRendered(false);
21 | setIsSearched(false);
22 | setOffset(1);
23 |
24 | const result = await getUserList();
25 |
26 | setData(result);
27 |
28 | setIsLoaded(false);
29 | };
30 |
31 | const searchDataWithState = async (keyword: string) => {
32 | setIsLoaded(true);
33 |
34 | //initialize
35 | setIsAllRendered(true);
36 | setIsSearched(true);
37 | setOffset(1);
38 |
39 | const filteredUsers = await searchUserList(keyword);
40 |
41 | if (!filteredUsers) {
42 | setData([]);
43 | setIsLoaded(false);
44 | return;
45 | }
46 |
47 | setSearchedData(filteredUsers);
48 | setData(filteredUsers.slice(0, DATA_LIMIT));
49 |
50 | if (filteredUsers.length > DATA_LIMIT) setIsAllRendered(false);
51 |
52 | setIsLoaded(false);
53 | };
54 |
55 | const getMoreDataWithAPI = async () => {
56 | setIsLoaded(true);
57 |
58 | const result = await getUserList(offset * DATA_LIMIT);
59 |
60 | setData([...(data || []), ...result]);
61 |
62 | setOffset((cur) => cur + 1);
63 |
64 | if (result.length === 0) setIsAllRendered(true);
65 |
66 | setIsLoaded(false);
67 | };
68 |
69 | const getMoreDataWithState = () => {
70 | const start = offset * DATA_LIMIT;
71 | const bound =
72 | searchedData && start + DATA_LIMIT >= searchedData.length
73 | ? searchedData.length
74 | : start + DATA_LIMIT;
75 |
76 | setData([...(data || []), ...(searchedData?.slice(start, bound) || [])]);
77 |
78 | setOffset((cur) => cur + 1);
79 |
80 | searchedData && bound === searchedData.length && setIsAllRendered(true);
81 | };
82 |
83 | const { setTarget } = useIntersectionObserver({
84 | root: null,
85 | rootMargin: '0px',
86 | threshold: 0.5,
87 | onIntersect: async ([{ isIntersecting }]) => {
88 | if (isIntersecting && !isLoaded) {
89 | !isSearched ? await getMoreDataWithAPI() : getMoreDataWithState();
90 | }
91 | },
92 | });
93 |
94 | return {
95 | setTarget,
96 | data,
97 | setData,
98 | isLoaded,
99 | setIsLoaded,
100 | isAllRendered,
101 | initAllStateAndGetDataWithAPI,
102 | searchDataWithState,
103 | };
104 | };
105 |
106 | export default useInfiniteScroll;
107 |
--------------------------------------------------------------------------------
/src/hooks/useIntersectionObserver.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | interface Props {
4 | root?: null;
5 | rootMargin?: string;
6 | threshold?: number;
7 | onIntersect: IntersectionObserverCallback;
8 | }
9 |
10 | const useIntersectionObserver = ({
11 | root,
12 | rootMargin,
13 | threshold,
14 | onIntersect,
15 | }: Props) => {
16 | const [target, setTarget] = useState(null);
17 |
18 | useEffect(() => {
19 | if (!target) return;
20 |
21 | const observer: IntersectionObserver = new IntersectionObserver(
22 | onIntersect,
23 | { root, rootMargin, threshold }
24 | );
25 |
26 | observer.observe(target);
27 |
28 | return () => observer.unobserve(target);
29 | }, [onIntersect, root, rootMargin, target, threshold]);
30 |
31 | return { setTarget };
32 | };
33 |
34 | export default useIntersectionObserver;
35 |
--------------------------------------------------------------------------------
/src/hooks/useIsOverByScroll.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 |
3 | const useIsOverByScroll = () => {
4 | const ref = useRef(null);
5 | const [isOverByScroll, setIsOverByScroll] = useState(false);
6 |
7 | useEffect(() => {
8 | const refHeight = ref.current?.getBoundingClientRect();
9 | const scrollEvent = () => {
10 | if (refHeight && window.scrollY > refHeight?.height) {
11 | setIsOverByScroll(true);
12 | return;
13 | }
14 |
15 | setIsOverByScroll(false);
16 | };
17 |
18 | document.addEventListener('scroll', scrollEvent);
19 | return () => {
20 | document.removeEventListener('scroll', scrollEvent);
21 | };
22 | }, []);
23 |
24 | return { ref, isOverByScroll };
25 | };
26 |
27 | export default useIsOverByScroll;
28 |
--------------------------------------------------------------------------------
/src/hooks/useLazyLoadImage.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 |
3 | const LOAD_IMAGE_EVENT_TYPE = 'loadImage';
4 |
5 | const observerOptions = {
6 | rootMargin: '200px',
7 | threshold: 0.1,
8 | };
9 |
10 | const useLazyLoadImage = (lazy = false) => {
11 | const [loaded, setLoaded] = useState(false);
12 | const imageRef = useRef(null);
13 |
14 | useEffect(() => {
15 | if (!lazy) {
16 | setLoaded(true);
17 | return;
18 | }
19 |
20 | const handleLoadImage = () => {
21 | setLoaded(true);
22 | };
23 |
24 | imageRef.current?.addEventListener(LOAD_IMAGE_EVENT_TYPE, handleLoadImage);
25 |
26 | return () => {
27 | imageRef.current?.removeEventListener(
28 | LOAD_IMAGE_EVENT_TYPE,
29 | handleLoadImage
30 | );
31 | };
32 | }, []);
33 |
34 | useEffect(() => {
35 | if (!lazy) return;
36 |
37 | const observer = new IntersectionObserver(
38 | (entries, intersectionObserver) => {
39 | entries.forEach((entry) => {
40 | if (entry.isIntersecting) {
41 | intersectionObserver.unobserve(entry.target);
42 | entry.target.dispatchEvent(new CustomEvent(LOAD_IMAGE_EVENT_TYPE));
43 | }
44 | });
45 | },
46 | observerOptions
47 | );
48 |
49 | imageRef.current && observer.observe(imageRef.current);
50 | }, []);
51 |
52 | return { loaded, imageRef };
53 | };
54 |
55 | export default useLazyLoadImage;
56 |
--------------------------------------------------------------------------------
/src/hooks/useLike.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { deleteStoryLike } from '../apis/story';
4 | import { ERROR_MESSAGES } from '../constants/errorMessages';
5 | import { Like } from '../interfaces/like';
6 | import { postNotification } from './../apis/notification';
7 | import { postStoryLike } from './../apis/story';
8 |
9 | const useLike = (
10 | userId: string,
11 | authorId: string,
12 | storyLikes: Like[],
13 | storyId: string
14 | ) => {
15 | const [isLike, setIsLike] = useState(false);
16 | const [likeId, setLikeId] = useState('');
17 | const [likeCount, setLikeCount] = useState(storyLikes.length);
18 |
19 | useEffect(() => {
20 | const findStoryLike = storyLikes.find((data) => data.user === userId);
21 | if (findStoryLike) {
22 | setIsLike(true);
23 | setLikeId(findStoryLike._id);
24 | }
25 | }, [userId]);
26 |
27 | const handleClick = async () => {
28 | if (!userId) {
29 | alert('로그인 후 이용해 주세요.');
30 | return;
31 | }
32 |
33 | try {
34 | if (isLike) {
35 | await deleteStoryLike(likeId);
36 | setIsLike(false);
37 | setLikeId('');
38 | setLikeCount(likeCount - 1);
39 | } else {
40 | const { _id, user } = await postStoryLike(storyId);
41 | setIsLike(true);
42 | setLikeId(_id);
43 | setLikeCount(likeCount + 1);
44 | //like를 누른 사람(user)과 게시글 작성자(authorId)가 다르다면 Like보내기
45 | if (user !== authorId) {
46 | await postNotification('LIKE', _id, authorId, storyId);
47 | }
48 | }
49 | } catch (error) {
50 | console.error(error);
51 | alert(ERROR_MESSAGES.INVOKED_ERROR_POSTING_LIKE);
52 | }
53 | };
54 |
55 | return { isLike, likeCount, handleClick };
56 | };
57 |
58 | export default useLike;
59 |
--------------------------------------------------------------------------------
/src/hooks/useSearchForm.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, useState } from 'react';
2 |
3 | import { validateSearchInput } from '../utils/validationSearchForm';
4 |
5 | interface Props {
6 | onSubmit: (keyword: string) => void;
7 | }
8 |
9 | const useSearchForm = ({ onSubmit }: Props) => {
10 | const [value, setValue] = useState('');
11 | const [error, setError] = useState({
12 | keyword: '',
13 | });
14 |
15 | const handleInputChange = (e: ChangeEvent) => {
16 | const keyword = e.target.value;
17 |
18 | const newError = validateSearchInput(keyword);
19 | setError(newError);
20 |
21 | if (!newError.keyword.length || keyword.length === 0) {
22 | onSubmit(keyword);
23 | setError({ keyword: '' });
24 | setValue(keyword.replace(/[\s]/g, ''));
25 | }
26 | };
27 |
28 | const handleInputClear = () => {
29 | setValue('');
30 | onSubmit('');
31 | };
32 |
33 | const handleFormSubmit = (e: ChangeEvent) => {
34 | e.preventDefault();
35 | };
36 |
37 | return {
38 | value,
39 | error,
40 | handleInputChange,
41 | handleInputClear,
42 | handleFormSubmit,
43 | };
44 | };
45 |
46 | export default useSearchForm;
47 |
--------------------------------------------------------------------------------
/src/hooks/useSignInForm.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import { signin } from '../apis/auth';
5 | import { ERROR_MESSAGES } from '../constants/errorMessages';
6 | import { ROUTES } from '../constants/routes';
7 | import { validateSignInInput } from './../utils/validations';
8 |
9 | interface InitialState {
10 | email: string;
11 | password: string;
12 | }
13 |
14 | const useSignInForm = (initialState: InitialState) => {
15 | const [values, setValues] = useState(initialState);
16 | const [errors, setErrors] = useState(initialState);
17 | const [isLoading, setIsLoading] = useState(false);
18 | const navigate = useNavigate();
19 |
20 | const handleChange = (
21 | event: React.ChangeEvent
22 | ) => {
23 | const { name, value } = event.target;
24 | setValues({ ...values, [name]: value });
25 | setErrors({ ...errors, [name]: '' });
26 | };
27 |
28 | const handleSubmit = async (event: React.FormEvent) => {
29 | event.preventDefault();
30 |
31 | setIsLoading(true);
32 | const { isPassed, errors: newErrors } = validateSignInInput(values);
33 | if (isPassed) {
34 | const { isSignInFailed, errorMessage } = await signin(values);
35 |
36 | if (!isSignInFailed) {
37 | navigate(ROUTES.HOME);
38 | setIsLoading(false);
39 | return;
40 | }
41 |
42 | if (errorMessage) {
43 | setErrors({
44 | ...newErrors,
45 | password: ERROR_MESSAGES.CHECK_EMAIL_OR_PASSWORD,
46 | });
47 | setIsLoading(false);
48 | return;
49 | }
50 | }
51 | setErrors(newErrors);
52 | setIsLoading(false);
53 | };
54 |
55 | return {
56 | values,
57 | errors,
58 | isLoading,
59 | handleChange,
60 | handleSubmit,
61 | };
62 | };
63 |
64 | export default useSignInForm;
65 |
--------------------------------------------------------------------------------
/src/hooks/useSignUpForm.ts:
--------------------------------------------------------------------------------
1 | import dayjs, { Dayjs } from 'dayjs';
2 | import { ChangeEvent, FormEvent, useState } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import { signin } from '../apis/auth';
6 | import { postSignUp } from '../apis/signup';
7 | import { getUserList } from '../apis/userList';
8 | import { CHANNEL_ID } from '../constants/apiParams';
9 | import { User } from '../interfaces/user';
10 | import { getDateInfo } from '../utils/helpers';
11 | import { signUpIsValid } from '../utils/signUpIsValid';
12 | import { signUpValidate } from '../utils/signUpValidate';
13 | import { postStory } from './../apis/story';
14 | import { ROUTES } from './../constants/routes';
15 |
16 | const error = {
17 | fullName: '',
18 | email: '',
19 | password: '',
20 | passwordConfirm: '',
21 | date: '',
22 | job: '',
23 | };
24 |
25 | const initialState = {
26 | fullName: '',
27 | email: '',
28 | password: '',
29 | passwordConfirm: '',
30 | date: getDateInfo(dayjs(new Date())),
31 | job: '',
32 | };
33 |
34 | const today = dayjs(new Date());
35 |
36 | const useSignUpForm = () => {
37 | const [values, setValues] = useState(initialState);
38 | const [errors, setErrors] = useState(error);
39 | const [date, setDate] = useState(today);
40 | const [isLoading, setIsLoading] = useState(false);
41 | const [isChecked, setIsChecked] = useState(false);
42 | const navigate = useNavigate();
43 |
44 | const handleChange = (e: ChangeEvent) => {
45 | const { name, value } = e.target;
46 | if (name === 'fullName') setIsChecked(false);
47 | setValues({ ...values, [name]: value.replace(/\s/g, '') });
48 | };
49 |
50 | const handleDuplicate = async () => {
51 | const res = await getUserList();
52 | const nameList = res.map((user: User) => user.fullName);
53 | const fullNameRegex = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]{2,8}$/;
54 | const koreanRegex = /^[A-Za-z0-9가-힣]{2,8}$/;
55 | if (nameList.includes(values.fullName))
56 | alert('중복된 닉네임 입니다. 다른 닉네임을 입력해주세요.');
57 | else if (!fullNameRegex.test(values.fullName))
58 | alert('영어, 한글, 숫자 (2~8자리)로 입력해주세요.');
59 | else if (!koreanRegex.test(values.fullName))
60 | alert(
61 | '한글은 완성된 단어로 입력해주세요. \n(자음과 모음은 독립적으로 사용이 불가능합니다.)'
62 | );
63 | else {
64 | alert('사용가능한 닉네임입니다.');
65 | setErrors({ ...errors, fullName: '' });
66 | setIsChecked(true);
67 | }
68 | };
69 |
70 | const handleDateChange = (newValue: Dayjs | null) => {
71 | setDate(newValue);
72 | if (newValue) setValues({ ...values, date: getDateInfo(newValue) });
73 | };
74 |
75 | const generateFormData = () => {
76 | const formData = new FormData();
77 | formData.append(
78 | 'title',
79 | JSON.stringify({
80 | storyTitle: `${values.fullName}님 탄생일`,
81 | year: values.date.year,
82 | month: values.date.month,
83 | day: values.date.day,
84 | content: '🥳 해삐 바쓰데이 🎉',
85 | })
86 | );
87 | formData.append('channelId', CHANNEL_ID);
88 | return formData;
89 | };
90 |
91 | const handleSubmit = async (e: FormEvent) => {
92 | e.preventDefault();
93 |
94 | const newError = signUpValidate(values);
95 | setErrors(newError);
96 |
97 | if (signUpIsValid(newError)) {
98 | setIsLoading(true);
99 | if (!isChecked) {
100 | alert('중복확인 버튼을 눌러주세요');
101 | setIsLoading(false);
102 | return;
103 | }
104 | try {
105 | await postSignUp(values);
106 | await signin({ email: values.email, password: values.password });
107 | const formData = generateFormData();
108 | await postStory(formData);
109 | navigate(ROUTES.HOME);
110 | setTimeout(function () {
111 | alert('가입이 완료되었습니다.');
112 | }, 0);
113 | } catch (error) {
114 | console.error(error);
115 | } finally {
116 | setIsLoading(false);
117 | }
118 | }
119 | };
120 |
121 | return {
122 | values,
123 | isLoading,
124 | date,
125 | handleSubmit,
126 | handleChange,
127 | handleDateChange,
128 | handleDuplicate,
129 | errors,
130 | };
131 | };
132 |
133 | export default useSignUpForm;
134 |
--------------------------------------------------------------------------------
/src/hooks/useTimeoutFn.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react';
2 |
3 | interface Props {
4 | fn: () => void;
5 | ms: number;
6 | }
7 |
8 | const useTimeoutFn = ({ fn, ms }: Props) => {
9 | const timeoutId = useRef(0);
10 | const callback = useRef(fn);
11 |
12 | useEffect(() => {
13 | callback.current = fn;
14 | }, [fn]);
15 |
16 | const run = useCallback(() => {
17 | timeoutId.current && clearTimeout(timeoutId.current);
18 | timeoutId.current = setTimeout(() => {
19 | callback.current();
20 | }, ms);
21 | }, [ms]);
22 |
23 | const clear = useCallback(() => {
24 | timeoutId.current && clearTimeout(timeoutId.current);
25 | }, [timeoutId]);
26 |
27 | useEffect(() => clear, [clear]);
28 |
29 | return [run, clear];
30 | };
31 |
32 | export default useTimeoutFn;
33 |
--------------------------------------------------------------------------------
/src/interfaces/comment.ts:
--------------------------------------------------------------------------------
1 | import { User } from './user';
2 |
3 | export interface Comment {
4 | _id: string;
5 | comment: string;
6 | author: User;
7 | post: string; // 포스트 id
8 | createdAt: string;
9 | updatedAt: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/interfaces/displayMode.ts:
--------------------------------------------------------------------------------
1 | export interface DisplayMode {
2 | displayMode: 'light' | 'dark';
3 | }
4 |
--------------------------------------------------------------------------------
/src/interfaces/followList.ts:
--------------------------------------------------------------------------------
1 | export interface List {
2 | _id: string;
3 | user: string;
4 | follower?: string;
5 | fullName?: string;
6 | image?: string;
7 | coverImage?: string;
8 | username?: string;
9 | isOnline?: boolean;
10 | followingId?: string;
11 | }
12 |
13 | export interface FollowerList {
14 | _id: string;
15 | follower: string;
16 | fullName?: string;
17 | image?: string;
18 | isOnline?: boolean;
19 | coverImage?: string;
20 | username?: string;
21 | followingId?: string;
22 | }
23 |
--------------------------------------------------------------------------------
/src/interfaces/like.ts:
--------------------------------------------------------------------------------
1 | export interface Like {
2 | _id: string;
3 | user: string; // 사용자 id
4 | post: string; // 포스트 id
5 | createdAt: string;
6 | updatedAt: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/interfaces/message.ts:
--------------------------------------------------------------------------------
1 | export interface Message {
2 | _id: string[];
3 | message: string;
4 | sender: {
5 | _id: string;
6 | createdAt: string;
7 | fullName: string;
8 | image?: string;
9 | };
10 | receiver: {
11 | createdAt: string;
12 | fullName: string;
13 | _id: string;
14 | image?: string;
15 | };
16 | seen: boolean;
17 | createdAt: string;
18 | updatedAt: string;
19 | }
20 |
--------------------------------------------------------------------------------
/src/interfaces/notification.ts:
--------------------------------------------------------------------------------
1 | import { Comment } from './comment';
2 | import { Like } from './like';
3 | import { Message } from './message';
4 | import { Follow, User } from './user';
5 |
6 | export interface Notification {
7 | _id: string;
8 | author: User;
9 | post?: string;
10 | like?: Like;
11 | comment?: Comment;
12 | follow?: Follow;
13 | message?: Message;
14 | seen: boolean;
15 | createdAt?: string;
16 | updatedAt?: string;
17 | }
18 |
--------------------------------------------------------------------------------
/src/interfaces/signUp.ts:
--------------------------------------------------------------------------------
1 | export interface args {
2 | fullName: string;
3 | email: string;
4 | password: string;
5 | passwordConfirm: string;
6 | date: {
7 | year: number;
8 | month: number;
9 | day: number;
10 | };
11 | job: string;
12 | }
13 |
--------------------------------------------------------------------------------
/src/interfaces/story.ts:
--------------------------------------------------------------------------------
1 | import { Comment } from './comment';
2 | import { Like } from './like';
3 | import { User } from './user';
4 |
5 | export interface Story {
6 | _id: string;
7 | title: string;
8 | image: string;
9 | isFirstInSameMonths?: boolean;
10 | }
11 |
12 | export interface StoryWithIsFirstInSameMonths extends Story {
13 | isFirstInSameMonths: boolean;
14 | }
15 |
16 | export interface StoryYear {
17 | year: string;
18 | }
19 |
20 | export interface StoryMonth {
21 | month: string;
22 | }
23 |
24 | export interface StoryDay {
25 | day: string;
26 | }
27 |
28 | export interface Title {
29 | storyTitle: string;
30 | }
31 |
32 | export interface StoryDate extends StoryYear, StoryMonth, StoryDay {}
33 |
34 | export interface StoriesWithYear extends StoryYear {
35 | stories: Story[];
36 | }
37 |
38 | export interface StoryData {
39 | likes: Like[];
40 | comments: Comment[];
41 | _id: string;
42 | imagePublicId: string;
43 | image: string;
44 | title: string;
45 | author: User;
46 | createdAt: string;
47 | updatedAt: string;
48 | }
49 |
50 | export interface StoryInfo {
51 | title: string;
52 | date: {
53 | year: number;
54 | month: number;
55 | day: number;
56 | };
57 | imageURL?: string;
58 | content: string;
59 | }
60 |
--------------------------------------------------------------------------------
/src/interfaces/user.ts:
--------------------------------------------------------------------------------
1 | export interface Follow {
2 | _id: string;
3 | user: string;
4 | follower: string;
5 | createdAt: string;
6 | updatedAt: string;
7 | }
8 |
9 | export interface User {
10 | _id: string;
11 | image: string; // 프로필 이미지
12 | fullName: string;
13 | role: string;
14 | coverImage?: string; // 커버 이미지
15 | emailVerified?: boolean; // 사용되지 않음
16 | banned?: boolean; // 사용되지 않음
17 | isOnline?: boolean;
18 | posts?: [];
19 | likes?: [];
20 | comments?: [];
21 | followers?: [];
22 | following?: Follow[];
23 | notifications?: [];
24 | messages?: [];
25 | email?: string;
26 | createdAt?: string;
27 | updatedAt?: string;
28 | username?: string;
29 | }
30 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 |
5 | import App from './App.js';
6 | import { DisplayModeProvider } from './contexts/DisplayModeContext.js';
7 | import NotificationsProvider from './contexts/NotificationContext.js';
8 | import GlobalStyle from './styles/GlobalStyle.js';
9 |
10 | const rootElement = document.getElementById('root');
11 | if (!rootElement) throw new Error('Failed to find the root element');
12 | const root = ReactDOM.createRoot(rootElement);
13 |
14 | root.render(
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Button, Typography } from '@mui/material';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import notFound from '../assets/images/notFound.png';
6 | import { COLORS } from '../constants/colors';
7 |
8 | const NotFound = () => {
9 | const navigate = useNavigate();
10 |
11 | return (
12 |
13 |
14 |
15 | 페이지를 찾을수 없습니다
16 |
17 |
23 | 페이지가 존재하지 않거나, 사용할 수 없는 페이지입니다.
24 | 입력하신 주소가 정확한지 다시 한번 확인해 주시기 바랍니다.
25 |
26 |
29 |
30 | );
31 | };
32 |
33 | export default NotFound;
34 |
35 | const WarningContainer = styled.div`
36 | display: flex;
37 | flex-direction: column;
38 | justify-content: center;
39 | align-items: center;
40 | `;
41 |
42 | const Image = styled.img`
43 | height: 400px;
44 | width: 400px;
45 | `;
46 |
--------------------------------------------------------------------------------
/src/pages/Chat.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import {
3 | Avatar,
4 | List,
5 | ListItem,
6 | ListItemAvatar,
7 | ListItemButton,
8 | ListItemText,
9 | Typography,
10 | } from '@mui/material';
11 | import { useEffect, useState } from 'react';
12 | import { useLocation } from 'react-router-dom';
13 |
14 | import http from '../apis/instance';
15 | import MessageInputForm from '../components/Message/MessageInputForm';
16 | import { API_URLS } from '../constants/apiUrls';
17 | import { TOKEN_KEY } from '../constants/auth';
18 | import { Message } from '../interfaces/message';
19 | import { getLocalStorage } from '../utils/storage';
20 |
21 | const Chat = () => {
22 | const hasToken = getLocalStorage(TOKEN_KEY) ? true : false;
23 | const { state: userInfo } = useLocation();
24 | const [conversationPartner, setConversationPartner] = useState(
25 | userInfo.user ?? ''
26 | );
27 | const [messages, setMessages] = useState([]);
28 | const [specificUsers, setSpecificUser] = useState([]);
29 |
30 | const handleListItemClick = (index: string) => {
31 | setConversationPartner(index);
32 | };
33 |
34 | useEffect(() => {
35 | (async () => {
36 | await http
37 | .get({
38 | url: API_URLS.MESSAGE.GET_MY_MESSAGES,
39 | })
40 | .then((data) => {
41 | setMessages(
42 | userInfo?.fullName === undefined
43 | ? data.data
44 | : data.data.find(
45 | (user: Message) => user.receiver._id === userInfo.user
46 | )
47 | ? data.data
48 | : [
49 | {
50 | _id: [userInfo.user],
51 | message: '',
52 | sender: {
53 | createdAt: '',
54 | fullName: '나',
55 | },
56 | receiver: {
57 | createdAt: '',
58 | fullName: userInfo.fullName,
59 | _id: userInfo.user,
60 | },
61 | seen: false,
62 | createdAt: '',
63 | updatedAt: '',
64 | },
65 | ...data.data,
66 | ]
67 | );
68 | });
69 | })();
70 | }, [specificUsers]);
71 |
72 | useEffect(() => {
73 | if (conversationPartner !== '') updateConversationPartner();
74 | }, [conversationPartner, specificUsers]);
75 |
76 | const updateConversationPartner = async () => {
77 | await http
78 | .get({
79 | url: '/messages',
80 | params: {
81 | userId: conversationPartner,
82 | },
83 | })
84 | .then((data) => setSpecificUser(data.data));
85 | };
86 |
87 | /* TODO : 채팅 API 구현
88 | const businessLogic = () => {
89 | // const myConversationPartnersLastMessage: MyPartnerLastMessage[];
90 |
91 | // messages?.map((message) => console.log(message));
92 |
93 | const myConversationPartners = messages
94 | ?.map((message) => message._id)
95 | .map((id) => {
96 | return id.find((v) => v !== myID);
97 | });
98 |
99 | const myConversationPartnersData = myConversationPartners.map(async (v) => {
100 | return {
101 | partner: v,
102 | partnerImage: await userSearch(v as string).then((data) => data?.image),
103 | };
104 | });
105 |
106 | const promiseMyConversationPartnersMessages = myConversationPartners.map(
107 | async (partner) =>
108 | await http
109 | .get({
110 | url: '/messages',
111 | params: {
112 | userId: partner,
113 | },
114 | })
115 | .then((data) => {
116 | return {
117 | partner,
118 | lastMessage: data.data,
119 | };
120 | })
121 | );
122 |
123 | const MyConversationPartners = () =>
124 | promiseMyConversationPartnersMessages.map((myConversation) =>
125 | myConversation.then((data) => {
126 | return data;
127 | })
128 | );
129 | }; */
130 |
131 | return (
132 |
133 |
134 | {!hasToken && 로그인이 필요해요
}
135 | {messages.map((message) => {
136 | return (
137 | {
141 | setConversationPartner(message.receiver._id);
142 | updateConversationPartner();
143 | }}>
144 | handleListItemClick(message.receiver._id)}
146 | selected={message.receiver._id === conversationPartner}>
147 |
148 |
152 |
153 |
161 |
162 | {message.message}
163 | {message.updatedAt}
164 |
165 |
166 | }
167 | />
168 |
169 |
170 | );
171 | })}
172 |
173 |
177 |
178 | );
179 | };
180 |
181 | export default Chat;
182 |
183 | const Wrapper = styled.div`
184 | display: flex;
185 | height: 70vh;
186 | ::-webkit-scrollbar {
187 | display: none;
188 | }
189 | `;
190 |
191 | const TypoContents = styled.div`
192 | display: flex;
193 | flex-direction: column;
194 | justify-content: space-between;
195 | font-size: 0.6rem;
196 | `;
197 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { Box, CircularProgress, Typography } from '@mui/material';
2 | import { useState } from 'react';
3 |
4 | import SearchForm from '../components/Home/SearchForm.js';
5 | import UserList from '../components/Home/UserList.js';
6 | import useDebounce from '../hooks/useDebounce.js';
7 | import useInfiniteScroll from '../hooks/useInfiniteScroll.js';
8 |
9 | const Home = () => {
10 | const {
11 | setTarget,
12 | data,
13 | isLoaded,
14 | isAllRendered,
15 | initAllStateAndGetDataWithAPI,
16 | searchDataWithState,
17 | } = useInfiniteScroll();
18 |
19 | const [keyword, setKeyword] = useState('');
20 |
21 | const handleSubmit = async (keyword: string) => {
22 | setKeyword(keyword);
23 | };
24 |
25 | useDebounce({
26 | fn: async () => {
27 | keyword === ''
28 | ? await initAllStateAndGetDataWithAPI()
29 | : await searchDataWithState(keyword);
30 | },
31 | ms: 300,
32 | deps: [keyword],
33 | });
34 |
35 | return (
36 |
47 |
54 | B.
55 |
56 |
63 |
64 |
65 |
72 | This is...
73 |
74 | {data && }
75 |
76 |
77 | {!isAllRendered && (
78 |
88 | {isLoaded && (
89 |
96 | )}
97 |
98 | )}
99 |
100 | );
101 | };
102 |
103 | export default Home;
104 |
--------------------------------------------------------------------------------
/src/pages/Notification.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button } from '@mui/material';
2 | import { useEffect, useState } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import {
6 | checkNotificationSeen,
7 | getNotificationList,
8 | } from '../apis/notification';
9 | import NotificationList from '../components/Notification/NotificationList';
10 | import TabContainer from '../components/Notification/TabContainer';
11 | import { ROUTES } from '../constants/routes';
12 | import { useNotificationsContext } from '../contexts/NotificationContext';
13 | import { Notification as NotificationType } from '../interfaces/notification';
14 |
15 | const { SIGNIN } = ROUTES;
16 |
17 | const DEFAULT_TAB_VALUE = 'post';
18 | const CHECK_ALL_NOTIFICATION = '전체 읽음';
19 |
20 | const Notification = () => {
21 | const [tabValue, setTabValue] = useState(DEFAULT_TAB_VALUE);
22 | const [notifications, setNotifications] = useState([]);
23 | const { notifications: notificationsFromContext } = useNotificationsContext();
24 | const navigate = useNavigate();
25 |
26 | const setNotificationsOrRedirection = async () => {
27 | const result = await getNotificationList();
28 |
29 | result ? setNotifications(result) : navigate(SIGNIN);
30 | };
31 |
32 | const handleCheckNotificationBtnClick = async () => {
33 | await checkNotificationSeen();
34 | await setNotificationsOrRedirection();
35 | };
36 |
37 | useEffect(() => {
38 | notificationsFromContext && setNotifications(notificationsFromContext);
39 | }, [notificationsFromContext]);
40 |
41 | useEffect(() => {
42 | const getNotifications = async () => {
43 | await setNotificationsOrRedirection();
44 | };
45 |
46 | getNotifications();
47 | }, [tabValue]);
48 |
49 | return (
50 |
60 |
67 |
72 | {
74 | setTabValue(type);
75 | }}
76 | />
77 |
78 |
79 |
82 |
83 |
84 |
85 |
86 | );
87 | };
88 |
89 | export default Notification;
90 |
--------------------------------------------------------------------------------
/src/pages/SignIn.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Divider } from '@mui/material';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import SignInForm from '../components/SignIn/SignInForm';
5 | import SignInLinks from '../components/SignIn/SignInLinks';
6 | import { ROUTES } from '../constants/routes';
7 | import useCheckAuthToken from '../hooks/useCheckAuthToken';
8 | import useSignInForm from '../hooks/useSignInForm';
9 |
10 | const SignIn = () => {
11 | useCheckAuthToken();
12 | const navigate = useNavigate();
13 | const { values, errors, isLoading, handleChange, handleSubmit } =
14 | useSignInForm({
15 | email: '',
16 | password: '',
17 | });
18 |
19 | const handleClickButtonWithoutSignIn = () => {
20 | navigate(ROUTES.HOME);
21 | };
22 |
23 | const handleClickButtonToSignUp = () => {
24 | navigate(ROUTES.SIGNUP);
25 | };
26 |
27 | return (
28 |
29 |
37 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default SignIn;
48 |
--------------------------------------------------------------------------------
/src/pages/SignUp.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Container } from '@mui/material';
2 |
3 | import SignUpInput from '../components/SignUp//SignUpInput';
4 | import SignUpButton from '../components/SignUp/SignUpButton';
5 | import DatePicker from '../components/StoryEdit/DatePicker';
6 | import useSignUpForm from '../hooks/useSignUpForm';
7 |
8 | const SignUp = () => {
9 | const {
10 | values,
11 | isLoading,
12 | errors,
13 | date,
14 | handleSubmit,
15 | handleChange,
16 | handleDuplicate,
17 | handleDateChange,
18 | } = useSignUpForm();
19 |
20 | return (
21 |
22 |
79 |
80 | );
81 | };
82 |
83 | export default SignUp;
84 |
--------------------------------------------------------------------------------
/src/pages/Story.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Box, Divider } from '@mui/material';
3 | import { useEffect, useState } from 'react';
4 | import { useLocation } from 'react-router-dom';
5 |
6 | import StoryComment from '../components/Story/StoryComment';
7 | import StoryInfo from '../components/Story/StoryInfo';
8 | import { useFetchStory } from '../hooks/useStory';
9 |
10 | const Story = () => {
11 | const { story, fetchComment } = useFetchStory();
12 | const { state } = useLocation();
13 | const [detailStory, setDetailStory] = useState(state);
14 |
15 | useEffect(() => {
16 | if (story.createdAt !== '') {
17 | setDetailStory(story);
18 | }
19 | }, [story]);
20 |
21 | return (
22 |
23 |
24 |
25 |
30 |
31 | );
32 | };
33 |
34 | export default Story;
35 |
36 | const Container = styled(Box)`
37 | display: flex;
38 | flex-direction: column;
39 | padding: 30px;
40 | `;
41 |
--------------------------------------------------------------------------------
/src/pages/StoryBook.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { useState } from 'react';
3 |
4 | import FollowModal from '../components/Follow/FollowModal';
5 | import Empty from '../components/StoryBook/Empty';
6 | import Loading from '../components/StoryBook/Loading';
7 | import StoriesByYear from '../components/StoryBook/StoriesByYear';
8 | import StoryBookTitle from '../components/StoryBook/StoryBookTitle';
9 | import useFetchStories from '../hooks/useFetchStories';
10 |
11 | const StoryBook = () => {
12 | const { storiesByYear, currentUserInfo, isLoading } = useFetchStories();
13 | const [isModalOpen, setIsModalOpen] = useState(false);
14 | const handleClick = () => {
15 | setIsModalOpen(true);
16 | };
17 |
18 | const handleClose = () => {
19 | setIsModalOpen(false);
20 | };
21 |
22 | if (isLoading) return ;
23 |
24 | return (
25 |
26 |
27 | {currentUserInfo && (
28 |
32 | )}
33 | {storiesByYear.length !== 0 ? (
34 | storiesByYear.map(({ year, stories }) => (
35 |
36 | ))
37 | ) : (
38 | <>
39 | {!!currentUserInfo && (
40 | {currentUserInfo.fullName}님은 게으른가봐요. ㅋ
41 | )}
42 | >
43 | )}
44 |
45 | {currentUserInfo && (
46 |
58 | )}
59 |
60 | );
61 | };
62 |
63 | export default StoryBook;
64 |
65 | const Container = styled.main`
66 | padding: 0 1rem;
67 | `;
68 |
69 | const StoriesContainer = styled.div`
70 | display: flex;
71 | flex-direction: column;
72 | `;
73 |
--------------------------------------------------------------------------------
/src/pages/StoryEdit.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { useParams } from 'react-router-dom';
3 |
4 | import StoryEditForm from '../components/StoryEdit/StoryEditForm';
5 |
6 | const StoryEdit = () => {
7 | const { storyId } = useParams();
8 |
9 | return (
10 |
11 | 스토리 {storyId === 'new' ? '추가' : '수정'}
12 |
13 |
14 | );
15 | };
16 |
17 | export default StoryEdit;
18 |
19 | const Container = styled.div`
20 | display: flex;
21 | flex-direction: column;
22 | padding: 30px;
23 | `;
24 |
--------------------------------------------------------------------------------
/src/pages/follow.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import FavoriteIcon from '@mui/icons-material/Favorite';
3 | import PeopleIcon from '@mui/icons-material/People';
4 | import {
5 | Container,
6 | createTheme,
7 | Tab,
8 | Tabs,
9 | ThemeProvider,
10 | } from '@mui/material';
11 | import { SyntheticEvent, useLayoutEffect, useState } from 'react';
12 |
13 | import FollowEmpty from '../components/Follow/FollowEmpty';
14 | import FollowerButton from '../components/Follow/FollowerButton';
15 | import FollowingButton from '../components/Follow/FollowingButton';
16 | import FollowingList from '../components/Follow/FollowingList';
17 | import Loading from '../components/StoryBook/Loading';
18 | import { COLORS } from '../constants/colors';
19 | import useDisplayModeContext from '../contexts/DisplayModeContext';
20 | import useGetFollow from '../hooks/useGetFollow';
21 | import useGetFollower from '../hooks/useGetFollower';
22 |
23 | interface LinkTabProps {
24 | value: FOLLOW;
25 | label?: string;
26 | onClick?: () => void;
27 | icon: JSX.Element;
28 | }
29 |
30 | type FOLLOW = 'FOLLOWING' | 'FOLLOWER';
31 |
32 | const Follow = () => {
33 | const [value, setValue] = useState('FOLLOWING');
34 | const { followerList, loading, f4f, getUserInfo, handleClick } =
35 | useGetFollower();
36 | const { displayMode } = useDisplayModeContext();
37 |
38 | const {
39 | followingList,
40 | loading: ingLoading,
41 | followLoading,
42 | getUserInfo: ingGetUserInfo,
43 | handleClick: ingHandleClick,
44 | } = useGetFollow();
45 |
46 | useLayoutEffect(() => {
47 | if (value === 'FOLLOWING') ingGetUserInfo();
48 | else getUserInfo();
49 | }, [value]);
50 |
51 | const handleChange = (e: SyntheticEvent, newValue: FOLLOW) => {
52 | setValue(newValue);
53 | };
54 |
55 | return (
56 |
57 |
58 |
63 | }
67 | />
68 | } />
69 |
70 |
71 | {value === 'FOLLOWER' &&
72 | (loading ? (
73 |
74 | ) : followerList.length > 0 ? (
75 | followerList.map(
76 | ({
77 | _id,
78 | followingId,
79 | image,
80 | fullName,
81 | isOnline,
82 | follower,
83 | coverImage,
84 | username,
85 | }) => (
86 |
89 |
99 |
105 |
106 | )
107 | )
108 | ) : (
109 |
110 | ))}
111 |
112 | {value === 'FOLLOWING' &&
113 | (ingLoading ? (
114 |
115 | ) : followingList.length > 0 ? (
116 | followingList.map(
117 | ({
118 | _id,
119 | image,
120 | fullName,
121 | isOnline,
122 | user,
123 | coverImage,
124 | username,
125 | }) => (
126 |
129 |
139 |
145 |
146 | )
147 | )
148 | ) : (
149 |
150 | ))}
151 |
152 | );
153 | };
154 |
155 | export default Follow;
156 |
157 | const LinkTab = (props: LinkTabProps) => {
158 | const { displayMode } = useDisplayModeContext();
159 |
160 | return (
161 | ) => {
165 | event.preventDefault();
166 | }}
167 | {...props}
168 | />
169 | );
170 | };
171 |
172 | const Wrapper = styled.div<{ display: string }>`
173 | display: flex;
174 | width: 100%;
175 | margin: 0 auto;
176 | justify-content: space-between;
177 | align-items: center;
178 | border-radius: 1rem;
179 | margin-bottom: 0.5rem;
180 | box-sizing: border-box;
181 | background-color: ${(props) =>
182 | props.display === 'dark' ? COLORS.DARK_MODE_HEADER : 'white'};
183 | `;
184 |
185 | const tabsColor = createTheme({
186 | palette: {
187 | primary: {
188 | main: COLORS.SUB,
189 | },
190 | },
191 | typography: {
192 | fontFamily: "'MaplestoryOTFLight', cursive",
193 | },
194 | });
195 |
--------------------------------------------------------------------------------
/src/styles/GlobalStyle.tsx:
--------------------------------------------------------------------------------
1 | import { css, Global } from '@emotion/react';
2 |
3 | import { COLORS } from '../constants/colors';
4 |
5 | const GlobalStyle = () => {
6 | return ;
7 | };
8 |
9 | export default GlobalStyle;
10 |
11 | const style = css`
12 | :root[color-theme='light'] {
13 | --backgroundColor: ${COLORS.MAIN};
14 | --color: black;
15 | }
16 |
17 | :root[color-theme='dark'] {
18 | --backgroundColor: black;
19 | --color: white;
20 | }
21 |
22 | @font-face {
23 | font-family: 'MaplestoryOTFLight';
24 | src: url('/MaplestoryOTFLight.woff2') format('woff');
25 | font-weight: normal;
26 | font-style: normal;
27 | font-display: swap;
28 | }
29 |
30 | body {
31 | font-family: 'MaplestoryOTFLight', cursive;
32 | background-color: var(--backgroundColor);
33 | color: var(--color);
34 | margin: 0;
35 | overflow-y: scroll;
36 | transition: background-color 0.2s ease-out, color 0.2s ease-out;
37 |
38 | @media all and (min-width: 768px) {
39 | width: 412px;
40 | margin: 0 auto;
41 | }
42 | }
43 |
44 | main {
45 | display: block;
46 | }
47 | h1 {
48 | font-size: 2em;
49 | margin: 0.67em 0;
50 | }
51 | hr {
52 | box-sizing: content-box;
53 | height: 0;
54 | overflow: visible;
55 | }
56 | pre {
57 | font-family: monospace, monospace;
58 | font-size: 1em;
59 | }
60 | a {
61 | background-color: transparent;
62 | }
63 | abbr[title] {
64 | border-bottom: none;
65 | text-decoration: underline;
66 | text-decoration: underline dotted;
67 | }
68 | b,
69 | strong {
70 | font-weight: bolder;
71 | }
72 | code,
73 | kbd,
74 | samp {
75 | font-family: monospace, monospace;
76 | font-size: 1em;
77 | }
78 | small {
79 | font-size: 80%;
80 | }
81 | sub,
82 | sup {
83 | font-size: 75%;
84 | line-height: 0;
85 | position: relative;
86 | vertical-align: baseline;
87 | }
88 | sub {
89 | bottom: -0.25em;
90 | }
91 | sup {
92 | top: -0.5em;
93 | }
94 | img {
95 | border-style: none;
96 | }
97 | button,
98 | input,
99 | optgroup,
100 | select,
101 | textarea {
102 | font-family: inherit;
103 | font-size: 100%;
104 | line-height: 1.15;
105 | margin: 0;
106 | }
107 | button,
108 | input {
109 | overflow: visible;
110 | }
111 | button,
112 | select {
113 | text-transform: none;
114 | }
115 | button,
116 | [type='button'],
117 | [type='reset'],
118 | [type='submit'] {
119 | appearance: button;
120 | -webkit-appearance: button;
121 | }
122 | button::-moz-focus-inner,
123 | [type='button']::-moz-focus-inner,
124 | [type='reset']::-moz-focus-inner,
125 | [type='submit']::-moz-focus-inner {
126 | border-style: none;
127 | padding: 0;
128 | }
129 | button:-moz-focusring,
130 | [type='button']:-moz-focusring,
131 | [type='reset']:-moz-focusring,
132 | [type='submit']:-moz-focusring {
133 | outline: 1px dotted ButtonText;
134 | }
135 | fieldset {
136 | padding: 0.35em 0.75em 0.625em;
137 | }
138 | legend {
139 | box-sizing: border-box;
140 | color: inherit;
141 | display: table;
142 | max-width: 100%;
143 | padding: 0;
144 | white-space: normal;
145 | }
146 | progress {
147 | vertical-align: baseline;
148 | }
149 | textarea {
150 | overflow: auto;
151 | }
152 | [type='checkbox'],
153 | [type='radio'] {
154 | box-sizing: border-box;
155 | padding: 0;
156 | }
157 | [type='number']::-webkit-inner-spin-button,
158 | [type='number']::-webkit-outer-spin-button {
159 | height: auto;
160 | }
161 | [type='search'] {
162 | appearance: textfield;
163 | -webkit-appearance: textfield;
164 | outline-offset: -2px;
165 | }
166 | [type='search']::-webkit-search-decoration {
167 | -webkit-appearance: none;
168 | }
169 | ::-webkit-file-upload-button {
170 | -webkit-appearance: button;
171 | font: inherit;
172 | }
173 | details {
174 | display: block;
175 | }
176 | summary {
177 | display: list-item;
178 | }
179 | template {
180 | display: none;
181 | }
182 | [hidden] {
183 | display: none;
184 | }
185 | `;
186 |
--------------------------------------------------------------------------------
/src/utils/calcCreatedToCurrentTime.ts:
--------------------------------------------------------------------------------
1 | const EXPIRED_LIMIT_DATE = 5;
2 |
3 | export const calcCreatedToCurrentDate = (createdAt: string) => {
4 | if (createdAt === '') return '';
5 |
6 | const curTime = new Date();
7 | const createdTime = new Date(createdAt);
8 |
9 | const elapsedTime = curTime.getTime() - createdTime.getTime();
10 |
11 | const eDay = Math.floor(elapsedTime / (1000 * 60 * 60 * 24));
12 | const eHour = Math.floor(elapsedTime / (1000 * 60 * 60));
13 | const eMinutes = Math.floor(elapsedTime / (1000 * 60));
14 |
15 | if (elapsedTime < 0 || eMinutes === 0) return '방금 전';
16 |
17 | if (eDay === 0) {
18 | if (eHour === 0) return `${eMinutes}분 전`;
19 | return `${eHour}시간 전`;
20 | }
21 | return `${eDay}일 전`;
22 | };
23 |
24 | export const isExpiredDate = (createdAt: string) => {
25 | if (createdAt === '') return true;
26 |
27 | const curTime = new Date();
28 | const createdTime = new Date(createdAt);
29 |
30 | const elapsedTime = curTime.getTime() - createdTime.getTime();
31 |
32 | const eDay = Math.floor(elapsedTime / (1000 * 60 * 60 * 24));
33 |
34 | if (eDay > EXPIRED_LIMIT_DATE) return true;
35 | return false;
36 | };
37 |
--------------------------------------------------------------------------------
/src/utils/getChangedIndex.ts:
--------------------------------------------------------------------------------
1 | import { List } from '../interfaces/followList';
2 |
3 | export const getChangedIndex = (list: List[], followId?: string) => {
4 | return list.findIndex((item) => item._id === followId);
5 | };
6 |
--------------------------------------------------------------------------------
/src/utils/getF4FId.ts:
--------------------------------------------------------------------------------
1 | export const getF4FId = (f4f: string[][]) => {
2 | const followerId = f4f[0];
3 | const myFollowingId = f4f[1];
4 | return followerId.filter((item: string) => myFollowingId.includes(item));
5 | };
6 |
--------------------------------------------------------------------------------
/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import { Dayjs } from 'dayjs';
2 |
3 | export const changeColorTheme = (nextDisplayMode: 'dark' | 'light') => {
4 | nextDisplayMode === 'dark'
5 | ? document.documentElement.setAttribute('color-theme', 'dark')
6 | : document.documentElement.setAttribute('color-theme', 'light');
7 | };
8 |
9 | export const getDateInfo = (date: Dayjs) => ({
10 | year: date.get('year'),
11 | month: date.get('month') + 1,
12 | day: date.get('date'),
13 | });
14 |
--------------------------------------------------------------------------------
/src/utils/setUserListImageFirst.ts:
--------------------------------------------------------------------------------
1 | import { User } from '../interfaces/user';
2 |
3 | export const setUserListImageFirst = (users: User[]) => {
4 | const imageUsers = users.filter((user: User) => user.image);
5 | const notHaveImageUsers = users.filter((user: User) => !user.image);
6 |
7 | const reArrangedUsersList = [...imageUsers, ...notHaveImageUsers];
8 |
9 | return reArrangedUsersList;
10 | };
11 |
--------------------------------------------------------------------------------
/src/utils/signUpIsValid.ts:
--------------------------------------------------------------------------------
1 | interface args {
2 | fullName: string;
3 | email: string;
4 | password: string;
5 | passwordConfirm: string;
6 | date: string;
7 | job: string;
8 | }
9 |
10 | export const signUpIsValid = (newError: args) => {
11 | const ErrorNum = 6;
12 | if (
13 | Object.values(newError).filter((item) => item === '').length === ErrorNum
14 | ) {
15 | return true;
16 | }
17 | return false;
18 | };
19 |
--------------------------------------------------------------------------------
/src/utils/signUpValidate.ts:
--------------------------------------------------------------------------------
1 | import { args } from '../interfaces/signUp';
2 |
3 | export const signUpValidate = (values: args) => {
4 | const emailRegex =
5 | /([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4})(\]?)$/;
6 | const fullNameRegex = /^[A-Za-z0-9가-힣]{2,8}$/;
7 | const passwordRegex = /^.{6,15}$/;
8 | const jobRegex = /^[가-힣]{2,6}$/;
9 |
10 | const newError = {
11 | fullName: '',
12 | email: '',
13 | password: '',
14 | passwordConfirm: '',
15 | date: '',
16 | job: '',
17 | };
18 |
19 | if (!values.fullName) newError.fullName = '닉네임을 입력해 주세요.';
20 | else if (!fullNameRegex.test(values.fullName))
21 | newError.fullName = '영어, 숫자, 한글만 입력가능합니다.';
22 | else newError.fullName = '';
23 |
24 | if (!values.email) newError.email = '이메일을 입력해 주세요.';
25 | else if (!emailRegex.test(values.email))
26 | newError.email = '올바른 이메일 형식이 아닙니다.';
27 | else newError.email = '';
28 |
29 | if (!values.password) newError.password = '비밀번호를 입력해 주세요.';
30 | else if (!passwordRegex.test(values.password))
31 | newError.password = '6자리 이상, 15자리 이하로 입력해주세요.';
32 | else newError.password = '';
33 |
34 | if (!values.passwordConfirm)
35 | newError.passwordConfirm = '비밀번호를 확인해 주세요.';
36 | else if (values.password !== values.passwordConfirm)
37 | newError.passwordConfirm = '비밀번호가 일치하지 않습니다.';
38 | else newError.passwordConfirm = '';
39 |
40 | if (!values.date) newError.date = '생년월일을 선택해 주세요.';
41 | else newError.date = '';
42 |
43 | if (!values.job) newError.job = '직업을 입력해 주세요.';
44 | else if (!jobRegex.test(values.job)) newError.job = '한글만 입력가능합니다.';
45 | else newError.job = '';
46 |
47 | return newError;
48 | };
49 |
--------------------------------------------------------------------------------
/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | const storage = localStorage;
2 |
3 | export const getLocalStorage = (key: string, defaultValue = '') => {
4 | try {
5 | const storedValue = JSON.parse(storage.getItem(key) || '""');
6 |
7 | return storedValue ? storedValue : defaultValue;
8 | } catch (error) {
9 | console.error(error);
10 | return defaultValue;
11 | }
12 | };
13 |
14 | export const setLocalStorage = (key: string, value: T) => {
15 | try {
16 | storage.setItem(key, JSON.stringify(value));
17 | } catch (error) {
18 | console.error(error);
19 | }
20 | };
21 |
22 | export const removeLocalStorage = (key: string) => {
23 | try {
24 | storage.removeItem(key);
25 | } catch (error) {
26 | console.error(error);
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/utils/validationSearchForm.ts:
--------------------------------------------------------------------------------
1 | const ERROR_MESSAGE_NO_BLANK = '공백 문자는 허용되지 않습니다.';
2 | const ERROR_MESSAGE_ONLY_KO_EN_NUM =
3 | '영어, 숫자, 한글만 입력가능합니다.(최대 8자리)';
4 |
5 | export const validateSearchInput = (keyword: string) => {
6 | const error = { keyword: '' };
7 | const fullNameRegex = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]{1,8}$/;
8 |
9 | if (keyword.match(/[\s]/g)) error.keyword = ERROR_MESSAGE_NO_BLANK;
10 | else if (!fullNameRegex.test(keyword))
11 | error.keyword = ERROR_MESSAGE_ONLY_KO_EN_NUM;
12 |
13 | return error;
14 | };
15 |
--------------------------------------------------------------------------------
/src/utils/validations.ts:
--------------------------------------------------------------------------------
1 | export const validateSignInInput = ({
2 | email,
3 | password,
4 | }: {
5 | email: string;
6 | password: string;
7 | }) => {
8 | const errors = { email: '', password: '' };
9 | const emailRegExp =
10 | /([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$/;
11 | let isPassed = false;
12 |
13 | if (!email) errors.email = '이메일을 입력해주세요.';
14 | if (!password) errors.password = '비밀번호를 입력해주세요.';
15 | if (email && !emailRegExp.test(email))
16 | errors.email = '올바른 이메일을 입력해주세요.';
17 | if (errors.email === '' && errors.password === '') isPassed = true;
18 |
19 | return {
20 | isPassed,
21 | errors,
22 | };
23 | };
24 |
25 | export const isBlankString = (string: string) => {
26 | return string.trim().length === 0;
27 | };
28 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
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": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------