├── .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 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/icons/hamburger_menu.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/icons/user_profile.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 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 | 52 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | {userInfo.fullName} 73 | 78 | } 80 | color='info' 81 | label={job} 82 | variant='outlined' 83 | /> 84 | } 86 | color='secondary' 87 | label={year} 88 | variant='outlined' 89 | /> 90 | {userInfo.isOnline ? ( 91 | } 93 | color='success' 94 | label='온라인' 95 | variant='outlined' 96 | /> 97 | ) : ( 98 | 99 | )} 100 | 101 | 102 | 107 | 118 | 126 | 127 | 128 | 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 |
94 | 95 | 100 | {error} 101 | 102 | 103 | 108 | 120 | 128 | 129 | 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 |
93 | 94 | 106 | 119 | 131 | 132 | 137 | 149 | 157 | 158 |
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 | 84 | 85 | 86 | 89 | {modal[type].title} 변경 90 | 91 | 92 |
{modal[type].form}
93 |
94 |
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 |
143 | 144 | 155 | 156 | 161 | 173 | 181 | 182 |
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 |
26 | 35 | 40 | 41 | 42 |
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 |
handleSubmit(e, state?.imagePublicId || '')}> 30 |
31 | 32 | 33 | 34 |
35 |
36 | 37 | 48 | 49 |
50 |
51 | 52 | 57 | 58 |
59 |
60 | 61 | 74 | 75 |
76 | 77 | 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 |
23 | 35 | 45 | 55 | 64 | 65 | 66 | 76 | 77 | 78 | 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 | --------------------------------------------------------------------------------