├── public ├── robots.txt ├── favicon.ico ├── og-image.png ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── manifest.json └── index.html ├── .husky └── pre-commit ├── src ├── assets │ ├── images │ │ ├── icon │ │ │ ├── 404.png │ │ │ ├── add.png │ │ │ ├── new.png │ │ │ ├── back.png │ │ │ ├── close.png │ │ │ ├── delete.png │ │ │ ├── edit.png │ │ │ ├── ground.png │ │ │ ├── liked.png │ │ │ ├── lock.png │ │ │ ├── search.png │ │ │ ├── share.png │ │ │ ├── user.png │ │ │ ├── comment.png │ │ │ ├── my-like.png │ │ │ ├── profile.png │ │ │ ├── setting.png │ │ │ ├── unliked.png │ │ │ ├── user-light.png │ │ │ ├── default-profile.png │ │ │ ├── notification-off.png │ │ │ └── notification-on.png │ │ └── logo │ │ │ ├── big.png │ │ │ ├── long.png │ │ │ └── small.png │ ├── fonts │ │ ├── Inter │ │ │ ├── Inter-Bold-subset.woff │ │ │ ├── Inter-Bold-subset.woff2 │ │ │ ├── Inter-Medium-subset.woff │ │ │ ├── Inter-Medium-subset.woff2 │ │ │ ├── Inter-Regular-subset.woff │ │ │ └── Inter-Regular-subset.woff2 │ │ └── NotoSans │ │ │ ├── NotoSansKR-Bold-subset.woff │ │ │ ├── NotoSansKR-Bold-subset.woff2 │ │ │ ├── NotoSansKR-Medium-subset.woff │ │ │ ├── NotoSansKR-Medium-subset.woff2 │ │ │ ├── NotoSansKR-Regular-subset.woff │ │ │ └── NotoSansKR-Regular-subset.woff2 │ └── styles │ │ └── font.css ├── types │ ├── api │ │ ├── default │ │ │ └── index.ts │ │ ├── like.ts │ │ ├── follow.ts │ │ ├── channel.ts │ │ ├── comment.ts │ │ ├── notification.ts │ │ ├── post.ts │ │ └── user.ts │ └── recoil │ │ └── user.ts ├── utils │ ├── formatDate.ts │ ├── api │ │ ├── channel.ts │ │ ├── like.ts │ │ ├── comment.ts │ │ ├── follow.ts │ │ ├── notification.ts │ │ ├── user.ts │ │ └── post.ts │ ├── axios.ts │ ├── constants.ts │ ├── colors.ts │ ├── image.ts │ ├── routes.ts │ ├── messages.ts │ └── formRules.ts ├── recoil │ └── atoms │ │ ├── loading.ts │ │ ├── toast.ts │ │ ├── modal.ts │ │ ├── search.ts │ │ └── user.ts ├── components │ ├── UserForm │ │ ├── UserForm.tsx │ │ ├── ErrorMessage.tsx │ │ ├── FormButton.tsx │ │ ├── FormProfileImage.tsx │ │ └── FormInput.tsx │ ├── Layout │ │ └── SearchbarLayout.tsx │ ├── Base │ │ ├── ToastList.tsx │ │ ├── GlobalSpinner.tsx │ │ ├── Divider.tsx │ │ ├── Icon.tsx │ │ ├── ToastItem.tsx │ │ ├── Spinner.tsx │ │ ├── Image.tsx │ │ └── Modal.tsx │ ├── Button │ │ └── LinkButton.tsx │ ├── Post │ │ ├── PostList.tsx │ │ ├── EditButton.tsx │ │ ├── PostContent.tsx │ │ ├── PostEdit.tsx │ │ └── Post.tsx │ ├── Profile │ │ ├── TabItem.tsx │ │ └── ProfileEditForm.tsx │ ├── Notification │ │ ├── Notification.tsx │ │ └── NotificationList.tsx │ ├── Header │ │ ├── DetailHeader.tsx │ │ ├── LinkButtons.tsx │ │ ├── Header.tsx │ │ └── Searchbar.tsx │ ├── User │ │ └── UserItem.tsx │ ├── Follow │ │ ├── FollowList.tsx │ │ └── FollowButton.tsx │ ├── SIgnUp │ │ └── SignUpForm.tsx │ ├── Login │ │ └── LoginForm.tsx │ └── Comment │ │ ├── CommentInput.tsx │ │ └── Comment.tsx ├── index.tsx ├── hooks │ ├── useModal.ts │ ├── useLogout.ts │ ├── useGetMyInfo.ts │ ├── useIntersectionObserver.ts │ ├── useAxiosInterceptor.ts │ ├── useToast.ts │ ├── useNotification.ts │ └── usePost.ts ├── App.tsx ├── pages │ ├── NotificationsPage.tsx │ ├── index.ts │ ├── ProfileEditPage.tsx │ ├── PostEditPage.tsx │ ├── MyLikesPage.tsx │ ├── NotFoundPage.tsx │ ├── HomePage.tsx │ ├── PostPage.tsx │ ├── SignUpPage.tsx │ ├── SearchPage.tsx │ ├── LoginPage.tsx │ └── ProfilePage.tsx ├── Router.tsx └── GlobalStyle.tsx ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ └── 유리팀-이슈-템플릿.md └── workflows │ ├── build.yaml │ ├── preview.yaml │ └── production.yaml ├── .prettierrc ├── .gitignore ├── .eslintrc.json ├── tsconfig.json ├── package.json └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/og-image.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/assets/images/icon/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/404.png -------------------------------------------------------------------------------- /src/assets/images/icon/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/add.png -------------------------------------------------------------------------------- /src/assets/images/icon/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/new.png -------------------------------------------------------------------------------- /src/assets/images/logo/big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/logo/big.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/assets/images/icon/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/back.png -------------------------------------------------------------------------------- /src/assets/images/icon/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/close.png -------------------------------------------------------------------------------- /src/assets/images/icon/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/delete.png -------------------------------------------------------------------------------- /src/assets/images/icon/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/edit.png -------------------------------------------------------------------------------- /src/assets/images/icon/ground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/ground.png -------------------------------------------------------------------------------- /src/assets/images/icon/liked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/liked.png -------------------------------------------------------------------------------- /src/assets/images/icon/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/lock.png -------------------------------------------------------------------------------- /src/assets/images/icon/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/search.png -------------------------------------------------------------------------------- /src/assets/images/icon/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/share.png -------------------------------------------------------------------------------- /src/assets/images/icon/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/user.png -------------------------------------------------------------------------------- /src/assets/images/logo/long.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/logo/long.png -------------------------------------------------------------------------------- /src/assets/images/logo/small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/logo/small.png -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📌 이슈 번호 2 | 3 | 4 | 5 | ## 👩‍💻 작업 내용 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/images/icon/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/comment.png -------------------------------------------------------------------------------- /src/assets/images/icon/my-like.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/my-like.png -------------------------------------------------------------------------------- /src/assets/images/icon/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/profile.png -------------------------------------------------------------------------------- /src/assets/images/icon/setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/setting.png -------------------------------------------------------------------------------- /src/assets/images/icon/unliked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/unliked.png -------------------------------------------------------------------------------- /src/assets/images/icon/user-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/user-light.png -------------------------------------------------------------------------------- /src/types/api/default/index.ts: -------------------------------------------------------------------------------- 1 | export default interface DefaultResponse { 2 | _id: string; 3 | createdAt: string; 4 | updatedAt?: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/images/icon/default-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/default-profile.png -------------------------------------------------------------------------------- /src/assets/images/icon/notification-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/notification-off.png -------------------------------------------------------------------------------- /src/assets/images/icon/notification-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/images/icon/notification-on.png -------------------------------------------------------------------------------- /src/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | export const formatDate = { 2 | year: (date: string) => date.slice(0, 4), 3 | fullDate: (date: string) => date.slice(0, 10), 4 | }; 5 | -------------------------------------------------------------------------------- /src/assets/fonts/Inter/Inter-Bold-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/Inter/Inter-Bold-subset.woff -------------------------------------------------------------------------------- /src/assets/fonts/Inter/Inter-Bold-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/Inter/Inter-Bold-subset.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Inter/Inter-Medium-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/Inter/Inter-Medium-subset.woff -------------------------------------------------------------------------------- /src/assets/fonts/Inter/Inter-Medium-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/Inter/Inter-Medium-subset.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Inter/Inter-Regular-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/Inter/Inter-Regular-subset.woff -------------------------------------------------------------------------------- /src/assets/fonts/Inter/Inter-Regular-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/Inter/Inter-Regular-subset.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/NotoSans/NotoSansKR-Bold-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/NotoSans/NotoSansKR-Bold-subset.woff -------------------------------------------------------------------------------- /src/assets/fonts/NotoSans/NotoSansKR-Bold-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/NotoSans/NotoSansKR-Bold-subset.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/NotoSans/NotoSansKR-Medium-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/NotoSans/NotoSansKR-Medium-subset.woff -------------------------------------------------------------------------------- /src/assets/fonts/NotoSans/NotoSansKR-Medium-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/NotoSans/NotoSansKR-Medium-subset.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/NotoSans/NotoSansKR-Regular-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/NotoSans/NotoSansKR-Regular-subset.woff -------------------------------------------------------------------------------- /src/assets/fonts/NotoSans/NotoSansKR-Regular-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-fe-devcourse/FEDC3_DigDigDeep_Yuri/HEAD/src/assets/fonts/NotoSans/NotoSansKR-Regular-subset.woff2 -------------------------------------------------------------------------------- /src/types/api/like.ts: -------------------------------------------------------------------------------- 1 | import type ApiDefaultResponse from './default'; 2 | 3 | export interface LikeResponse extends ApiDefaultResponse { 4 | user: string; // 사용자 id 5 | post: string; // 포스트 id 6 | } 7 | -------------------------------------------------------------------------------- /src/types/api/follow.ts: -------------------------------------------------------------------------------- 1 | import type DefaultResponse from './default'; 2 | 3 | export interface FollowResponse extends DefaultResponse { 4 | user: string; // 사용자 id 5 | follower: string; // 사용자 id 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/유리팀-이슈-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 유리팀 이슈 템플릿 3 | about: 공용 이슈 템플릿 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📃 작업 내용 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/types/api/channel.ts: -------------------------------------------------------------------------------- 1 | import type DefaultResponse from './default'; 2 | 3 | export interface ChannelResponse extends DefaultResponse { 4 | posts: string[]; 5 | name: string; 6 | description: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/recoil/atoms/loading.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type LoadingProps = boolean; 4 | 5 | export const loadingState = atom({ 6 | key: 'loadingState', 7 | default: false, 8 | }); 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "jsxSingleQuote": false, 7 | "useTabs": false, 8 | "arrowParens": "always", 9 | "printWidth": 80, 10 | "quoteProps": "as-needed" 11 | } 12 | -------------------------------------------------------------------------------- /src/types/api/comment.ts: -------------------------------------------------------------------------------- 1 | import type DefaultResponse from './default'; 2 | import type { UserResponse } from './user'; 3 | 4 | export interface CommentResponse extends DefaultResponse { 5 | comment: string; 6 | author: UserResponse; 7 | post: string; // 포스트 id 8 | } 9 | -------------------------------------------------------------------------------- /src/components/UserForm/UserForm.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const UserForm = styled.form` 4 | width: 100%; 5 | box-sizing: border-box; 6 | display: flex; 7 | flex-direction: column; 8 | gap: 1.6rem; 9 | `; 10 | 11 | export default UserForm; 12 | -------------------------------------------------------------------------------- /src/recoil/atoms/toast.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type ToastProps = { 4 | id?: string; 5 | message: string; 6 | duration?: number; 7 | }; 8 | 9 | export const toastsState = atom({ 10 | key: 'toastsState', 11 | default: [], 12 | }); 13 | -------------------------------------------------------------------------------- /src/types/recoil/user.ts: -------------------------------------------------------------------------------- 1 | import type { UserResponse } from '../api/user'; 2 | 3 | export type RecoilUser = Pick< 4 | UserResponse, 5 | | '_id' 6 | | 'image' 7 | | 'fullName' 8 | | 'likes' 9 | | 'comments' 10 | | 'email' 11 | | 'following' 12 | | 'notifications' 13 | >; 14 | -------------------------------------------------------------------------------- /src/utils/api/channel.ts: -------------------------------------------------------------------------------- 1 | import axiosInstance from '../axios'; 2 | import { CHANNEL_NAME } from '../constants'; 3 | 4 | export const getChannelInfo = async () => { 5 | const encode = encodeURI(CHANNEL_NAME); 6 | const { data } = await axiosInstance.get(`/channels/${encode}`); 7 | return data; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/Layout/SearchbarLayout.tsx: -------------------------------------------------------------------------------- 1 | import Header from '../Header/Header'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | const SearchbarLayout = () => { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | }; 12 | 13 | export default SearchbarLayout; 14 | -------------------------------------------------------------------------------- /src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_BASE_URL = 4 | process.env.NODE_ENV === 'development' 5 | ? process.env.REACT_APP_API_BASE_URL 6 | : '/api'; 7 | 8 | const axiosInstance = axios.create({ 9 | baseURL: API_BASE_URL, 10 | }); 11 | 12 | export default axiosInstance; 13 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const defaultUserValue = { 2 | _id: '', 3 | likes: [], 4 | comments: [], 5 | image: '', 6 | fullName: '', 7 | email: '', 8 | following: [], 9 | notifications: [], 10 | }; 11 | 12 | export const CHANNEL_NAME = '기본 채널'; 13 | 14 | export const LIMITED_FILE_SIZE = 10485760; 15 | -------------------------------------------------------------------------------- /src/recoil/atoms/modal.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type ModalProps = { 4 | message: string; 5 | handleClose?: (...arg: unknown[]) => unknown; 6 | handleConfirm?: (...arg: unknown[]) => unknown; 7 | }; 8 | 9 | export const modalState = atom({ 10 | key: 'modalState', 11 | default: null, 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/api/like.ts: -------------------------------------------------------------------------------- 1 | import axiosInstance from '../axios'; 2 | 3 | export const deleteLike = async (likeId: string) => 4 | await axiosInstance.delete(`/likes/delete`, { 5 | data: { id: likeId }, 6 | }); 7 | 8 | export const createLike = async (postId: string) => 9 | await axiosInstance.post(`/likes/create`, { 10 | postId: postId, 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | const COLORS = { 2 | white: '#FFFFFF', 3 | orange: '#E5540A', 4 | brown: '#7C5B4A', 5 | date: '#B7B7B7', 6 | text: '#715141', 7 | lightBrown: '#927160', 8 | brownGray: '#BFB0A8', 9 | green: '#95C746', 10 | lightGray: '#DBDBDB', 11 | gray: '#EDEDED', 12 | black: '#000000', 13 | bgColor: '#F8F8F8', 14 | } as const; 15 | 16 | export default COLORS; 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: dev 6 | push: 7 | branches: [dev, deploy/*] 8 | 9 | jobs: 10 | build: 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 | - run: npm ci 18 | - run: npm run build --if-present 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/components/UserForm/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import COLORS from '../../utils/colors'; 3 | 4 | const ErrorMessage = styled.span` 5 | display: block; 6 | font-weight: 400; 7 | font-size: 1.3rem; 8 | line-height: 1.9rem; 9 | letter-spacing: -0.01em; 10 | white-space: pre-wrap; 11 | min-height: 1.9rem; 12 | 13 | color: ${COLORS.orange}; 14 | `; 15 | 16 | export default ErrorMessage; 17 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import { RecoilRoot } from 'recoil'; 5 | import './assets/styles/font.css'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/utils/image.ts: -------------------------------------------------------------------------------- 1 | type QueryType = 'postDetail' | 'postList' | 'postAuthor' | 'profile'; 2 | 3 | const imageQuery = { 4 | postDetail: '/upload/q_auto:best/f_auto/', 5 | postList: '/upload/q_auto:low/f_auto/', 6 | postAuthor: '/upload/q_10/f_auto/', 7 | profile: '/upload/q_10/f_auto/', 8 | }; 9 | 10 | export const queryLowImage = (src: string, query: QueryType) => { 11 | return src.replace('/upload/', imageQuery[query]); 12 | }; 13 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DigDigDeep", 3 | "icons": [ 4 | { 5 | "src": "/android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image/png" 8 | }, 9 | { 10 | "src": "/android-chrome-512x512.png", 11 | "sizes": "512x512", 12 | "type": "image/png" 13 | } 14 | ], 15 | "theme_color": "#ffffff", 16 | "background_color": "#ffffff", 17 | "display": "standalone" 18 | } 19 | -------------------------------------------------------------------------------- /src/recoil/atoms/search.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export type SelectOption = { 4 | label: '그라운드' | '사용자'; 5 | value: 'posts' | 'users'; 6 | }; 7 | 8 | export type SearchProps = { 9 | value: string; 10 | options: SelectOption; 11 | }; 12 | 13 | export const searchState = atom({ 14 | key: 'searchState', 15 | default: { 16 | value: '', 17 | options: { 18 | label: '그라운드', 19 | value: 'posts', 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/types/api/notification.ts: -------------------------------------------------------------------------------- 1 | import type ApiDefaultResponse from './default'; 2 | import type { CommentResponse } from './comment'; 3 | import type { LikeResponse } from './like'; 4 | import type { UserResponse } from './user'; 5 | 6 | export interface NotificationResponse extends ApiDefaultResponse { 7 | seen: boolean; 8 | author: UserResponse; 9 | user: UserResponse | string; 10 | post: string | null; // 포스트 id 11 | like: LikeResponse; 12 | follow?: string; // 사용자 id 13 | comment?: CommentResponse; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from 'recoil'; 2 | import { modalState } from '../recoil/atoms/modal'; 3 | import type { ModalProps } from '../recoil/atoms/modal'; 4 | 5 | const useModal = () => { 6 | const [modal, setModal] = useRecoilState(modalState); 7 | 8 | const showModal = (modalProps: ModalProps) => { 9 | setModal(modalProps); 10 | }; 11 | 12 | const hideModal = () => { 13 | setModal(null); 14 | }; 15 | 16 | return { modal, showModal, hideModal }; 17 | }; 18 | 19 | export default useModal; 20 | -------------------------------------------------------------------------------- /src/types/api/post.ts: -------------------------------------------------------------------------------- 1 | import type DefaultResponse from './default'; 2 | import type { ChannelResponse } from './channel'; 3 | import type { CommentResponse } from './comment'; 4 | import type { LikeResponse } from './like'; 5 | import type { UserResponse } from './user'; 6 | 7 | export interface PostResponse extends DefaultResponse { 8 | likes: LikeResponse[]; 9 | comments: CommentResponse[]; 10 | image?: string; 11 | imagePublicId?: string; 12 | title: string; 13 | channel?: ChannelResponse; 14 | author: UserResponse; 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react/jsx-runtime" 11 | ], 12 | "overrides": [], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["react", "@typescript-eslint"], 19 | "rules": { 20 | "@typescript-eslint/no-var-requires": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import useAxiosInterceptor from './hooks/useAxiosInterceptor'; 2 | import Router from './Router'; 3 | import GlobalStyle from './GlobalStyle'; 4 | import Modal from './components/Base/Modal'; 5 | import ToastList from './components/Base/ToastList'; 6 | import GlobalSpinner from './components/Base/GlobalSpinner'; 7 | 8 | function App() { 9 | useAxiosInterceptor(); 10 | 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /src/pages/NotificationsPage.tsx: -------------------------------------------------------------------------------- 1 | import NotificationList from '../components/Notification/NotificationList'; 2 | import DetailHeader from '../components/Header/DetailHeader'; 3 | import styled from 'styled-components'; 4 | 5 | const NotificationsPage = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default NotificationsPage; 15 | 16 | const Container = styled.div` 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | `; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": 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 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { useSetRecoilState } from 'recoil'; 2 | import { tokenState, userState } from '../recoil/atoms/user'; 3 | import { logout as requestLogout } from '../utils/api/user'; 4 | import { defaultUserValue } from '../utils/constants'; 5 | 6 | const useLogout = () => { 7 | const setUser = useSetRecoilState(userState); 8 | const setToken = useSetRecoilState(tokenState); 9 | 10 | const logout = async () => { 11 | await requestLogout(); 12 | setUser(defaultUserValue); 13 | setToken(''); 14 | }; 15 | 16 | return logout; 17 | }; 18 | 19 | export default useLogout; 20 | -------------------------------------------------------------------------------- /src/utils/api/comment.ts: -------------------------------------------------------------------------------- 1 | import type { CommentResponse } from '../../types/api/comment'; 2 | import axiosInstance from '../axios'; 3 | 4 | interface CommentParam { 5 | (comment: string, postId: string): Promise; 6 | } 7 | 8 | export const createComment: CommentParam = async (comment, postId) => { 9 | const { data } = await axiosInstance.post(`/comments/create`, { 10 | comment, 11 | postId, 12 | }); 13 | 14 | return data; 15 | }; 16 | 17 | export const deleteComment = async (commentId: string) => { 18 | await axiosInstance.delete(`/comments/delete`, { data: { id: commentId } }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/api/follow.ts: -------------------------------------------------------------------------------- 1 | import type { FollowResponse } from '../../types/api/follow'; 2 | import axiosInstance from '../axios'; 3 | 4 | export const follow = async ({ userId }: { userId: string }) => { 5 | const { data } = await axiosInstance.post('/follow/create', { 6 | userId, 7 | }); 8 | return data; 9 | }; 10 | 11 | export const unfollow = async ({ followId }: { followId: string }) => { 12 | const { data } = await axiosInstance.delete( 13 | '/follow/delete', 14 | { 15 | data: { 16 | id: followId, 17 | }, 18 | } 19 | ); 20 | return data; 21 | }; 22 | -------------------------------------------------------------------------------- /src/hooks/useGetMyInfo.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from 'recoil'; 2 | import { userState } from '../recoil/atoms/user'; 3 | import { getUser } from '../utils/api/user'; 4 | import useLogout from './useLogout'; 5 | 6 | const useGetMyInfo = () => { 7 | const [user, setUser] = useRecoilState(userState); 8 | const logout = useLogout(); 9 | 10 | const getMyInfo = async () => { 11 | try { 12 | const responseUser = await getUser(user._id); 13 | setUser(responseUser); 14 | } catch (error) { 15 | console.error(error); 16 | logout(); 17 | } 18 | }; 19 | 20 | return getMyInfo; 21 | }; 22 | 23 | export default useGetMyInfo; 24 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HomePage } from './HomePage'; 2 | export { default as LoginPage } from './LoginPage'; 3 | export { default as SignUpPage } from './SignUpPage'; 4 | export { default as PostPage } from './PostPage'; 5 | export { default as ProfilePage } from './ProfilePage'; 6 | export { default as MyLikesPage } from './MyLikesPage'; 7 | export { default as SearchPage } from './SearchPage'; 8 | export { default as PostEditPage } from './PostEditPage'; 9 | export { default as NotFoundPage } from './NotFoundPage'; 10 | export { default as NotificationsPage } from './NotificationsPage'; 11 | export { default as ProfileEditPage } from './ProfileEditPage'; 12 | -------------------------------------------------------------------------------- /src/components/Base/ToastList.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil'; 2 | import styled from 'styled-components'; 3 | import { toastsState } from '../../recoil/atoms/toast'; 4 | import ToastItem from './ToastItem'; 5 | 6 | const ToastList = () => { 7 | const toasts = useRecoilValue(toastsState); 8 | 9 | return ( 10 | 11 | {toasts.map((toast) => ( 12 | 13 | ))} 14 | 15 | ); 16 | }; 17 | 18 | export default ToastList; 19 | 20 | const StyledToastList = styled.div` 21 | position: fixed; 22 | width: 100%; 23 | bottom: 10%; 24 | left: 0; 25 | z-index: 10; 26 | `; 27 | -------------------------------------------------------------------------------- /src/utils/api/notification.ts: -------------------------------------------------------------------------------- 1 | import axiosInstance from '../axios'; 2 | 3 | interface LikeParam { 4 | (type: string, dataId: string, userId: string, postId: string | null): void; 5 | } 6 | 7 | export const getNotifications = async () => { 8 | const { data } = await axiosInstance.get(`/notifications`); 9 | return data; 10 | }; 11 | 12 | export const seenNotifications = async () => { 13 | await axiosInstance.put(`/notifications/seen`); 14 | }; 15 | 16 | export const sendNotification: LikeParam = async ( 17 | type, 18 | dataId, 19 | userId, 20 | postId 21 | ) => 22 | await axiosInstance.post(`/notifications/create`, { 23 | notificationType: type, 24 | notificationTypeId: dataId, 25 | userId, 26 | postId, 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/routes.ts: -------------------------------------------------------------------------------- 1 | const ROUTES = { 2 | HOME: '/', 3 | LOGIN: '/login', 4 | SIGNUP: '/signup', 5 | SEARCH: '/search', 6 | POSTS_NEW: '/posts/new', 7 | POSTS_DETAIL: '/posts/:postId', 8 | POSTS_EDIT: '/posts/:postId/edit', 9 | PROFILE_DETAIL: '/profile/:userId', 10 | PROFILE_ME: '/profile/me', 11 | PROFILE_ME_LIKES: '/profile/me/likes', 12 | PROFILE_ME_EDIT: '/profile/me/edit', 13 | NOTIFICATION: '/notifications', 14 | NOT_FOUND: '*', 15 | POSTS_BY_ID: (postId: string) => `/posts/${postId}`, 16 | PROFILE_BY_USER_ID: (userId: string) => `/profile/${userId}`, 17 | POSTS_EDIT_BY_ID: (postId: string) => `/posts/${postId}/edit`, 18 | SEARCH_BY_QUERY: (search: string, select: string) => 19 | `/search?q=${search}&type=${select}`, 20 | } as const; 21 | 22 | export default ROUTES; 23 | -------------------------------------------------------------------------------- /src/pages/ProfileEditPage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import DetailHeader from '../components/Header/DetailHeader'; 3 | import ProfileEditForm from '../components/Profile/ProfileEditForm'; 4 | 5 | const ProfileEditPage = () => { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default ProfileEditPage; 17 | 18 | const LoginPageContainer = styled.div` 19 | display: flex; 20 | flex-direction: column; 21 | width: 35%; 22 | min-width: 35rem; 23 | height: 100vh; 24 | box-sizing: border-box; 25 | text-align: center; 26 | margin: 0 auto; 27 | @media screen and (max-width: 767px) and (orientation: portrait) { 28 | width: 90%; 29 | min-width: 0; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /src/types/api/user.ts: -------------------------------------------------------------------------------- 1 | import type DefaultResponse from './default'; 2 | import type { CommentResponse } from './comment'; 3 | import type { LikeResponse } from './like'; 4 | import type { PostResponse } from './post'; 5 | import type { NotificationResponse } from './notification'; 6 | import type { FollowResponse } from './follow'; 7 | 8 | export interface UserResponse extends DefaultResponse { 9 | coverImage: string; // 커버 이미지 10 | image: string; // 프로필 이미지 11 | role: string; 12 | isOnline: boolean; 13 | posts: PostResponse[]; 14 | likes: LikeResponse[]; 15 | comments: CommentResponse[]; 16 | followers: FollowResponse[]; 17 | following: FollowResponse[]; 18 | notifications: NotificationResponse[]; 19 | messages: []; 20 | fullName: string; 21 | email: string; 22 | } 23 | 24 | export interface LoginResponse { 25 | user: UserResponse; 26 | token: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/recoil/atoms/user.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | import { defaultUserValue } from '../../utils/constants'; 3 | import type { AtomEffect } from 'recoil'; 4 | import type { RecoilUser } from '../../types/recoil/user'; 5 | 6 | const localStorageEffect: (key: string) => AtomEffect = 7 | (key: string) => 8 | ({ setSelf, onSet }) => { 9 | const item = localStorage.getItem(key); 10 | if (item) { 11 | setSelf(JSON.parse(item)); 12 | } 13 | 14 | onSet((value) => { 15 | localStorage.setItem(key, JSON.stringify(value)); 16 | }); 17 | }; 18 | 19 | export const userState = atom({ 20 | key: 'userState', 21 | default: defaultUserValue, 22 | effects: [localStorageEffect('user')], 23 | }); 24 | 25 | export const tokenState = atom({ 26 | key: 'tokenState', 27 | default: '', 28 | effects: [localStorageEffect('token')], 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/Base/GlobalSpinner.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from './Spinner'; 2 | import { useRecoilValue } from 'recoil'; 3 | import { loadingState } from '../../recoil/atoms/loading'; 4 | import styled from 'styled-components'; 5 | 6 | const GlobalSpinner = () => { 7 | const loading = useRecoilValue(loadingState); 8 | 9 | return ( 10 | <> 11 | {loading ? ( 12 | 13 | 14 | 15 | 16 | 17 | ) : null} 18 | 19 | ); 20 | }; 21 | 22 | export default GlobalSpinner; 23 | 24 | const Background = styled.div` 25 | position: fixed; 26 | top: 0; 27 | left: 0; 28 | width: 100vw; 29 | height: 100vh; 30 | z-index: 1000; 31 | opacity: 1; 32 | `; 33 | 34 | const Wrapper = styled.div` 35 | position: relative; 36 | top: 50%; 37 | transform: translate(0, -50%); 38 | `; 39 | -------------------------------------------------------------------------------- /src/hooks/useIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | interface useIntersectionObserverProps { 4 | root?: null; 5 | rootMargin?: string; 6 | threshold?: number; 7 | onIntersect: IntersectionObserverCallback; 8 | } 9 | 10 | const useIntersectionObserver = ({ 11 | root, 12 | rootMargin = '0px', 13 | threshold = 0.5, 14 | onIntersect, 15 | }: useIntersectionObserverProps) => { 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 | observer.observe(target); 26 | 27 | return () => observer.unobserve(target); 28 | }, [onIntersect, root, rootMargin, target, threshold]); 29 | 30 | return { setTarget }; 31 | }; 32 | 33 | export default useIntersectionObserver; 34 | -------------------------------------------------------------------------------- /src/hooks/useAxiosInterceptor.ts: -------------------------------------------------------------------------------- 1 | import axiosInstance from '../utils/axios'; 2 | import { useEffect, useCallback } from 'react'; 3 | import { useRecoilValue } from 'recoil'; 4 | import { tokenState } from '../recoil/atoms/user'; 5 | import type { AxiosRequestConfig } from 'axios'; 6 | const useAxiosInterceptor = () => { 7 | const token = useRecoilValue(tokenState); 8 | 9 | const requestHandler = useCallback( 10 | (config: AxiosRequestConfig) => { 11 | if (config.headers) { 12 | config.headers['Authorization'] = ''; 13 | if (token) config.headers['Authorization'] = `bearer ${token}`; 14 | } 15 | return config; 16 | }, 17 | [token] 18 | ); 19 | 20 | useEffect(() => { 21 | const requestInterceptors = 22 | axiosInstance.interceptors.request.use(requestHandler); 23 | return () => { 24 | axiosInstance.interceptors.request.eject(requestInterceptors); 25 | }; 26 | }, [requestHandler, token]); 27 | }; 28 | 29 | export default useAxiosInterceptor; 30 | -------------------------------------------------------------------------------- /src/components/Base/Divider.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface DividerProps { 5 | type: string; 6 | size: number; 7 | style?: CSSProperties; 8 | } 9 | 10 | const Divider = ({ 11 | type = 'horizontal', 12 | size = 16, 13 | ...props 14 | }: DividerProps) => { 15 | const dividerStyle = { 16 | margin: type === 'vertical' ? `0 ${size}px` : `${size}px auto`, 17 | }; 18 | return ( 19 | 24 | ); 25 | }; 26 | 27 | export default Divider; 28 | 29 | const Line = styled.hr` 30 | border: none; 31 | background-color: #dadada; 32 | 33 | &.vertical { 34 | position: relative; 35 | top: -1; 36 | display: inline-block; 37 | width: 1px; 38 | height: 13px; 39 | vertical-align: middle; 40 | } 41 | 42 | &.horizontal { 43 | display: block; 44 | width: 100%; 45 | height: 1px; 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useRecoilState } from 'recoil'; 3 | import { v4 } from 'uuid'; 4 | import { toastsState } from '../recoil/atoms/toast'; 5 | import type { ToastProps } from '../recoil/atoms/toast'; 6 | 7 | const useToast = () => { 8 | const [toasts, setToasts] = useRecoilState(toastsState); 9 | 10 | const hideToast = useCallback( 11 | (toastId: string) => { 12 | setToasts((currentToasts) => 13 | currentToasts.filter((toast) => toast.id !== toastId) 14 | ); 15 | }, 16 | [setToasts] 17 | ); 18 | 19 | const showToast = useCallback( 20 | (toast: ToastProps) => { 21 | const newToastId = v4(); 22 | setToasts((currentToasts) => [ 23 | ...currentToasts, 24 | { ...toast, id: newToastId }, 25 | ]); 26 | setTimeout(() => hideToast(newToastId), 500 + (toast.duration ?? 1000)); 27 | }, 28 | [hideToast, setToasts] 29 | ); 30 | 31 | return { toasts, showToast }; 32 | }; 33 | 34 | export default useToast; 35 | -------------------------------------------------------------------------------- /.github/workflows/preview.yaml: -------------------------------------------------------------------------------- 1 | name: Vercel Preview Deployment 2 | 3 | on: 4 | pull_request: 5 | branches: dev 6 | push: 7 | branches: [dev, deploy/*] 8 | 9 | jobs: 10 | preview: 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 | } 29 | ' >> vercel.json 30 | - uses: amondnet/vercel-action@v20 31 | with: 32 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID}} 35 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID}} 36 | -------------------------------------------------------------------------------- /src/components/Button/LinkButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import COLORS from '../../utils/colors'; 5 | import Image from '../Base/Image'; 6 | import Icon from './../Base/Icon'; 7 | 8 | interface LinkButtonProps extends ButtonHTMLAttributes { 9 | to: string; 10 | name: string; 11 | isProfile?: boolean; 12 | src?: string; 13 | alt?: string; 14 | size?: number; 15 | } 16 | 17 | const LinkButton = ({ 18 | to, 19 | name, 20 | isProfile = false, 21 | src = '', 22 | alt = '', 23 | size = 16, 24 | }: LinkButtonProps) => { 25 | return ( 26 | 27 | {isProfile ? ( 28 | 29 | {alt} 30 | 31 | ) : ( 32 | 33 | )} 34 | 35 | ); 36 | }; 37 | 38 | const Container = styled.div` 39 | width: 2.4rem; 40 | height: 2.4rem; 41 | border-radius: 50%; 42 | border: 0.5px solid ${COLORS.lightGray}; 43 | `; 44 | export default LinkButton; 45 | -------------------------------------------------------------------------------- /src/hooks/useNotification.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect } from 'react'; 2 | import { getNotifications } from '../utils/api/notification'; 3 | import { useRecoilValue } from 'recoil'; 4 | import { tokenState } from '../recoil/atoms/user'; 5 | import type { NotificationResponse } from '../types/api/notification'; 6 | 7 | const useCheckNotifications = () => { 8 | const token = useRecoilValue(tokenState); 9 | const [notifications, setNotifications] = useState( 10 | [] 11 | ); 12 | 13 | const isSeen = useMemo(() => { 14 | const seenResults = notifications.map((v) => v.seen); 15 | if (seenResults.includes(false)) { 16 | return false; 17 | } 18 | return true; 19 | }, [notifications]); 20 | 21 | useEffect(() => { 22 | const handleInterval = setInterval(async () => { 23 | if (!token) return; 24 | const notifications = await getNotifications(); 25 | setNotifications(notifications); 26 | }, 15000); 27 | return () => { 28 | clearInterval(handleInterval); 29 | }; 30 | }, [token]); 31 | 32 | return { 33 | isSeen, 34 | }; 35 | }; 36 | 37 | export default useCheckNotifications; 38 | -------------------------------------------------------------------------------- /.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 | 6 | on: 7 | push: 8 | branches: main 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - name: create vercel.json 19 | run: | 20 | touch vercel.json 21 | echo ' 22 | { 23 | "rewrites": [ 24 | { 25 | "source": "/api/:url*", 26 | "destination": "${{ secrets.API_BASE_URL }}/:url*" 27 | } 28 | ] 29 | } 30 | ' >> vercel.json 31 | - name: Install Vercel CLI 32 | run: npm install --global vercel@latest 33 | - name: Pull Vercel Environment Information 34 | run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} 35 | - name: Build Project Artifacts 36 | run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} 37 | - name: Deploy Project Artifacts to Vercel 38 | run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} 39 | -------------------------------------------------------------------------------- /src/components/Base/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import styled from 'styled-components'; 3 | interface IconProps { 4 | name: string; 5 | size?: number; 6 | width?: number; 7 | height?: number; 8 | style?: CSSProperties; 9 | } 10 | 11 | interface StyledIconProps { 12 | size?: number; 13 | widthProps?: number; 14 | heighProps?: number; 15 | } 16 | 17 | const Icon = ({ name, size, width, height, style, ...props }: IconProps) => { 18 | return ( 19 | 20 | 28 | 29 | ); 30 | }; 31 | 32 | export default Icon; 33 | 34 | const IconContainer = styled.i` 35 | display: inline-block; 36 | `; 37 | 38 | const StyledIcon = styled.img` 39 | ${({ size, widthProps, heighProps }) => 40 | size 41 | ? ` 42 | width: ${size}px; 43 | height: ${size}px; 44 | ` 45 | : ` 46 | width: ${widthProps}px; 47 | height: ${heighProps}px; 48 | `} 49 | -webkit-user-drag: none; 50 | -khtml-user-drag: none; 51 | -moz-user-drag: none; 52 | -o-user-drag: none; 53 | `; 54 | -------------------------------------------------------------------------------- /src/components/Base/ToastItem.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import { ToastProps } from '../../recoil/atoms/toast'; 4 | import COLORS from '../../utils/colors'; 5 | 6 | const ToastItem = ({ message, duration = 1000 }: ToastProps) => { 7 | const [visible, setVisible] = useState(false); 8 | 9 | useEffect(() => { 10 | setVisible(true); 11 | 12 | const handleSetTimeout = setTimeout(() => { 13 | setVisible(false); 14 | clearTimeout(handleSetTimeout); 15 | }, duration); 16 | }, [duration]); 17 | 18 | return {message}; 19 | }; 20 | 21 | export default ToastItem; 22 | 23 | const Toast = styled.div<{ visible: boolean }>` 24 | width: fit-content; 25 | position: absolute; 26 | left: 50%; 27 | bottom: 50%; 28 | padding: 1.5rem 2rem; 29 | transform: translate(-50%, -50%); 30 | background-color: ${COLORS.brownGray}; 31 | 32 | font-weight: 500; 33 | font-size: 1.4rem; 34 | line-height: 2rem; 35 | text-align: center; 36 | letter-spacing: -0.01em; 37 | 38 | box-shadow: 0px 3px 4px rgba(218, 218, 218, 0.24); 39 | border-radius: 23.5px; 40 | 41 | color: ${COLORS.white}; 42 | 43 | opacity: ${({ visible }) => (visible ? 0.95 : 0)}; 44 | transition: opacity 0.5s ease-in-out; 45 | `; 46 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | DigDigDeep 34 | 35 | 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/Base/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import COLORS from '../../utils/colors'; 3 | 4 | interface SpinnerProps { 5 | loading: boolean; 6 | height?: number; 7 | width?: number; 8 | stroke?: number; 9 | display?: 'block' | 'inline-block'; 10 | } 11 | 12 | interface ContainerProps { 13 | size: Pick; 14 | stroke: number; 15 | display: string; 16 | } 17 | 18 | const Spinner = ({ 19 | loading, 20 | width = 5, 21 | height = 5, 22 | display = 'block', 23 | stroke = 0.5, 24 | }: SpinnerProps) => { 25 | return loading ? ( 26 | 31 | ) : null; 32 | }; 33 | export default Spinner; 34 | 35 | const StyledSpinner = styled.div` 36 | display: ${({ display }) => display}; 37 | width: ${({ size }) => `${size.width}rem`}; 38 | height: ${({ size }) => `${size.height}rem`}; 39 | 40 | color: ${COLORS.lightGray}; 41 | border-width: ${({ stroke }) => `${stroke}rem`}; 42 | border-style: solid; 43 | 44 | border-top-color: ${COLORS.lightGray}; 45 | border-right-color: ${COLORS.lightGray}; 46 | border-left-color: transparent; 47 | border-bottom-color: transparent; 48 | border-radius: 50%; 49 | margin: 1.6rem auto; 50 | animation: spinner 0.7s linear infinite; 51 | 52 | @keyframes spinner { 53 | 100% { 54 | transform: rotate(360deg); 55 | } 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /src/components/Post/PostList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useRecoilValue } from 'recoil'; 3 | import styled from 'styled-components'; 4 | import { userState } from '../../recoil/atoms/user'; 5 | import { getPostsByAuthor } from '../../utils/api/post'; 6 | import Post from './Post'; 7 | import COLORS from '../../utils/colors'; 8 | import type { PostResponse } from '../../types/api/post'; 9 | 10 | interface Props { 11 | authorId: string; 12 | } 13 | 14 | const PostList = ({ authorId }: Props) => { 15 | const { _id: myId } = useRecoilValue(userState); 16 | const [posts, setPosts] = useState([]); 17 | const checkIsMine = authorId === myId ? true : false; 18 | 19 | const fetchPosts = useCallback(async () => { 20 | const posts = await getPostsByAuthor(authorId); 21 | setPosts(posts); 22 | }, [authorId]); 23 | 24 | useEffect(() => { 25 | fetchPosts(); 26 | }, [fetchPosts, authorId]); 27 | 28 | if (posts.length === 0) return 생성된 그라운드가 없습니다.; 29 | 30 | return ( 31 | 32 | {posts.map((post) => ( 33 | 34 | ))} 35 | 36 | ); 37 | }; 38 | 39 | export default PostList; 40 | 41 | const UnorderedList = styled.ul` 42 | width: 100%; 43 | `; 44 | 45 | const Text = styled.h3` 46 | margin-top: 4rem; 47 | font-weight: 400; 48 | font-size: 1.5rem; 49 | text-align: center; 50 | color: ${COLORS.brownGray}; 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/Profile/TabItem.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import COLORS from '../../utils/colors'; 4 | 5 | interface TabProps extends ButtonHTMLAttributes { 6 | item: string; 7 | value: number | undefined; 8 | isActive: boolean; 9 | onClick?(): void; 10 | } 11 | 12 | const TabItem = ({ item, value, isActive, onClick }: TabProps) => { 13 | const [name, setName] = useState(''); 14 | 15 | useEffect(() => { 16 | if (item === 'posts') { 17 | setName('ground'); 18 | } else if (item === 'followers') { 19 | setName('follower'); 20 | } else { 21 | setName('following'); 22 | } 23 | }, [item]); 24 | 25 | return ( 26 | 30 | ); 31 | }; 32 | 33 | export default TabItem; 34 | 35 | const Button = styled.button<{ isActive: boolean }>` 36 | cursor: pointer; 37 | width: 8rem; 38 | border-bottom: 3px solid 39 | ${({ isActive }) => (isActive ? COLORS.green : 'transparent')}; 40 | `; 41 | 42 | const Value = styled.div` 43 | font-size: 1.8rem; 44 | font-weight: 500; 45 | color: ${COLORS.text}; 46 | padding-bottom: 0.4rem; 47 | `; 48 | 49 | const Title = styled.div` 50 | color: ${COLORS.brownGray}; 51 | font-size: 1.3rem; 52 | font-weight: 500; 53 | padding-bottom: 0.7rem; 54 | `; 55 | -------------------------------------------------------------------------------- /src/components/UserForm/FormButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | import COLORS from '../../utils/colors'; 4 | import type { ButtonHTMLAttributes } from 'react'; 5 | 6 | interface FormButtonProps extends ButtonHTMLAttributes { 7 | children?: ReactNode; 8 | type: 'submit' | 'button'; 9 | isValid?: boolean; 10 | isSubmitting?: boolean; 11 | } 12 | 13 | const FormButton = ({ 14 | type, 15 | children, 16 | isValid = true, 17 | isSubmitting = false, 18 | ...props 19 | }: FormButtonProps) => { 20 | return ( 21 | 31 | ); 32 | }; 33 | 34 | export default FormButton; 35 | 36 | const Button = styled.button` 37 | width: 100%; 38 | font-weight: 700; 39 | font-size: 1.6rem; 40 | line-height: 1.9rem; 41 | letter-spacing: -0.01em; 42 | color: ${COLORS.white}; 43 | padding: 1.6rem 0; 44 | background-color: ${({ isValid }) => 45 | isValid ? `${COLORS.green}` : `${COLORS.lightGray}`}; 46 | box-shadow: ${({ isValid, isSubmitting }) => 47 | !isValid && isSubmitting 48 | ? '0px 2px 4px 1px rgba(127, 176, 49, 0.37)' 49 | : 'none'}; 50 | border-radius: 23.5px; 51 | border: none; 52 | cursor: pointer; 53 | 54 | :disabled { 55 | cursor: not-allowed; 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /src/hooks/usePost.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { checkFileSize } from '../utils/formRules'; 3 | import { ERROR_MESSAGES } from '../utils/messages'; 4 | import useToast from './useToast'; 5 | 6 | const usePost = () => { 7 | const [title, setTitle] = useState(''); 8 | const [body, setBody] = useState(''); 9 | const [image, setImage] = useState(); 10 | 11 | const [imageId, setImageId] = useState(''); 12 | 13 | const [name, setName] = useState(''); 14 | 15 | const { showToast } = useToast(); 16 | 17 | const handleChangeTitle = (e: React.ChangeEvent) => { 18 | setTitle(e.target.value); 19 | }; 20 | 21 | const handleChangeBody = (e: React.ChangeEvent) => { 22 | setBody(e.target.value); 23 | }; 24 | 25 | const handleChangeImage = async (e: React.ChangeEvent) => { 26 | try { 27 | const file = e.target.files?.[0]; 28 | if (!file) return; 29 | 30 | if (!checkFileSize(file.size)) { 31 | showToast({ message: ERROR_MESSAGES.MAX_SIZE_IS_10MB }); 32 | return; 33 | } 34 | setImage(file); 35 | setName(file.name); 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | }; 40 | 41 | return { 42 | name, 43 | title, 44 | body, 45 | image, 46 | imageId, 47 | setTitle, 48 | setBody, 49 | setImage, 50 | setImageId, 51 | handleChangeTitle, 52 | handleChangeBody, 53 | handleChangeImage, 54 | }; 55 | }; 56 | 57 | export default usePost; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dig-dig-deep", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^1.2.1", 7 | "react": "^18.2.0", 8 | "react-dom": "^18.2.0", 9 | "react-hook-form": "^7.41.5", 10 | "react-router-dom": "^6.6.1", 11 | "react-scripts": "5.0.1", 12 | "recoil": "^0.7.6", 13 | "styled-components": "^5.3.6", 14 | "typescript": "^4.9.4", 15 | "uuid": "^9.0.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.0.26", 19 | "@types/react-dom": "^18.0.10", 20 | "@types/styled-components": "^5.1.26", 21 | "@types/uuid": "^9.0.0", 22 | "@typescript-eslint/eslint-plugin": "^5.51.0", 23 | "@typescript-eslint/parser": "^5.51.0", 24 | "eslint": "^8.33.0", 25 | "eslint-plugin-react": "^7.32.2", 26 | "husky": "^8.0.3", 27 | "lint-staged": "^13.1.0", 28 | "prettier": "^2.8.2" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "prepare": "husky install" 34 | }, 35 | "eslintConfig": { 36 | "extends": [ 37 | "react-app", 38 | "react-app/jest" 39 | ] 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | }, 53 | "lint-staged": { 54 | "src/**/*.{js,jsx,ts,tsx}": [ 55 | "eslint --fix --max-warnings 0", 56 | "prettier --write" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Icon from '../Base/Icon'; 3 | import COLORS from '../../utils/colors'; 4 | import Image from '../Base/Image'; 5 | import type { NotificationResponse } from '../../types/api/notification'; 6 | 7 | const Notification = ({ 8 | author, 9 | like, 10 | follow, 11 | comment, 12 | }: NotificationResponse) => { 13 | return ( 14 | <> 15 | {like || follow || comment ? ( 16 | 17 | {author.image ? ( 18 | 19 | {author.fullName} 20 | 21 | ) : ( 22 | 23 | )} 24 | 25 | {author.fullName}님이{` `} 26 | {like && `내 그라운드를 좋아합니다.`} 27 | {follow && `나를 팔로우했습니다.`} 28 | {comment && `내 그라운드를 디깅했습니다.`} 29 | 30 | 31 | ) : null} 32 | 33 | ); 34 | }; 35 | 36 | export default Notification; 37 | 38 | const ListItem = styled.li` 39 | display: flex; 40 | align-items: center; 41 | gap: 1.8rem; 42 | padding: 1.4rem; 43 | border-bottom: 0.3px solid ${COLORS.lightGray}; 44 | `; 45 | 46 | const ImageContainer = styled.div` 47 | width: 3.8rem; 48 | height: 3.8rem; 49 | border-radius: 50%; 50 | `; 51 | 52 | const Text = styled.span` 53 | font-weight: 400; 54 | font-size: 1.4rem; 55 | line-height: 2rem; 56 | letter-spacing: -0.01em; 57 | color: ${COLORS.text}; 58 | `; 59 | 60 | const Strong = styled.strong` 61 | font-weight: 700; 62 | `; 63 | -------------------------------------------------------------------------------- /src/utils/messages.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_MESSAGES = { 2 | EDIT_ERROR: (type: string) => `${type} 수정에 실패했습니다.`, 3 | DELETE_ERROR: (type: string) => `${type} 삭제하는데 실패했습니다.`, 4 | CREATE_ERROR: (type: string) => `${type} 생성에 실패했습니다. `, 5 | GET_ERROR: (type: string) => `${type} 불러오던 중 문제가 발생했습니다.`, 6 | SERVER_ERROR: '서버와 통신 중 문제가 발생했습니다.', 7 | REQUIRE_LOGIN: '로그인이 필요합니다.', 8 | REQUIRE_INPUT: (type: string) => `${type} 작성해주세요.`, 9 | SEARCH_INPUT: '검색어를 입력해주세요.', 10 | MAX_SIZE_IS_10MB: '파일 크기는 10MB를 넘길 수 없습니다.', 11 | } as const; 12 | 13 | export const CONFIRM_MESSAGES = { 14 | DELETE_CONFIRM: '정말로 삭제하시겠습니까?', 15 | LOGOUT_CONFIRM: '정말로 로그아웃 하시겠습니까?', 16 | } as const; 17 | 18 | export const SUCCESS_MESSAGES = { 19 | DELETE_SUCCESS: (type: string) => `${type} 삭제되었습니다.`, 20 | EDIT_SUCCESS: (type: string) => `${type} 수정되었습니다.`, 21 | CREATE_SUCCESS: (type: string) => `${type} 생성되었습니다.`, 22 | SIGNUP_SUCCESS: '가입되었습니다.', 23 | SHARE_SUCCESS: '클립보드에 저장되었습니다', 24 | LIKE_SUCCESS: '좋아요를 눌렀습니다.', 25 | UNLIKE_SUCCESS: '좋아요를 취소했습니다.', 26 | FOLLOW_SUCCESS: '팔로우 했습니다.', 27 | UNFOLLOW_SUCCESS: '언팔로우 했습니다.', 28 | CREATE_COMMENT_SUCCESS: '디깅 +1', 29 | DELETE_COMMENT_SUCCESS: '디깅 -1', 30 | } as const; 31 | 32 | export const FORM_RULE_MESSAGE = { 33 | NICKNAME_REQUIRED: '닉네임을 입력해주세요.', 34 | NICKNAME_PATTERN: '영어, 숫자, 한글만 입력 가능합니다. (2-8자리)', 35 | EMAIL_REQUIRED: '이메일을 입력해주세요.', 36 | EMAIL_PATTERN: '올바르지 않은 이메일 형식입니다.', 37 | PASSWORD_REQUIRED: '비밀번호를 입력해주세요.', 38 | PASSWORD_PATTERN: '영문과 숫자를 조합해주세요. (6-12자리)', 39 | CONFIRM_PASSWORD_VALIDATE: '비밀번호가 일치하지 않습니다.', 40 | EMAIL_ALREADY_IN_USE: '이미 사용중인 이메일입니다.', 41 | INCORRECT_LOGIN_INFO: 42 | '이메일 또는 비밀번호를 잘못 입력했습니다.\n입력하신 내용을 다시 확인해주세요.', 43 | } as const; 44 | -------------------------------------------------------------------------------- /src/utils/api/user.ts: -------------------------------------------------------------------------------- 1 | import type { LoginResponse, UserResponse } from '../../types/api/user'; 2 | import axiosInstance from '../axios'; 3 | 4 | export const signUp = async ({ 5 | email, 6 | password, 7 | fullName, 8 | }: { 9 | email: string; 10 | password: string; 11 | fullName: string; 12 | }) => { 13 | await axiosInstance.post('/signup', { 14 | email, 15 | password, 16 | fullName, 17 | }); 18 | }; 19 | 20 | export const login = async ({ 21 | email, 22 | password, 23 | }: { 24 | email: string; 25 | password: string; 26 | }) => { 27 | const { data } = await axiosInstance.post('/login', { 28 | email, 29 | password, 30 | }); 31 | return data; 32 | }; 33 | 34 | export const updateUserName = async ({ fullName }: { fullName: string }) => { 35 | await axiosInstance.put('/settings/update-user', { 36 | fullName, 37 | }); 38 | }; 39 | 40 | export const updatePassword = async ({ password }: { password: string }) => { 41 | await axiosInstance.put('/settings/update-password', { 42 | password, 43 | }); 44 | }; 45 | 46 | export const uploadPhoto = async (photo: Blob) => { 47 | const formData = new FormData(); 48 | formData.append('image', photo); 49 | formData.append('isCover', 'false'); 50 | await axiosInstance.post('/users/upload-photo', formData); 51 | }; 52 | 53 | export const logout = async () => { 54 | await axiosInstance.post('/logout'); 55 | }; 56 | 57 | export const getUser = async (userId: string) => { 58 | const { data } = await axiosInstance.get(`/users/${userId}`); 59 | return data; 60 | }; 61 | 62 | export const getUsers = async () => { 63 | const { data } = await axiosInstance.get('users/get-users'); 64 | return data; 65 | }; 66 | -------------------------------------------------------------------------------- /src/pages/PostEditPage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import DetailHeader from '../components/Header/DetailHeader'; 3 | import PostEdit from '../components/Post/PostEdit'; 4 | import { useParams } from 'react-router-dom'; 5 | import { useEffect, useState } from 'react'; 6 | import usePost from '../hooks/usePost'; 7 | 8 | const PostEditPage = () => { 9 | const { postId } = useParams(); 10 | const [hasId, setHasId] = useState(false); 11 | const { 12 | name, 13 | title, 14 | body, 15 | image, 16 | imageId, 17 | setTitle, 18 | setBody, 19 | setImage, 20 | setImageId, 21 | handleChangeTitle, 22 | handleChangeBody, 23 | handleChangeImage, 24 | } = usePost(); 25 | 26 | useEffect(() => { 27 | if (postId) setHasId(true); 28 | }, [postId]); 29 | 30 | return ( 31 | 32 | 42 | 57 | 58 | ); 59 | }; 60 | 61 | export default PostEditPage; 62 | 63 | const Container = styled.div` 64 | display: flex; 65 | flex-direction: column; 66 | align-items: center; 67 | `; 68 | -------------------------------------------------------------------------------- /src/assets/styles/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: fallback; 6 | src: local(Inter-Regular), 7 | url(../fonts/Inter/Inter-Regular-subset.woff2) format('woff2'), 8 | url(../fonts/Inter/Inter-Regular-subset.woff) format('woff'); 9 | } 10 | 11 | @font-face { 12 | font-family: 'Inter'; 13 | font-style: normal; 14 | font-weight: 500; 15 | font-display: fallback; 16 | src: local(Inter-Medium), 17 | url(../fonts/Inter/Inter-Medium-subset.woff2) format('woff2'), 18 | url(../fonts/Inter/Inter-Medium-subset.woff) format('woff'); 19 | } 20 | 21 | @font-face { 22 | font-family: 'Inter'; 23 | font-style: normal; 24 | font-weight: 700; 25 | font-display: fallback; 26 | src: local(Inter-Bold), 27 | url(../fonts/Inter/Inter-Bold-subset.woff2) format('woff2'), 28 | url(../fonts/Inter/Inter-Bold-subset.woff) format('woff'); 29 | } 30 | 31 | @font-face { 32 | font-family: 'Noto Sans KR'; 33 | font-style: normal; 34 | font-weight: 400; 35 | src: local(NotoSansKR-Regular), 36 | url(../fonts/NotoSans/NotoSansKR-Regular-subset.woff2) format('woff2'), 37 | url(../fonts/NotoSans/NotoSansKR-Regular-subset.woff) format('woff'); 38 | } 39 | 40 | @font-face { 41 | font-family: 'Noto Sans KR'; 42 | font-style: normal; 43 | font-weight: 500; 44 | font-display: fallback; 45 | src: local(NotoSansKR-Medium), 46 | url(../fonts/NotoSans/NotoSansKR-Medium-subset.woff2) format('woff2'), 47 | url(../fonts/NotoSans/NotoSansKR-Medium-subset.woff) format('woff'); 48 | } 49 | 50 | @font-face { 51 | font-family: 'Noto Sans KR'; 52 | font-style: normal; 53 | font-weight: 700; 54 | font-display: fallback; 55 | src: local(NotoSansKR-Bold), 56 | url(../fonts/NotoSans/NotoSansKR-Bold-subset.woff2) format('woff2'), 57 | url(../fonts/NotoSans/NotoSansKR-Bold-subset.woff) format('woff'); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Header/DetailHeader.tsx: -------------------------------------------------------------------------------- 1 | import Icon from './../Base/Icon'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import COLORS from '../../utils/colors'; 5 | import EditButton from './../Post/EditButton'; 6 | interface Props { 7 | name?: string; 8 | isButton: boolean; 9 | buttonText?: string; 10 | title?: string; 11 | body?: string; 12 | postId?: string; 13 | image?: Blob | null; 14 | imageId?: string; 15 | children?: React.ReactNode; 16 | } 17 | 18 | const DetailHeader = ({ 19 | name, 20 | isButton, 21 | buttonText, 22 | title, 23 | body, 24 | postId, 25 | image, 26 | imageId, 27 | children, 28 | }: Props) => { 29 | const navigate = useNavigate(); 30 | 31 | return ( 32 | 33 | navigate(-1)}> 34 | 35 | 36 | {name} 37 | {isButton && ( 38 | 46 | )} 47 | {children} 48 | 49 | ); 50 | }; 51 | 52 | export default DetailHeader; 53 | 54 | const Container = styled.div` 55 | display: grid; 56 | grid-template-columns: 1fr 2fr 1fr; 57 | width: 35%; 58 | min-width: 350px; 59 | height: 6.4rem; 60 | align-items: center; 61 | justify-items: end; 62 | 63 | @media screen and (max-width: 767px) and (orientation: portrait) { 64 | width: 88%; 65 | min-width: 0; 66 | height: 6.1rem; 67 | } 68 | `; 69 | 70 | const BackLink = styled.button` 71 | cursor: pointer; 72 | font-size: 1.6rem; 73 | justify-self: start; 74 | `; 75 | 76 | const Title = styled.h3` 77 | font-weight: 700; 78 | font-size: 1.6rem; 79 | line-height: 1.9rem; 80 | letter-spacing: -0.01em; 81 | color: ${COLORS.text}; 82 | justify-self: center; 83 | width: max-content; 84 | `; 85 | -------------------------------------------------------------------------------- /src/pages/MyLikesPage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useRecoilValue } from 'recoil'; 3 | import Post from '../components/Post/Post'; 4 | import DetailHeader from '../components/Header/DetailHeader'; 5 | import { userState } from '../recoil/atoms/user'; 6 | import { getAllPosts } from '../utils/api/post'; 7 | import styled from 'styled-components'; 8 | import Spinner from '../components/Base/Spinner'; 9 | import type { PostResponse } from '../types/api/post'; 10 | 11 | const MyLikesPage = () => { 12 | const user = useRecoilValue(userState); 13 | const [posts, setPosts] = useState([]); 14 | const [loading, setLoading] = useState(false); 15 | 16 | const fetchPosts = useCallback(async () => { 17 | try { 18 | setLoading(true); 19 | const likedPosts = user.likes 20 | ?.filter((like) => like.post) 21 | .map((like) => like.post); 22 | const posts = await getAllPosts(); 23 | const filteredPosts = posts.filter((post) => { 24 | return likedPosts.find((like) => { 25 | return like === post._id; 26 | }); 27 | }); 28 | setPosts(filteredPosts); 29 | setLoading(false); 30 | } catch (err) { 31 | console.error(err); 32 | } 33 | }, [user]); 34 | 35 | useEffect(() => { 36 | fetchPosts(); 37 | }, [fetchPosts]); 38 | 39 | return ( 40 | 41 | 42 | 43 | {posts.map((post) => ( 44 | 45 | ))} 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default MyLikesPage; 53 | 54 | const Container = styled.div` 55 | display: flex; 56 | flex-direction: column; 57 | align-items: center; 58 | `; 59 | 60 | const Wrapper = styled.div` 61 | width: 35%; 62 | min-width: 350px; 63 | 64 | @media screen and (max-width: 767px) and (orientation: portrait) { 65 | width: 100%; 66 | min-width: 0; 67 | } 68 | `; 69 | -------------------------------------------------------------------------------- /src/utils/api/post.ts: -------------------------------------------------------------------------------- 1 | import type { PostResponse } from '../../types/api/post'; 2 | import axiosInstance from '../axios'; 3 | 4 | export const createPost = async ( 5 | title: string, 6 | image: Blob | null, 7 | channelId: string 8 | ) => { 9 | const formData = new FormData(); 10 | 11 | formData.append('title', title); 12 | image && formData.append('image', image); 13 | formData.append('channelId', channelId); 14 | 15 | const { data } = await axiosInstance.post(`/posts/create`, formData); 16 | return data; 17 | }; 18 | 19 | export const updatePost = async ( 20 | postId: string, 21 | title: string, 22 | image: Blob | null, 23 | channelId: string, 24 | imageToDeletePublicId?: string | null 25 | ) => { 26 | const formData = new FormData(); 27 | 28 | formData.append('postId', postId); 29 | formData.append('title', title); 30 | image && formData.append('image', image); 31 | formData.append('channelId', channelId); 32 | imageToDeletePublicId && 33 | formData.append('imageToDeletePublicId', imageToDeletePublicId); 34 | 35 | await axiosInstance.put(`/posts/update`, formData); 36 | }; 37 | interface GetPostProps { 38 | (limit: number, offset: number): Promise; 39 | } 40 | 41 | export const getPosts: GetPostProps = async (limit, offset) => { 42 | const { data } = await axiosInstance.get( 43 | `/posts?limit=${limit}&offset=${offset}` 44 | ); 45 | return data; 46 | }; 47 | 48 | export const getAllPosts = async () => { 49 | const { data } = await axiosInstance.get(`/posts`); 50 | return data; 51 | }; 52 | 53 | export const getPost = async (postId: string) => { 54 | const { data } = await axiosInstance.get(`/posts/${postId}`); 55 | return data; 56 | }; 57 | 58 | export const getPostsByAuthor = async (authorId: string) => { 59 | const { data } = await axiosInstance.get( 60 | `/posts/author/${authorId}` 61 | ); 62 | return data; 63 | }; 64 | 65 | export const deletePost = async (postId: string) => { 66 | await axiosInstance.delete(`/posts/delete`, { data: { id: postId } }); 67 | }; 68 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import COLORS from '../utils/colors'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import ROUTES from '../utils/routes'; 5 | 6 | const NotFoundPage = () => { 7 | const navigate = useNavigate(); 8 | return ( 9 | 10 | 11 | 찾을 수 없는 페이지입니다. 12 | 요청하신 페이지가 사라졌거나, 잘못된 경로입니다... 13 | 14 | 15 | 16 | ); 17 | }; 18 | export default NotFoundPage; 19 | 20 | const Container = styled.div` 21 | height: 100vh; 22 | background-color: #9dd0ff; 23 | position: relative; 24 | overflow: hidden; 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | justify-content: center; 29 | `; 30 | 31 | const Image = styled.img` 32 | width: 20vw; 33 | 34 | @media screen and (max-width: 767px) and (orientation: portrait) { 35 | width: 50vw; 36 | } 37 | `; 38 | 39 | const Title = styled.h1` 40 | color: ${COLORS.white}; 41 | font-weight: 700; 42 | font-size: 3rem; 43 | margin: 5rem 0 1.7rem; 44 | 45 | @media screen and (max-width: 767px) and (orientation: portrait) { 46 | margin: 3rem 0 1rem; 47 | font-size: 2.2rem; 48 | } 49 | `; 50 | 51 | const Detail = styled.h2` 52 | color: ${COLORS.white}; 53 | font-weight: 400; 54 | font-size: 2rem; 55 | 56 | @media screen and (max-width: 767px) and (orientation: portrait) { 57 | font-size: 1.4rem; 58 | } 59 | `; 60 | 61 | const Button = styled.button` 62 | border-radius: 23.5px; 63 | border: 4px solid ${COLORS.white}; 64 | color: ${COLORS.white}; 65 | padding: 0.8rem 4rem; 66 | font-weight: 500; 67 | font-size: 1.6rem; 68 | margin-top: 5rem; 69 | `; 70 | 71 | const Background = styled.img` 72 | width: 100%; 73 | position: absolute; 74 | left: 0; 75 | bottom: -2.8rem; 76 | 77 | @media screen and (max-width: 767px) and (orientation: portrait) { 78 | bottom: 0; 79 | } 80 | `; 81 | -------------------------------------------------------------------------------- /src/utils/formRules.ts: -------------------------------------------------------------------------------- 1 | import { LIMITED_FILE_SIZE } from './constants'; 2 | import { FORM_RULE_MESSAGE } from './messages'; 3 | 4 | export const SIGN_UP_RULES = { 5 | nickname: { 6 | required: FORM_RULE_MESSAGE.NICKNAME_REQUIRED, 7 | pattern: { 8 | value: /^[A-Za-z0-9가-힣]{2,12}$/, 9 | message: FORM_RULE_MESSAGE.NICKNAME_PATTERN, 10 | }, 11 | }, 12 | 13 | email: { 14 | required: FORM_RULE_MESSAGE.EMAIL_REQUIRED, 15 | pattern: { 16 | value: 17 | /([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$/, 18 | message: FORM_RULE_MESSAGE.EMAIL_PATTERN, 19 | }, 20 | }, 21 | 22 | password: { 23 | required: FORM_RULE_MESSAGE.PASSWORD_REQUIRED, 24 | pattern: { 25 | value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,12}$/, 26 | message: FORM_RULE_MESSAGE.PASSWORD_PATTERN, 27 | }, 28 | }, 29 | 30 | confirmPassword: (password: string) => { 31 | return { 32 | required: FORM_RULE_MESSAGE.PASSWORD_REQUIRED, 33 | validate: (confirmPassword: string) => 34 | confirmPassword === password || 35 | FORM_RULE_MESSAGE.CONFIRM_PASSWORD_VALIDATE, 36 | }; 37 | }, 38 | } as const; 39 | 40 | export const LOGIN_RULES = { 41 | email: { 42 | required: FORM_RULE_MESSAGE.EMAIL_REQUIRED, 43 | }, 44 | 45 | password: { 46 | required: FORM_RULE_MESSAGE.PASSWORD_REQUIRED, 47 | }, 48 | } as const; 49 | 50 | export const PROFILE_EDIT_RULES = { 51 | nickname: { 52 | pattern: { 53 | value: /^[A-Za-z0-9가-힣]{2,12}$/, 54 | message: FORM_RULE_MESSAGE.NICKNAME_PATTERN, 55 | }, 56 | }, 57 | 58 | password: { 59 | pattern: { 60 | value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,12}$/, 61 | message: FORM_RULE_MESSAGE.PASSWORD_PATTERN, 62 | }, 63 | }, 64 | 65 | confirmPassword: (password: string) => { 66 | return { 67 | validate: (confirmPassword?: string) => 68 | confirmPassword === password || 69 | FORM_RULE_MESSAGE.CONFIRM_PASSWORD_VALIDATE, 70 | }; 71 | }, 72 | } as const; 73 | 74 | export const checkFileSize = (fileSize: number) => { 75 | return fileSize <= LIMITED_FILE_SIZE; 76 | }; 77 | -------------------------------------------------------------------------------- /src/Router.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; 2 | import { useRecoilValue } from 'recoil'; 3 | import { tokenState } from './recoil/atoms/user'; 4 | import ROUTES from './utils/routes'; 5 | import { 6 | HomePage, 7 | LoginPage, 8 | ProfilePage, 9 | MyLikesPage, 10 | SignUpPage, 11 | NotFoundPage, 12 | PostPage, 13 | NotificationsPage, 14 | SearchPage, 15 | PostEditPage, 16 | ProfileEditPage, 17 | } from './pages'; 18 | import SearchbarLayout from './components/Layout/SearchbarLayout'; 19 | 20 | const Router = () => { 21 | const token = useRecoilValue(tokenState); 22 | 23 | return ( 24 | 25 | 26 | {/* with searchbar */} 27 | }> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | 39 | ) : ( 40 | 41 | ) 42 | } 43 | /> 44 | 45 | {/* without searchbar */} 46 | : 50 | } 51 | /> 52 | : 56 | } 57 | /> 58 | } /> 59 | } /> 60 | } /> 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default Router; 67 | -------------------------------------------------------------------------------- /src/components/Notification/NotificationList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import Notification from './Notification'; 4 | import { useRecoilState } from 'recoil'; 5 | import { userState } from '../../recoil/atoms/user'; 6 | import COLORS from '../../utils/colors'; 7 | import { 8 | getNotifications, 9 | seenNotifications, 10 | } from '../../utils/api/notification'; 11 | import { ERROR_MESSAGES } from '../../utils/messages'; 12 | import useToast from '../../hooks/useToast'; 13 | import type { NotificationResponse } from '../../types/api/notification'; 14 | 15 | const NotificationList = () => { 16 | const [user] = useRecoilState(userState); 17 | const { showToast } = useToast(); 18 | 19 | const [notifications, setNotifications] = useState( 20 | [] 21 | ); 22 | 23 | const fetchNotifications = useCallback(async () => { 24 | try { 25 | const notifications = await getNotifications(); 26 | setNotifications(notifications); 27 | } catch (error) { 28 | console.error(error); 29 | showToast({ message: ERROR_MESSAGES.GET_ERROR('알림을') }); 30 | } 31 | }, [showToast]); 32 | 33 | const fetchSeenNotifications = useCallback(async () => { 34 | try { 35 | await seenNotifications(); 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | }, []); 40 | 41 | useEffect(() => { 42 | fetchSeenNotifications(); 43 | fetchNotifications(); 44 | }, [fetchSeenNotifications, fetchNotifications]); 45 | 46 | if (notifications.length === 0) { 47 | return 아무 알림도 오지 않았어요 ... 🦔; 48 | } 49 | 50 | return ( 51 | 52 | {notifications 53 | .filter((notification) => notification.author._id !== user._id) 54 | .map((notification) => ( 55 | 56 | ))} 57 | 58 | ); 59 | }; 60 | 61 | export default NotificationList; 62 | 63 | const List = styled.ul` 64 | width: 35%; 65 | min-width: 350px; 66 | 67 | @media screen and (max-width: 767px) and (orientation: portrait) { 68 | width: 88%; 69 | min-width: 0; 70 | } 71 | `; 72 | 73 | const Text = styled.h3` 74 | margin-top: 4rem; 75 | font-weight: 400; 76 | font-size: 1.5rem; 77 | text-align: center; 78 | color: ${COLORS.brownGray}; 79 | `; 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 디그디그딥 🦔 2 | 3 | ![README_main](https://user-images.githubusercontent.com/25377159/213715421-318edbea-e31c-4c83-8be0-2d9206bffb7f.jpg) 4 | 5 |
6 | 7 | # 서비스 소개 8 | 9 | logo 10 | 디그디그딥은 하나의 주제, 개념, 지직에 대해 깊게 이야기 할 수 있는 장소를 제공한다. 11 | 깊게 땅을 파는 두더 12 | 지로 부터 영감을 받았다. 13 | 14 | 사용자에게 학습을 시작할 수 있는 그라운드를 제공하고, 디깅을 통해 서로 의견을 나누며 더욱 깊이있는 학습을 할 수 있다. 15 | 16 |
17 | 18 | # 시연 영상 19 | [유튜브 시연 영상 바로가기](https://youtu.be/a-2vmgSHIYc) 20 | 21 |
22 | 23 | # Members 24 | 25 | ![members](https://user-images.githubusercontent.com/25377159/213723856-068944ac-4ed3-4870-ad89-18000497ce8d.jpg) 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
원다연김민재노지원백민종
37 | 38 |
39 | 40 | # 기술 스텍 41 | 42 | react 43 | typescript 44 | styled-components 45 | vercel 46 | 47 |
48 | 49 | # 개발 컨벤션 및 협업 툴 50 | 51 | git 52 | github 53 | prettier 54 | eslint 55 | figma 56 | notion 57 | 58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /src/components/Base/Image.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface ImageProps { 5 | src: string; 6 | alt: string; 7 | objectFit?: 'contain' | 'cover'; 8 | } 9 | 10 | const Image = ({ src, alt, objectFit = 'cover' }: ImageProps) => { 11 | const [loaded, setLoaded] = useState(false); 12 | const imageElement = useRef(null); 13 | 14 | const onLoad = useCallback(() => { 15 | if (!imageElement.current) return; 16 | if (imageElement.current.complete) { 17 | setLoaded(true); 18 | } 19 | }, []); 20 | 21 | useEffect(() => { 22 | setLoaded(false); 23 | onLoad(); 24 | }, [src, onLoad]); 25 | 26 | useEffect(() => { 27 | onLoad(); 28 | }, [onLoad]); 29 | 30 | return ( 31 | <> 32 | 40 | {!loaded && } 41 | 42 | ); 43 | }; 44 | 45 | export default Image; 46 | 47 | const Skleton = styled.div` 48 | width: 100%; 49 | height: 100%; 50 | display: inline-block; 51 | border-radius: inherit; 52 | background-image: linear-gradient( 53 | 90deg, 54 | #dfe3e8 0px, 55 | #efefef 40px, 56 | #dfe3e8 80px 57 | ); 58 | background-size: 200% 100%; 59 | background-position: 0 center; 60 | animation: skeleton--zoom-in 0.2s ease-out, 61 | skeleton--loading 2s infinite linear; 62 | 63 | @keyframes skeleton--zoom-in { 64 | 0% { 65 | opacity: 0; 66 | transform: scale(0.95); 67 | } 68 | 100% { 69 | opacity: 1; 70 | transform: scale(1); 71 | } 72 | } 73 | 74 | @keyframes skeleton--loading { 75 | 0% { 76 | background-position-x: 100%; 77 | } 78 | 50% { 79 | background-position-x: -100%; 80 | } 81 | 100% { 82 | background-position-x: -100%; 83 | } 84 | } 85 | `; 86 | 87 | const StyledImage = styled.img<{ loaded: boolean }>` 88 | width: 100%; 89 | height: 100%; 90 | opacity: ${({ loaded }) => (loaded ? '1' : '0')}; 91 | position: ${({ loaded }) => (loaded ? 'unset' : 'absolute')}; 92 | transition: all 0.5s ease-in; 93 | border-radius: inherit; 94 | -webkit-user-drag: none; 95 | -khtml-user-drag: none; 96 | -moz-user-drag: none; 97 | -o-user-drag: none; 98 | `; 99 | -------------------------------------------------------------------------------- /src/GlobalStyle.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import COLORS from './utils/colors'; 3 | 4 | const GlobalStyle = createGlobalStyle` 5 | html, 6 | body, 7 | div, 8 | span, 9 | applet, 10 | object, 11 | iframe, 12 | h1, 13 | h2, 14 | h3, 15 | h4, 16 | h5, 17 | h6, 18 | p, 19 | blockquote, 20 | pre, 21 | a, 22 | abbr, 23 | acronym, 24 | address, 25 | big, 26 | cite, 27 | code, 28 | del, 29 | dfn, 30 | em, 31 | img, 32 | ins, 33 | kbd, 34 | q, 35 | s, 36 | samp, 37 | small, 38 | strike, 39 | strong, 40 | sub, 41 | sup, 42 | tt, 43 | var, 44 | b, 45 | u, 46 | i, 47 | center, 48 | dl, 49 | dt, 50 | dd, 51 | ol, 52 | ul, 53 | li, 54 | fieldset, 55 | form, 56 | label, 57 | legend, 58 | table, 59 | caption, 60 | tbody, 61 | tfoot, 62 | thead, 63 | tr, 64 | th, 65 | td, 66 | article, 67 | aside, 68 | canvas, 69 | details, 70 | embed, 71 | figure, 72 | figcaption, 73 | footer, 74 | header, 75 | hgroup, 76 | menu, 77 | nav, 78 | output, 79 | ruby, 80 | section, 81 | summary, 82 | time, 83 | mark, 84 | audio, 85 | video { 86 | margin: 0; 87 | padding: 0; 88 | border: 0; 89 | font: inherit; 90 | vertical-align: baseline; 91 | } 92 | html { 93 | font-size: 62.5%; 94 | } 95 | body { 96 | background-color: ${COLORS.bgColor}; 97 | font-family: 'Inter', 'Noto Sans KR', sans-serif; 98 | font-style: normal; 99 | } 100 | /* HTML5 display-role reset for older browsers */ 101 | article, 102 | aside, 103 | details, 104 | figcaption, 105 | figure, 106 | footer, 107 | header, 108 | hgroup, 109 | menu, 110 | nav, 111 | section { 112 | display: block; 113 | } 114 | body { 115 | line-height: 1; 116 | } 117 | ol, 118 | ul { 119 | list-style: none; 120 | } 121 | blockquote, 122 | q { 123 | quotes: none; 124 | } 125 | blockquote:before, 126 | blockquote:after, 127 | q:before, 128 | q:after { 129 | content: ''; 130 | content: none; 131 | } 132 | table { 133 | border-collapse: collapse; 134 | border-spacing: 0; 135 | } 136 | button { 137 | border: none; 138 | background-color: transparent; 139 | padding: 0; 140 | margin: 0; 141 | font-family: 'Inter', 'Noto Sans KR', sans-serif; 142 | } 143 | input { 144 | border: none; 145 | outline: none; 146 | } 147 | textarea { 148 | border: none; 149 | resize: none; 150 | outline: none; 151 | font-family: 'Inter', 'Noto Sans KR', sans-serif; 152 | } 153 | a { 154 | color: black; 155 | text-decoration: none; 156 | outline: none; 157 | } 158 | `; 159 | 160 | export default GlobalStyle; 161 | -------------------------------------------------------------------------------- /src/components/Post/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import styled from 'styled-components'; 3 | import { createPost, updatePost } from '../../utils/api/post'; 4 | import COLORS from '../../utils/colors'; 5 | import { useNavigate } from 'react-router-dom'; 6 | import useToast from '../../hooks/useToast'; 7 | import ROUTES from '../../utils/routes'; 8 | import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../../utils/messages'; 9 | import { useSetRecoilState } from 'recoil'; 10 | import { loadingState } from '../../recoil/atoms/loading'; 11 | 12 | interface Props { 13 | text?: string; 14 | title?: string; 15 | body?: string; 16 | postId?: string; 17 | image?: Blob | null; 18 | imageId?: string; 19 | } 20 | 21 | const EditButton = ({ text, title, body, postId, image, imageId }: Props) => { 22 | const navigator = useNavigate(); 23 | const { showToast } = useToast(); 24 | const setLoading = useSetRecoilState(loadingState); 25 | 26 | const onClick = () => { 27 | if (!title || title.trim() === '') { 28 | showToast({ message: ERROR_MESSAGES.REQUIRE_INPUT('제목을') }); 29 | return; 30 | } 31 | if (!body || body.trim() === '') { 32 | showToast({ message: ERROR_MESSAGES.REQUIRE_INPUT('내용을') }); 33 | return; 34 | } 35 | text === 'CREATE' ? createGround() : updateGround(); 36 | }; 37 | 38 | const createGround = useCallback(async () => { 39 | try { 40 | setLoading(true); 41 | const data = await createPost( 42 | JSON.stringify({ title, body }), 43 | image ?? null, 44 | '63cab9b7bee10265b9975db0' 45 | ); 46 | showToast({ message: SUCCESS_MESSAGES.CREATE_SUCCESS('그라운드가') }); 47 | navigator(ROUTES.POSTS_BY_ID(data._id)); 48 | setLoading(false); 49 | } catch { 50 | showToast({ message: ERROR_MESSAGES.CREATE_ERROR('그라운드') }); 51 | } 52 | }, [setLoading, title, body, image, showToast, navigator]); 53 | 54 | const updateGround = useCallback(async () => { 55 | try { 56 | if (postId) { 57 | setLoading(true); 58 | await updatePost( 59 | postId, 60 | JSON.stringify({ title, body }), 61 | image ?? null, 62 | '63cab9b7bee10265b9975db0', 63 | image || image === null ? imageId : undefined 64 | ); 65 | showToast({ message: SUCCESS_MESSAGES.EDIT_SUCCESS('그라운드가') }); 66 | navigator(ROUTES.POSTS_BY_ID(postId)); 67 | setLoading(false); 68 | } 69 | } catch { 70 | showToast({ message: ERROR_MESSAGES.EDIT_ERROR('그라운드') }); 71 | } 72 | }, [postId, setLoading, title, body, image, imageId, showToast, navigator]); 73 | 74 | return ; 75 | }; 76 | 77 | export default EditButton; 78 | 79 | const Button = styled.button` 80 | background: ${COLORS.green}; 81 | border-radius: 23.5px; 82 | font-weight: 700; 83 | font-size: 1.2rem; 84 | letter-spacing: -0.01em; 85 | color: ${COLORS.white}; 86 | padding: 1.1rem 1.8rem; 87 | width: fit-content; 88 | justify-self: right; 89 | cursor: pointer; 90 | `; 91 | -------------------------------------------------------------------------------- /src/components/UserForm/FormProfileImage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import COLORS from '../../utils/colors'; 3 | 4 | import { ChangeEvent, InputHTMLAttributes, useState } from 'react'; 5 | import type { Control, FieldPath, FieldValues } from 'react-hook-form'; 6 | import { useController } from 'react-hook-form'; 7 | import Image from '../Base/Image'; 8 | import { checkFileSize } from '../../utils/formRules'; 9 | import useToast from '../../hooks/useToast'; 10 | import { ERROR_MESSAGES } from '../../utils/messages'; 11 | 12 | const defaultProfile = require('../../assets/images/icon/default-profile.png'); 13 | 14 | interface UserInputPrpos 15 | extends InputHTMLAttributes { 16 | control: Control; 17 | name: FieldPath; 18 | src?: string; 19 | } 20 | 21 | const FormProfileImage = ({ 22 | name, 23 | control, 24 | src, 25 | }: UserInputPrpos) => { 26 | const { 27 | field: { onChange }, 28 | } = useController({ 29 | name, 30 | control, 31 | }); 32 | 33 | const { showToast } = useToast(); 34 | const [previewSrc, setPreviewSrc] = useState(src); 35 | 36 | const onFileChange = (e: ChangeEvent) => { 37 | if (!e.target.files) return; 38 | else { 39 | const file = e.target.files[0]; 40 | if (!checkFileSize(file.size)) { 41 | showToast({ message: ERROR_MESSAGES.MAX_SIZE_IS_10MB }); 42 | return; 43 | } 44 | setPreviewSrc(URL.createObjectURL(file)); 45 | onChange(file); 46 | } 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default FormProfileImage; 68 | 69 | const Container = styled.div` 70 | margin-top: 6rem; 71 | position: relative; 72 | margin-bottom: 5rem; 73 | `; 74 | 75 | const Input = styled.input` 76 | display: none; 77 | `; 78 | 79 | const Label = styled.label` 80 | display: flex; 81 | flex-direction: column; 82 | align-items: center; 83 | gap: 1.6rem; 84 | font-weight: 700; 85 | font-size: 1.4rem; 86 | line-height: 1.7rem; 87 | color: ${COLORS.brownGray}; 88 | width: 10rem; 89 | cursor: pointer; 90 | `; 91 | 92 | const StyledButton = styled.button` 93 | cursor: pointer; 94 | :disabled { 95 | cursor: not-allowed; 96 | } 97 | `; 98 | 99 | const StyledSpan = styled.span``; 100 | 101 | const ImageContainer = styled.div` 102 | display: block; 103 | width: 10rem; 104 | height: 10rem; 105 | border-radius: 50%; 106 | overflow: hidden; 107 | position: relative; 108 | `; 109 | -------------------------------------------------------------------------------- /src/components/User/UserItem.tsx: -------------------------------------------------------------------------------- 1 | import { useParams, useNavigate } from 'react-router-dom'; 2 | import { useRecoilValue } from 'recoil'; 3 | import styled from 'styled-components'; 4 | import { userState } from '../../recoil/atoms/user'; 5 | import { unfollow } from '../../utils/api/follow'; 6 | import COLORS from '../../utils/colors'; 7 | import Icon from '../Base/Icon'; 8 | import useGetMyInfo from '../../hooks/useGetMyInfo'; 9 | import Image from '../Base/Image'; 10 | import { queryLowImage } from '../../utils/image'; 11 | import type { UserResponse } from '../../types/api/user'; 12 | import type { FollowResponse } from '../../types/api/follow'; 13 | 14 | const defaultProfile = require('../../assets/images/icon/default-profile.png'); 15 | 16 | interface UserItemProps { 17 | user: UserResponse; 18 | type?: 'following' | 'followers'; 19 | follow?: FollowResponse; 20 | onUnfollow?: () => unknown; 21 | } 22 | 23 | const UserItem = ({ user, type, follow, onUnfollow }: UserItemProps) => { 24 | const { userId } = useParams() as { userId: string }; 25 | const navigate = useNavigate(); 26 | const myUser = useRecoilValue(userState); 27 | 28 | const getMyInfo = useGetMyInfo(); 29 | 30 | const onClickUnfollow = async ( 31 | event: React.MouseEvent 32 | ) => { 33 | event.stopPropagation(); 34 | if (!user || !follow) return; 35 | await unfollow({ followId: follow._id }); 36 | await getMyInfo(); 37 | onUnfollow && (await onUnfollow()); 38 | }; 39 | 40 | const isUnfollowable = () => { 41 | return userId === 'me' && type === 'following'; 42 | }; 43 | 44 | const onContainerClick = () => { 45 | navigate(`/profile/${user._id === myUser._id ? 'me' : user._id}`); 46 | }; 47 | 48 | const render = () => { 49 | if (!user) return null; 50 | return ( 51 | 52 | 53 | user-profile 59 | 60 | {user.fullName} 61 | {isUnfollowable() && ( 62 | 65 | )} 66 | 67 | ); 68 | }; 69 | 70 | return <>{render()}; 71 | }; 72 | 73 | export default UserItem; 74 | 75 | const Container = styled.div` 76 | display: flex; 77 | align-items: center; 78 | gap: 1.8rem; 79 | cursor: pointer; 80 | `; 81 | 82 | const ImageWrapper = styled.div` 83 | width: 3.8rem; 84 | height: 3.8rem; 85 | border-radius: 50%; 86 | `; 87 | 88 | const Text = styled.div` 89 | display: flex; 90 | align-items: center; 91 | font-weight: 500; 92 | font-size: 1.4rem; 93 | line-height: 2rem; 94 | letter-spacing: -0.01em; 95 | color: ${COLORS.text}; 96 | `; 97 | 98 | const Button = styled.button` 99 | margin-left: auto; 100 | cursor: pointer; 101 | `; 102 | -------------------------------------------------------------------------------- /src/components/Follow/FollowList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from 'react'; 2 | import styled from 'styled-components'; 3 | import { getUser } from '../../utils/api/user'; 4 | import UserItem from '../User/UserItem'; 5 | import useToast from '../../hooks/useToast'; 6 | import COLORS from '../../utils/colors'; 7 | import Spinner from '../Base/Spinner'; 8 | import { ERROR_MESSAGES } from '../../utils/messages'; 9 | import type { UserResponse } from '../../types/api/user'; 10 | import type { FollowResponse } from '../../types/api/follow'; 11 | 12 | interface BasicFollow { 13 | follows: FollowResponse[]; 14 | onUnfollow: () => unknown; 15 | } 16 | 17 | interface Following extends BasicFollow { 18 | type: 'following'; 19 | } 20 | 21 | interface Followers extends BasicFollow { 22 | type: 'followers'; 23 | } 24 | 25 | const FollowList = ({ type, follows, onUnfollow }: Following | Followers) => { 26 | const { showToast } = useToast(); 27 | 28 | const [users, setUsers] = useState([]); 29 | 30 | const [loading, setLoading] = useState(false); 31 | 32 | const fetchUsers = useCallback(async () => { 33 | setLoading(true); 34 | Promise.allSettled( 35 | follows.map((follow) => { 36 | const userId = type === 'following' ? follow.user : follow.follower; 37 | return getUser(userId); 38 | }) 39 | ) 40 | .then((results) => { 41 | const users = results.map((result) => { 42 | if (result.status !== 'fulfilled' || !result.value) 43 | throw new Error(''); 44 | 45 | return result.value; 46 | }); 47 | setLoading(false); 48 | setUsers(users); 49 | }) 50 | .catch((error) => { 51 | setLoading(false); 52 | console.error(error); 53 | showToast({ 54 | message: ERROR_MESSAGES.GET_ERROR('사용자 정보'), 55 | }); 56 | }); 57 | }, [follows, showToast, type]); 58 | 59 | useEffect(() => { 60 | fetchUsers(); 61 | }, [fetchUsers]); 62 | 63 | if (loading) { 64 | return ; 65 | } 66 | 67 | if (users.length === 0) { 68 | return ( 69 | 70 | 아직 {type === 'following' ? '팔로잉' : '팔로워'} 목록이 없습니다. 71 | 72 | ); 73 | } 74 | 75 | return ( 76 | 77 | {users.map((user, index) => ( 78 | 79 | 85 | 86 | ))} 87 | 88 | ); 89 | }; 90 | 91 | export default FollowList; 92 | 93 | const Text = styled.h3` 94 | margin-top: 4rem; 95 | font-weight: 400; 96 | font-size: 1.5rem; 97 | text-align: center; 98 | color: ${COLORS.brownGray}; 99 | `; 100 | 101 | const List = styled.ul` 102 | width: 100%; 103 | 104 | @media screen and (max-width: 767px) and (orientation: portrait) { 105 | width: 90%; 106 | } 107 | `; 108 | 109 | const UserListItem = styled.div` 110 | padding: 1.4rem; 111 | border-bottom: 0.3px solid ${COLORS.lightGray}; 112 | `; 113 | -------------------------------------------------------------------------------- /src/components/Follow/FollowButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { useRecoilValue } from 'recoil'; 3 | import { tokenState, userState } from '../../recoil/atoms/user'; 4 | import { useEffect, useState, useCallback } from 'react'; 5 | import { follow, unfollow } from '../../utils/api/follow'; 6 | import useGetMyInfo from '../../hooks/useGetMyInfo'; 7 | import useToast from '../../hooks/useToast'; 8 | import COLORS from '../../utils/colors'; 9 | import { sendNotification } from '../../utils/api/notification'; 10 | import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../../utils/messages'; 11 | 12 | interface FollowButtonProps { 13 | targetId: string; 14 | fetchUser: (...any: unknown[]) => unknown; 15 | } 16 | 17 | const FollowButton = ({ targetId, fetchUser }: FollowButtonProps) => { 18 | const user = useRecoilValue(userState); 19 | const { showToast } = useToast(); 20 | const token = useRecoilValue(tokenState); 21 | const [isFollowable, setIsFollowable] = useState(false); 22 | 23 | const checkMyFollow = useCallback(() => { 24 | return ( 25 | user.following && 26 | user.following.find((follow) => follow.user === targetId) 27 | ); 28 | }, [targetId, user.following]); 29 | 30 | const getMyInfo = useGetMyInfo(); 31 | 32 | useEffect(() => { 33 | const follow = checkMyFollow(); 34 | if (follow) { 35 | setIsFollowable(false); 36 | } else { 37 | setIsFollowable(true); 38 | } 39 | }, [checkMyFollow]); 40 | 41 | const handleOnClick = async () => { 42 | if (isFollowable) { 43 | await followUser(); 44 | } else { 45 | await unFollowUser(); 46 | } 47 | getMyInfo(); 48 | fetchUser(); 49 | }; 50 | 51 | const followUser = async () => { 52 | if (!token) return showToast({ message: ERROR_MESSAGES.REQUIRE_LOGIN }); 53 | 54 | try { 55 | const data = await follow({ userId: targetId }); 56 | showToast({ message: SUCCESS_MESSAGES.FOLLOW_SUCCESS }); 57 | sendNotification('FOLLOW', data._id, targetId, null); 58 | } catch (error) { 59 | console.error(error); 60 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 61 | } 62 | }; 63 | 64 | const unFollowUser = async () => { 65 | const { _id } = checkMyFollow() || {}; 66 | if (!_id) return; 67 | try { 68 | await unfollow({ followId: _id }); 69 | showToast({ message: SUCCESS_MESSAGES.UNFOLLOW_SUCCESS }); 70 | } catch (error) { 71 | console.error(error); 72 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 73 | } 74 | }; 75 | 76 | return ( 77 | <> 78 | {targetId === 'me' ? null : ( 79 | 82 | )} 83 | 84 | ); 85 | }; 86 | 87 | export default FollowButton; 88 | 89 | const Button = styled.button<{ isFollowable: boolean }>` 90 | background-color: ${({ isFollowable }) => 91 | isFollowable ? COLORS.green : COLORS.lightGray}; 92 | border-radius: 23.5px; 93 | font-weight: 700; 94 | font-size: 1.2rem; 95 | letter-spacing: -0.01em; 96 | color: ${COLORS.white}; 97 | padding: 1.1rem 1.8rem; 98 | width: fit-content; 99 | justify-self: right; 100 | cursor: pointer; 101 | `; 102 | -------------------------------------------------------------------------------- /src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import Post from '../components/Post/Post'; 4 | import { getPosts } from '../utils/api/post'; 5 | import useIntersectionObserver from '../hooks/useIntersectionObserver'; 6 | import { getChannelInfo } from '../utils/api/channel'; 7 | import { useSetRecoilState } from 'recoil'; 8 | import { loadingState } from '../recoil/atoms/loading'; 9 | import { ERROR_MESSAGES } from '../utils/messages'; 10 | import useToast from '../hooks/useToast'; 11 | import GlobalSpinner from '../components/Base/GlobalSpinner'; 12 | import type { PostResponse } from '../types/api/post'; 13 | 14 | const HomePage = () => { 15 | const [posts, setPosts] = useState([]); 16 | const [postLength, setPostLength] = useState(0); 17 | const [offset, setOffset] = useState(0); 18 | const setLoading = useSetRecoilState(loadingState); 19 | const { showToast } = useToast(); 20 | 21 | const onIntersect: IntersectionObserverCallback = async ([ 22 | { isIntersecting }, 23 | ]) => { 24 | if (postLength <= offset) return; 25 | if (isIntersecting) { 26 | getMorePost(); 27 | } 28 | }; 29 | 30 | const getMorePost = async () => { 31 | try { 32 | setLoading(true); 33 | const fetchedPosts = await getPosts(10, offset); 34 | setPosts([...posts, ...fetchedPosts]); 35 | setOffset(offset + 10); 36 | setLoading(false); 37 | } catch { 38 | alert('포스트 정보를 불러올 수 없습니다.'); 39 | } 40 | }; 41 | 42 | const { setTarget } = useIntersectionObserver({ onIntersect }); 43 | 44 | const getPostsLength = useCallback(async () => { 45 | const data = await getChannelInfo(); 46 | setPostLength(data.posts ? data.posts.length : 0); 47 | }, []); 48 | 49 | const fetchHandler = useCallback(async () => { 50 | try { 51 | setLoading(true); 52 | const fetchedPosts = await getPosts(10, 0); 53 | setPosts(fetchedPosts); 54 | setOffset(10); 55 | setLoading(false); 56 | } catch { 57 | showToast({ message: ERROR_MESSAGES.GET_ERROR('포스트를') }); 58 | } 59 | }, [showToast, setLoading]); 60 | 61 | useEffect(() => { 62 | getPostsLength(); 63 | fetchHandler(); 64 | }, [getPostsLength, fetchHandler]); 65 | 66 | return ( 67 | 68 | 69 | {posts.map((post) => ( 70 | 71 | 72 | 73 | 74 | ))} 75 | 76 | 77 | 78 | ); 79 | }; 80 | 81 | const ObservedDiv = styled.div``; 82 | 83 | const Container = styled.div` 84 | display: flex; 85 | flex-direction: column; 86 | width: 100%; 87 | height: 100vh; 88 | box-sizing: border-box; 89 | `; 90 | 91 | const List = styled.ul` 92 | width: 35%; 93 | min-width: 350px; 94 | display: flex; 95 | flex-direction: column; 96 | margin: 0 auto; 97 | box-sizing: border-box; 98 | 99 | @media screen and (max-width: 767px) and (orientation: portrait) { 100 | width: 100%; 101 | min-width: 0; 102 | } 103 | `; 104 | 105 | const ListItem = styled.li` 106 | width: 100%; 107 | margin: 0.5rem auto; 108 | `; 109 | 110 | export default HomePage; 111 | -------------------------------------------------------------------------------- /src/components/Header/LinkButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation, useNavigate } from 'react-router-dom'; 2 | import useCheckNotifications from '../../hooks/useNotification'; 3 | import { queryLowImage } from '../../utils/image'; 4 | import ROUTES from '../../utils/routes'; 5 | import LinkButton from '../Button/LinkButton'; 6 | import styled, { css } from 'styled-components'; 7 | import COLORS from '../../utils/colors'; 8 | import useLogout from '../../hooks/useLogout'; 9 | import useModal from '../../hooks/useModal'; 10 | import useToast from '../../hooks/useToast'; 11 | import { CONFIRM_MESSAGES, ERROR_MESSAGES } from '../../utils/messages'; 12 | import { tokenState, userState } from '../../recoil/atoms/user'; 13 | import { useRecoilValue } from 'recoil'; 14 | 15 | const defaultProfile = require('../../assets/images/icon/default-profile.png'); 16 | 17 | const LinkButtons = () => { 18 | const token = useRecoilValue(tokenState); 19 | const user = useRecoilValue(userState); 20 | const { isSeen } = useCheckNotifications(); 21 | const { pathname } = useLocation(); 22 | const isProfilePage = pathname.includes('/profile/me'); 23 | 24 | const logout = useLogout(); 25 | const { showModal } = useModal(); 26 | const { showToast } = useToast(); 27 | const navigate = useNavigate(); 28 | 29 | const handleLogout = async () => { 30 | if (!token) return; 31 | 32 | showModal({ 33 | message: CONFIRM_MESSAGES.LOGOUT_CONFIRM, 34 | handleConfirm: async () => { 35 | try { 36 | navigate(ROUTES.HOME); 37 | await logout(); 38 | } catch (error) { 39 | console.error(error); 40 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 41 | } 42 | }, 43 | }); 44 | }; 45 | 46 | if (!token) { 47 | return LOG IN; 48 | } 49 | 50 | return ( 51 | <> 52 | {isProfilePage ? ( 53 | LOGOUT 54 | ) : ( 55 | <> 56 | 57 | 62 | 70 | 71 | )} 72 | 73 | ); 74 | }; 75 | 76 | const LinkContainer = css` 77 | width: 100%; 78 | font-weight: 700; 79 | font-size: 1.3rem; 80 | letter-spacing: -0.01em; 81 | color: ${COLORS.white}; 82 | padding: 1.3rem 2rem; 83 | border-radius: 23.5px; 84 | border: none; 85 | min-width: max-content; 86 | cursor: pointer; 87 | 88 | @media screen and (max-width: 767px) and (orientation: portrait) { 89 | padding: 1.2rem 1.8rem; 90 | } 91 | `; 92 | 93 | const LogOutButton = styled.button` 94 | color: ${COLORS.white}; 95 | background-color: ${COLORS.lightGray}; 96 | ${LinkContainer}; 97 | font-size: 1rem; 98 | `; 99 | 100 | const LogInButton = styled(Link)` 101 | background-color: ${COLORS.green}; 102 | ${LinkContainer} 103 | `; 104 | 105 | export default LinkButtons; 106 | -------------------------------------------------------------------------------- /src/components/Base/Modal.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import useModal from '../../hooks/useModal'; 3 | import { useRecoilValue } from 'recoil'; 4 | import { ModalProps, modalState } from '../../recoil/atoms/modal'; 5 | import COLORS from '../../utils/colors'; 6 | import { useEffect, useState } from 'react'; 7 | 8 | interface ButtonProps { 9 | isConfirm?: boolean; 10 | } 11 | 12 | const Modal = () => { 13 | const modalProps = useRecoilValue(modalState); 14 | return <>{modalProps ? : null}; 15 | }; 16 | 17 | export default Modal; 18 | 19 | const ConfirmModal = ({ message, handleClose, handleConfirm }: ModalProps) => { 20 | const { hideModal } = useModal(); 21 | const [visible, setVisible] = useState(false); 22 | 23 | useEffect(() => { 24 | setVisible(true); 25 | }, []); 26 | 27 | const close = () => { 28 | setVisible(false); 29 | setTimeout(() => { 30 | hideModal(); 31 | }, 300); 32 | }; 33 | 34 | const onCancel = () => { 35 | if (handleClose) handleClose(); 36 | close(); 37 | }; 38 | 39 | const onConfirm = async () => { 40 | if (handleConfirm) await handleConfirm(); 41 | close(); 42 | }; 43 | 44 | return ( 45 | 46 | 47 | {message} 48 | 49 | 50 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | const Background = styled.div<{ visible: boolean }>` 60 | position: fixed; 61 | top: 0; 62 | left: 0; 63 | width: 100vw; 64 | height: 100vh; 65 | background-color: rgba(108, 108, 108, 0.318); 66 | z-index: 1000; 67 | opacity: ${({ visible }) => (visible ? 1 : 0)}; 68 | transition: opacity 0.3s ease-in-out; 69 | `; 70 | 71 | const Container = styled.div<{ visible: boolean }>` 72 | width: 20vw; 73 | position: relative; 74 | top: 50%; 75 | left: 50%; 76 | display: flex; 77 | flex-direction: column; 78 | gap: 3.2rem; 79 | transform: translate(-50%, -50%); 80 | padding: 4.4rem 4.6rem 3.3rem; 81 | background: ${COLORS.white}; 82 | box-shadow: 0px 3px 4px rgba(95, 95, 95, 0.191); 83 | border-radius: 15px; 84 | box-sizing: border-box; 85 | 86 | opacity: ${({ visible }) => (visible ? 1 : 0)}; 87 | transition: opacity 0.3s ease-in-out; 88 | 89 | @media screen and (max-width: 767px) and (orientation: portrait) { 90 | width: 72vw; 91 | padding: 4rem 2.5rem 3rem; 92 | } 93 | `; 94 | 95 | const Messasge = styled.div` 96 | font-weight: 700; 97 | font-size: 1.6rem; 98 | line-height: 2.3rem; 99 | letter-spacing: -0.01em; 100 | text-align: center; 101 | word-break: keep-all; 102 | color: ${COLORS.text}; 103 | `; 104 | 105 | const ButtonContainer = styled.div` 106 | display: flex; 107 | justify-content: space-around; 108 | @media screen and (max-width: 767px) and (orientation: portrait) { 109 | justify-content: space-evenly; 110 | } 111 | `; 112 | 113 | const Button = styled.button` 114 | font-weight: 700; 115 | font-size: 1.1rem; 116 | align-items: center; 117 | text-align: center; 118 | letter-spacing: -0.01em; 119 | width: 7rem; 120 | color: ${COLORS.white}; 121 | background-color: ${({ isConfirm }) => 122 | isConfirm ? COLORS.green : COLORS.lightGray}; 123 | border-radius: 23.5px; 124 | padding: 1rem; 125 | cursor: pointer; 126 | `; 127 | -------------------------------------------------------------------------------- /src/pages/PostPage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import Comment from '../components/Comment/Comment'; 5 | import CommentInput from '../components/Comment/CommentInput'; 6 | import Post from '../components/Post/Post'; 7 | import COLORS from '../utils/colors'; 8 | import { getPost } from '../utils/api/post'; 9 | import { ERROR_MESSAGES } from '../utils/messages'; 10 | import useToast from '../hooks/useToast'; 11 | import type { PostResponse } from '../types/api/post'; 12 | 13 | type PostId = string; 14 | 15 | const PostPage = () => { 16 | const { postId } = useParams(); 17 | const [post, setPost] = useState(); 18 | const [commentSubmitted, setCommentSubmitted] = useState(false); 19 | const { showToast } = useToast(); 20 | 21 | const fetchAndScroll = async () => { 22 | await fetchHandler(); 23 | setCommentSubmitted(true); 24 | }; 25 | 26 | const toScrollBottom = () => 27 | window.scroll({ 28 | top: document.body.scrollHeight, 29 | left: 100, 30 | behavior: 'smooth', 31 | }); 32 | 33 | const fetchHandler = useCallback(async () => { 34 | if (postId) { 35 | try { 36 | const postDetail = await getPost(postId); 37 | setPost(postDetail); 38 | } catch { 39 | showToast({ message: ERROR_MESSAGES.GET_ERROR('포스트를') }); 40 | } 41 | } 42 | }, [postId, showToast]); 43 | 44 | useEffect(() => { 45 | fetchHandler(); 46 | }, [fetchHandler]); 47 | 48 | useEffect(() => { 49 | if (commentSubmitted) { 50 | toScrollBottom(); 51 | setCommentSubmitted(false); 52 | } 53 | }, [commentSubmitted]); 54 | 55 | return ( 56 | 57 | {post && ( 58 | 59 | 60 | 61 | {post.comments.length ? ( 62 | post.comments.map((comment) => ( 63 | 64 | 72 | 73 | )) 74 | ) : ( 75 | 댓글이 없습니다. 76 | )} 77 | 78 | 83 | 84 | )} 85 | 86 | ); 87 | }; 88 | 89 | export default PostPage; 90 | 91 | const Container = styled.div` 92 | display: flex; 93 | flex-direction: column; 94 | width: 100%; 95 | box-sizing: border-box; 96 | position: relative; 97 | margin-bottom: 7.5rem; 98 | `; 99 | 100 | const List = styled.ul``; 101 | 102 | const ListItem = styled.li``; 103 | 104 | const Wrapper = styled.ul` 105 | width: 35%; 106 | min-width: 350px; 107 | display: flex; 108 | flex-direction: column; 109 | margin: 0 auto; 110 | 111 | @media screen and (max-width: 767px) and (orientation: portrait) { 112 | width: 100%; 113 | min-width: 0; 114 | } 115 | `; 116 | 117 | const Text = styled.div` 118 | text-align: center; 119 | font-weight: 400; 120 | font-size: 1.4rem; 121 | letter-spacing: -0.01em; 122 | color: ${COLORS.brownGray}; 123 | margin-bottom: 3rem; 124 | `; 125 | -------------------------------------------------------------------------------- /src/pages/SignUpPage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import SignUpForm from '../components/SIgnUp/SignUpForm'; 3 | import COLORS from '../utils/colors'; 4 | 5 | const bigLogo = require('../assets/images/logo/big.png'); 6 | 7 | const SignUpPage = () => { 8 | return ( 9 | 10 | 11 | 12 | logo 13 | 14 | 15 | 16 | Create your account! 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default SignUpPage; 25 | 26 | const Text = styled.span` 27 | font-weight: 700; 28 | font-size: 1.6rem; 29 | line-height: 1.9rem; 30 | letter-spacing: -0.01em; 31 | color: ${COLORS.text}; 32 | display: block; 33 | margin-bottom: 2rem; 34 | padding: 0 0.8rem; 35 | `; 36 | 37 | const Background = styled.div` 38 | width: 100%; 39 | height: 100vh; 40 | background-color: ${COLORS.brownGray}; 41 | padding: 5rem 8rem; 42 | box-sizing: border-box; 43 | 44 | @media screen and (max-width: 767px) { 45 | padding: 0; 46 | } 47 | 48 | @media screen and (max-height: 767px) and (orientation: landscape) { 49 | height: 767px; 50 | overflow: hidden; 51 | } 52 | `; 53 | 54 | const Container = styled.div` 55 | border-radius: 57px; 56 | background-color: ${COLORS.bgColor}; 57 | display: flex; 58 | flex-direction: row; 59 | width: 100%; 60 | height: 100%; 61 | justify-content: space-around; 62 | 63 | @media screen and (max-width: 767px) and (orientation: portrait) { 64 | display: flex; 65 | justify-content: start; 66 | padding: 0 1.6rem; 67 | box-sizing: border-box; 68 | flex-direction: column; 69 | gap: 5rem; 70 | border-radius: 0; 71 | } 72 | 73 | @media screen and (max-width: 767px) { 74 | border-radius: 0; 75 | } 76 | `; 77 | 78 | const ImageWrapper = styled.div` 79 | text-align: center; 80 | width: 25%; 81 | flex-shrink: 0; 82 | box-sizing: border-box; 83 | display: flex; 84 | align-items: center; 85 | justify-content: center; 86 | 87 | @media screen and (max-width: 1200px) { 88 | width: 40%; 89 | } 90 | 91 | @media screen and (max-width: 767px) and (orientation: portrait) { 92 | margin-top: 8rem; 93 | background-color: inherit; 94 | flex-direction: column; 95 | gap: 5rem; 96 | width: 100%; 97 | padding: 0 6rem; 98 | } 99 | `; 100 | 101 | const Divider = styled.hr` 102 | background-color: ${COLORS.lightGray}; 103 | position: absolute; 104 | display: inline-block; 105 | width: 1px; 106 | height: 50rem; 107 | vertical-align: middle; 108 | border: none; 109 | 110 | position: absolute; 111 | left: 50%; 112 | top: 50%; 113 | transform: translate(-50%, -50%); 114 | 115 | @media screen and (max-width: 767px) and (orientation: portrait) { 116 | display: none; 117 | } 118 | 119 | @media screen and (max-height: 767px) and (orientation: landscape) { 120 | height: 383.5px; 121 | position: fixed; 122 | top: 383.5px; 123 | } 124 | `; 125 | 126 | const Image = styled.img` 127 | height: min-content; 128 | /* width: min-content; */ 129 | max-width: 100%; 130 | @media screen and (max-width: 767px) and (orientation: portrait) { 131 | width: 100%; 132 | } 133 | `; 134 | 135 | const FormWrapper = styled.div` 136 | display: flex; 137 | flex-direction: column; 138 | justify-content: center; 139 | width: 25%; 140 | @media screen and (max-width: 1200px) { 141 | width: 40%; 142 | } 143 | @media screen and (max-width: 767px) and (orientation: portrait) { 144 | width: 100%; 145 | } 146 | `; 147 | -------------------------------------------------------------------------------- /src/components/SIgnUp/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useForm } from 'react-hook-form'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { AxiosError } from 'axios'; 5 | import { signUp } from '../../utils/api/user'; 6 | import UserForm from '../UserForm/UserForm'; 7 | import FormInput from '../UserForm/FormInput'; 8 | import FormButton from '../UserForm/FormButton'; 9 | import ErrorMessage from '../UserForm/ErrorMessage'; 10 | import useToast from '../../hooks/useToast'; 11 | import ROUTES from '../../utils/routes'; 12 | import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../../utils/messages'; 13 | import { useSetRecoilState } from 'recoil'; 14 | import { SIGN_UP_RULES } from '../../utils/formRules'; 15 | import { FORM_RULE_MESSAGE } from '../../utils/messages'; 16 | import { loadingState } from '../../recoil/atoms/loading'; 17 | 18 | const RESPONSE_ERROR_MESSAGE = 'The email address is already being used.'; 19 | 20 | const SignUpForm = () => { 21 | const navigate = useNavigate(); 22 | const [errorMessage, setErrorMessage] = useState(''); 23 | const setLoading = useSetRecoilState(loadingState); 24 | const { showToast } = useToast(); 25 | 26 | const { 27 | handleSubmit, 28 | resetField, 29 | watch, 30 | control, 31 | formState: { isSubmitting, isValid }, 32 | } = useForm({ 33 | mode: 'all', 34 | defaultValues: { 35 | email: '', 36 | fullName: '', 37 | password: '', 38 | confirmPassword: '', 39 | }, 40 | }); 41 | 42 | const onSubmit = async (data: { 43 | email: string; 44 | fullName: string; 45 | password: string; 46 | confirmPassword: string; 47 | }) => { 48 | try { 49 | setLoading(true); 50 | await signUp(data); 51 | setLoading(false); 52 | setErrorMessage(''); 53 | showToast({ message: SUCCESS_MESSAGES.SIGNUP_SUCCESS }); 54 | navigate(ROUTES.LOGIN); 55 | } catch (error) { 56 | setLoading(false); 57 | if (error instanceof AxiosError && error.response?.data) { 58 | if (error.response?.data === RESPONSE_ERROR_MESSAGE) { 59 | setErrorMessage(FORM_RULE_MESSAGE.EMAIL_ALREADY_IN_USE); 60 | } 61 | } else { 62 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 63 | } 64 | } 65 | }; 66 | 67 | return ( 68 | 69 | 76 | 83 | 92 | 101 | {errorMessage && ( 102 | 103 | {errorMessage} 104 | 105 | )} 106 | 112 | SIGN UP 113 | 114 | 115 | ); 116 | }; 117 | 118 | export default SignUpForm; 119 | -------------------------------------------------------------------------------- /src/components/UserForm/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, useState } from 'react'; 2 | import type { 3 | Control, 4 | FieldError, 5 | FieldPath, 6 | FieldValues, 7 | RegisterOptions, 8 | UseFormResetField, 9 | } from 'react-hook-form'; 10 | import { useController } from 'react-hook-form'; 11 | import styled from 'styled-components'; 12 | import Icon from '../Base/Icon'; 13 | import COLORS from '../../utils/colors'; 14 | import ErrorMessage from './ErrorMessage'; 15 | 16 | interface UserInputPrpos 17 | extends InputHTMLAttributes { 18 | control: Control; 19 | name: FieldPath; 20 | rules?: RegisterOptions; 21 | resetField?: UseFormResetField; 22 | icon?: string; 23 | } 24 | 25 | interface InputWrapperProps { 26 | error?: FieldError; 27 | isFocus: boolean; 28 | } 29 | 30 | const FormInput = ({ 31 | name, 32 | rules, 33 | control, 34 | resetField, 35 | icon = 'user', 36 | ...props 37 | }: UserInputPrpos) => { 38 | const { 39 | field: { value, onChange, onBlur }, 40 | fieldState: { error }, 41 | } = useController({ 42 | name, 43 | rules, 44 | control, 45 | }); 46 | 47 | const [isFocus, setIsFocus] = useState(false); 48 | 49 | const onInputFocus = () => setIsFocus(true); 50 | 51 | const onInputBlur = () => { 52 | setIsFocus(false); 53 | onBlur(); 54 | }; 55 | 56 | const onResetButtonClick = () => { 57 | if (resetField) resetField(name); 58 | }; 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 | 74 | {resetField && value && ( 75 | 76 | 77 | 78 | )} 79 | 80 | {error?.message && error.message} 81 | 82 | ); 83 | }; 84 | 85 | export default FormInput; 86 | 87 | const InputContainer = styled.div` 88 | display: flex; 89 | flex-direction: column; 90 | gap: 1.2rem; 91 | `; 92 | 93 | const InputWrapper = styled.div` 94 | display: flex; 95 | background: ${COLORS.white}; 96 | box-shadow: 0px 2px 4px rgba(146, 113, 96, 0.11); 97 | border-radius: 23.5px; 98 | align-items: center; 99 | justify-content: space-between; 100 | gap: 1.6rem; 101 | padding: 1.6rem 2.4rem; 102 | border: 1px solid; 103 | border-color: ${({ error, isFocus }) => 104 | error ? COLORS.orange : isFocus ? COLORS.lightBrown : COLORS.lightGray}; 105 | 106 | :hover { 107 | border-color: ${COLORS.lightBrown}; 108 | } 109 | 110 | transition: border 0.2s ease-in-out; 111 | `; 112 | 113 | const InputLabel = styled.label` 114 | position: absolute; 115 | `; 116 | 117 | const Input = styled.input` 118 | width: 100%; 119 | border: none; 120 | outline: none; 121 | letter-spacing: -0.01em; 122 | color: ${COLORS.lightBrown}; 123 | font-weight: 400; 124 | font-size: 1.4rem; 125 | line-height: 1.6rem; 126 | flex-shrink: 1; 127 | flex-grow: 1; 128 | padding: 0; 129 | 130 | ::placeholder { 131 | color: ${COLORS.brownGray}; 132 | line-height: 1.6rem; 133 | } 134 | 135 | :disabled { 136 | background: inherit; 137 | color: ${COLORS.brownGray}; 138 | } 139 | `; 140 | 141 | const ResetButton = styled.button` 142 | background-color: unset; 143 | border: none; 144 | padding: 0; 145 | width: 1.6rem; 146 | height: 1.6rem; 147 | cursor: pointer; 148 | `; 149 | -------------------------------------------------------------------------------- /src/components/Post/PostContent.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import styled from 'styled-components'; 3 | import COLORS from '../../utils/colors'; 4 | import { queryLowImage } from '../../utils/image'; 5 | import ROUTES from '../../utils/routes'; 6 | import Image from '../Base/Image'; 7 | 8 | interface PostContentProps { 9 | _id: string; 10 | title: string; 11 | image?: string; 12 | isDetailPage?: boolean; 13 | isMyLikes?: boolean; 14 | } 15 | 16 | interface PostDetailProps { 17 | isDetailPage?: boolean; 18 | isMyLikes?: boolean; 19 | } 20 | 21 | interface ImageContainerProps { 22 | width?: string; 23 | height?: string; 24 | isMyLikes?: boolean; 25 | } 26 | 27 | const PostContent = ({ 28 | _id, 29 | title, 30 | image, 31 | isDetailPage, 32 | isMyLikes, 33 | }: PostContentProps) => { 34 | const navigate = useNavigate(); 35 | const postContent = JSON.parse(title); 36 | 37 | const toPostDetail = () => { 38 | navigate(ROUTES.POSTS_BY_ID(_id)); 39 | }; 40 | 41 | if (isDetailPage) { 42 | return ( 43 |
44 | {postContent.title} 45 | {image && ( 46 | 47 | post-image 52 | 53 | )} 54 | {postContent.body} 55 |
56 | ); 57 | } 58 | 59 | if (isMyLikes) { 60 | return ( 61 | 62 | {postContent.title} 63 | {postContent.body} 64 | 65 | ); 66 | } 67 | 68 | return ( 69 |
70 | {postContent.title} 71 | {image && ( 72 | 73 | post-image 78 | 79 | )} 80 | {postContent.body} 81 |
82 | ); 83 | }; 84 | 85 | export default PostContent; 86 | 87 | const Section = styled.div` 88 | background-color: ${COLORS.white}; 89 | padding: 1rem 2rem; 90 | 91 | @media screen and (max-width: 767px) and (orientation: portrait) { 92 | padding: 1rem 1.5rem; 93 | } 94 | `; 95 | 96 | const PostWrapper = styled.div``; 97 | 98 | const shortenContent = ` 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | display: -webkit-box; 102 | -webkit-line-clamp: 2; 103 | -webkit-box-orient: vertical; 104 | `; 105 | 106 | const Title = styled.span` 107 | font-weight: 700; 108 | font-size: 1.8rem; 109 | line-height: 2.5rem; 110 | letter-spacing: -0.01em; 111 | color: ${COLORS.brown}; 112 | margin: ${({ isMyLikes }) => (isMyLikes ? '' : '0.5rem 0 1.5rem')}; 113 | word-break: break-all; 114 | ${({ isDetailPage }) => (isDetailPage ? '' : shortenContent)}; 115 | `; 116 | 117 | const Text = styled.div` 118 | font-weight: 500; 119 | font-size: 1.5rem; 120 | line-height: 2rem; 121 | margin: 1rem 0 0.5rem; 122 | letter-spacing: -0.01em; 123 | color: ${COLORS.brown}; 124 | word-break: break-all; 125 | white-space: pre-wrap; 126 | ${({ isDetailPage }) => (isDetailPage ? '' : shortenContent)}; 127 | `; 128 | 129 | const ImageContainer = styled.div` 130 | position: relative; 131 | display: inline-block; 132 | overflow: hidden; 133 | width: ${({ width }) => width ?? '12rem'}; 134 | min-width: 12rem; 135 | aspect-ratio: 1 / 1; 136 | background-color: #fafafa; 137 | border-radius: ${({ isMyLikes }) => (isMyLikes ? 0 : '15px')}; 138 | `; 139 | -------------------------------------------------------------------------------- /src/pages/SearchPage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useNavigate, useSearchParams } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import Spinner from '../components/Base/Spinner'; 5 | import Post from '../components/Post/Post'; 6 | import UserItem from '../components/User/UserItem'; 7 | import axiosInstance from '../utils/axios'; 8 | import COLORS from '../utils/colors'; 9 | import { getPost } from '../utils/api/post'; 10 | import type { PostResponse } from '../types/api/post'; 11 | import type { UserResponse } from '../types/api/user'; 12 | 13 | const SearchPage = () => { 14 | const [searchParams] = useSearchParams(); 15 | const navigate = useNavigate(); 16 | const [userResult, setUserResult] = useState([]); 17 | const [postResult, setPostResult] = useState([]); 18 | const [loading, setLoading] = useState(false); 19 | 20 | const toUserProfile = (authorId: string) => { 21 | navigate(`/profile/${authorId}`); 22 | }; 23 | 24 | const getSearch = useCallback(async () => { 25 | const getType = searchParams.get('type'); 26 | const getSearchTerm = searchParams.get('q'); 27 | 28 | if (getType === 'users') { 29 | setLoading(true); 30 | const { data } = await axiosInstance.get( 31 | `/search/users/${getSearchTerm}` 32 | ); 33 | setUserResult(data); 34 | setLoading(false); 35 | } 36 | if (searchParams.get('type') === 'posts') { 37 | setLoading(true); 38 | const { data } = await axiosInstance.get( 39 | `/search/all/${getSearchTerm}` 40 | ); 41 | Promise.all( 42 | data.reduce[]>((prev, post) => { 43 | if ('author' in post) { 44 | prev.push(getPost(post._id)); 45 | } 46 | return prev; 47 | }, []) 48 | ).then((data) => setPostResult(data)); 49 | setLoading(false); 50 | } 51 | }, [searchParams]); 52 | 53 | useEffect(() => { 54 | getSearch(); 55 | }, [getSearch]); 56 | 57 | return ( 58 | 59 | {searchParams.get('type') === 'users' ? ( 60 | 61 | {userResult.map((user) => ( 62 | toUserProfile(user._id)} 65 | > 66 | 67 | 68 | ))} 69 | 70 | ) : postResult.length ? ( 71 | 72 | {postResult.map((post) => ( 73 | 74 | 75 | 76 | ))} 77 | 78 | ) : ( 79 | 검색 결과가 없습니다. 80 | )} 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default SearchPage; 87 | 88 | const Container = styled.div` 89 | display: flex; 90 | flex-direction: column; 91 | width: 100%; 92 | height: 100vh; 93 | box-sizing: border-box; 94 | `; 95 | 96 | const UserListItem = styled.li` 97 | width: 80%; 98 | margin: 0.5rem auto; 99 | display: flex; 100 | align-items: center; 101 | gap: 1.8rem; 102 | padding: 1.4rem; 103 | border-bottom: 0.3px solid ${COLORS.lightGray}; 104 | `; 105 | 106 | const List = styled.ul` 107 | width: 35%; 108 | min-width: 350px; 109 | display: flex; 110 | flex-direction: column; 111 | margin: 0 auto; 112 | 113 | @media screen and (max-width: 767px) and (orientation: portrait) { 114 | width: 100%; 115 | min-width: 0; 116 | } 117 | `; 118 | 119 | const ListItem = styled.li` 120 | width: 100%; 121 | margin: 0.5rem auto; 122 | `; 123 | 124 | const BigText = styled.span` 125 | width: 100%; 126 | font-weight: 500; 127 | font-size: 2rem; 128 | line-height: 4rem; 129 | letter-spacing: -0.01em; 130 | color: ${COLORS.lightBrown}; 131 | position: absolute; 132 | top: 30%; 133 | text-align: center; 134 | `; 135 | -------------------------------------------------------------------------------- /src/components/Login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { useSetRecoilState } from 'recoil'; 3 | import { tokenState, userState } from '../../recoil/atoms/user'; 4 | import { login } from '../../utils/api/user'; 5 | import { AxiosError } from 'axios'; 6 | import FormInput from '../UserForm/FormInput'; 7 | import UserForm from '../UserForm/UserForm'; 8 | import FormButton from '../UserForm/FormButton'; 9 | import { useState } from 'react'; 10 | import styled from 'styled-components'; 11 | import Divider from '../Base/Divider'; 12 | import { Link } from 'react-router-dom'; 13 | import COLORS from '../../utils/colors'; 14 | import ErrorMessage from '../UserForm/ErrorMessage'; 15 | import ROUTES from '../../utils/routes'; 16 | import { ERROR_MESSAGES } from '../../utils/messages'; 17 | import useToast from '../../hooks/useToast'; 18 | import { loadingState } from '../../recoil/atoms/loading'; 19 | import { LOGIN_RULES } from '../../utils/formRules'; 20 | import { FORM_RULE_MESSAGE } from '../../utils/messages'; 21 | 22 | const RESPONSE_ERROR_MESSAGE = 23 | 'Your email and password combination does not match an account.'; 24 | 25 | const LoginForm = () => { 26 | const [errorMessage, setErrorMessage] = useState(''); 27 | const setUser = useSetRecoilState(userState); 28 | const setToken = useSetRecoilState(tokenState); 29 | const setLoading = useSetRecoilState(loadingState); 30 | const { showToast } = useToast(); 31 | 32 | const { 33 | handleSubmit, 34 | resetField, 35 | control, 36 | formState: { isSubmitting, isValid }, 37 | } = useForm({ 38 | mode: 'all', 39 | defaultValues: { 40 | email: '', 41 | password: '', 42 | }, 43 | }); 44 | 45 | const onSubmit = async (data: { email: string; password: string }) => { 46 | try { 47 | setLoading(true); 48 | const { user, token } = await login(data); 49 | setLoading(false); 50 | setErrorMessage(''); 51 | setUser(user); 52 | setToken(token); 53 | } catch (error) { 54 | setLoading(false); 55 | console.error(error); 56 | if (error instanceof AxiosError && error.response?.data) { 57 | if (error.response.data === RESPONSE_ERROR_MESSAGE) { 58 | setErrorMessage(FORM_RULE_MESSAGE.INCORRECT_LOGIN_INFO); 59 | } 60 | } else { 61 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 62 | } 63 | } 64 | }; 65 | 66 | return ( 67 | 68 | 75 | 84 | {errorMessage && ( 85 | 86 | {errorMessage} 87 | 88 | )} 89 | 95 | LOG IN 96 | 97 | 98 | 99 | 아직 회원이 아니신가요? 100 | SIGN UP 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default LoginForm; 107 | 108 | const SignUpLinkWrapper = styled.div` 109 | text-align: center; 110 | font-weight: 700; 111 | font-size: 1.4rem; 112 | line-height: 2rem; 113 | letter-spacing: -0.01em; 114 | 115 | color: ${COLORS.text}; 116 | `; 117 | 118 | const SignUpLink = styled(Link)` 119 | font-size: 1.6rem; 120 | margin-left: 1rem; 121 | color: ${COLORS.green}; 122 | text-decoration: underline; 123 | `; 124 | -------------------------------------------------------------------------------- /src/pages/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import LoginForm from '../components/Login/LoginForm'; 3 | import COLORS from '../utils/colors'; 4 | 5 | const bigLogo = require('../assets/images/logo/big.png'); 6 | 7 | const LoginPage = () => { 8 | return ( 9 | 10 | 11 | 12 | logo 13 | 14 | 15 | 16 | 17 |

Welcome!

18 | Let's dig dig deep 19 |
20 | 21 |
22 |
23 |
24 | ); 25 | }; 26 | 27 | export default LoginPage; 28 | 29 | const Background = styled.div` 30 | width: 100%; 31 | height: 100vh; 32 | background-color: ${COLORS.brownGray}; 33 | padding: 5rem 8rem; 34 | box-sizing: border-box; 35 | 36 | @media screen and (max-width: 767px) { 37 | padding: 0; 38 | } 39 | 40 | @media screen and (max-height: 767px) and (orientation: landscape) { 41 | height: 767px; 42 | overflow: hidden; 43 | } 44 | `; 45 | 46 | const Container = styled.div` 47 | border-radius: 57px; 48 | background-color: ${COLORS.bgColor}; 49 | display: flex; 50 | flex-direction: row; 51 | width: 100%; 52 | height: 100%; 53 | justify-content: space-around; 54 | 55 | @media screen and (max-width: 767px) and (orientation: portrait) { 56 | display: flex; 57 | justify-content: start; 58 | padding: 0 1.6rem; 59 | box-sizing: border-box; 60 | flex-direction: column; 61 | gap: 5rem; 62 | border-radius: 0; 63 | } 64 | 65 | @media screen and (max-width: 767px) { 66 | border-radius: 0; 67 | } 68 | `; 69 | 70 | const ImageWrapper = styled.div` 71 | text-align: center; 72 | width: 25%; 73 | flex-shrink: 0; 74 | box-sizing: border-box; 75 | display: flex; 76 | align-items: center; 77 | justify-content: center; 78 | 79 | @media screen and (max-width: 1200px) { 80 | width: 40%; 81 | } 82 | 83 | @media screen and (max-width: 767px) and (orientation: portrait) { 84 | margin-top: 8rem; 85 | background-color: inherit; 86 | flex-direction: column; 87 | gap: 5rem; 88 | width: 100%; 89 | padding: 0 6rem; 90 | } 91 | `; 92 | 93 | const Divider = styled.hr` 94 | background-color: ${COLORS.lightGray}; 95 | position: absolute; 96 | display: inline-block; 97 | width: 1px; 98 | /* height: 50rem; */ 99 | height: 45%; 100 | vertical-align: middle; 101 | border: none; 102 | 103 | position: absolute; 104 | left: 50%; 105 | top: 50%; 106 | transform: translate(-50%, -50%); 107 | 108 | @media screen and (max-width: 767px) and (orientation: portrait) { 109 | display: none; 110 | } 111 | 112 | @media screen and (max-height: 767px) and (orientation: landscape) { 113 | height: 383.5px; 114 | position: fixed; 115 | top: 383.5px; 116 | } 117 | `; 118 | 119 | const Image = styled.img` 120 | height: min-content; 121 | max-width: 100%; 122 | @media screen and (max-width: 767px) and (orientation: portrait) { 123 | width: 100%; 124 | } 125 | `; 126 | 127 | const TextWrapper = styled.div` 128 | display: block; 129 | @media screen and (max-width: 767px) and (orientation: portrait) { 130 | display: none; 131 | } 132 | `; 133 | 134 | const H1 = styled.h1` 135 | font-family: 'Inter', sans-serif; 136 | font-weight: 700; 137 | font-size: 4.8rem; 138 | line-height: 5.8rem; 139 | letter-spacing: -0.01em; 140 | 141 | color: ${COLORS.text}; 142 | `; 143 | 144 | const Text = styled.div` 145 | font-family: 'Inter', sans-serif; 146 | font-weight: 400; 147 | font-size: 3rem; 148 | line-height: 3.6rem; 149 | letter-spacing: -0.01em; 150 | 151 | color: ${COLORS.lightBrown}; 152 | margin-bottom: 5rem; 153 | `; 154 | 155 | const FormWrapper = styled.div` 156 | display: flex; 157 | flex-direction: column; 158 | justify-content: center; 159 | width: 25%; 160 | @media screen and (max-width: 1200px) { 161 | width: 40%; 162 | } 163 | @media screen and (max-width: 767px) and (orientation: portrait) { 164 | width: 100%; 165 | } 166 | `; 167 | -------------------------------------------------------------------------------- /src/components/Profile/ProfileEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { useForm } from 'react-hook-form'; 3 | import { useRecoilValue, useSetRecoilState } from 'recoil'; 4 | import { userState } from '../../recoil/atoms/user'; 5 | import { 6 | updatePassword, 7 | updateUserName, 8 | uploadPhoto, 9 | } from '../../utils/api/user'; 10 | import UserForm from '../UserForm/UserForm'; 11 | import FormButton from '../UserForm/FormButton'; 12 | import FormInput from '../UserForm/FormInput'; 13 | import useGetMyInfo from '../../hooks/useGetMyInfo'; 14 | import useToast from '../../hooks/useToast'; 15 | import ROUTES from '../../utils/routes'; 16 | import FormImageFile from '../UserForm/FormProfileImage'; 17 | import { loadingState } from '../../recoil/atoms/loading'; 18 | import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../../utils/messages'; 19 | import { PROFILE_EDIT_RULES } from '../../utils/formRules'; 20 | import { queryLowImage } from '../../utils/image'; 21 | 22 | const ProfileEditForm = () => { 23 | const user = useRecoilValue(userState); 24 | const setLoading = useSetRecoilState(loadingState); 25 | const getMyInfo = useGetMyInfo(); 26 | const { showToast } = useToast(); 27 | 28 | const navigate = useNavigate(); 29 | const { 30 | handleSubmit, 31 | resetField, 32 | watch, 33 | getValues, 34 | control, 35 | formState: { isSubmitting, isValid }, 36 | } = useForm({ 37 | mode: 'all', 38 | defaultValues: { 39 | email: user.email, 40 | fullName: user.fullName, 41 | password: '', 42 | confirmPassword: '', 43 | image: undefined, 44 | }, 45 | }); 46 | 47 | const onSubmit = async (data: { 48 | email: string; 49 | fullName: string; 50 | password: string; 51 | image?: File; 52 | }) => { 53 | const promises = []; 54 | 55 | if (data.image) { 56 | promises.push(uploadPhoto(data.image)); 57 | } 58 | if (getValues('fullName') !== user.fullName) { 59 | promises.push(updateUserName(data)); 60 | } 61 | 62 | if (getValues('password')) { 63 | promises.push(updatePassword(data)); 64 | } 65 | 66 | if (promises.length === 0) { 67 | showToast({ message: SUCCESS_MESSAGES.EDIT_SUCCESS('') }); 68 | navigate(ROUTES.PROFILE_ME); 69 | return; 70 | } 71 | 72 | setLoading(true); 73 | Promise.all(promises) 74 | .then(() => { 75 | getMyInfo(); 76 | setLoading(false); 77 | showToast({ message: SUCCESS_MESSAGES.EDIT_SUCCESS('') }); 78 | navigate(ROUTES.PROFILE_ME); 79 | }) 80 | .catch((error) => { 81 | setLoading(false); 82 | console.error(error); 83 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 84 | }); 85 | }; 86 | 87 | return ( 88 | 89 | 94 | 101 | 107 | 116 | 125 | 131 | COMPLETE 132 | 133 | 134 | ); 135 | }; 136 | 137 | export default ProfileEditForm; 138 | -------------------------------------------------------------------------------- /src/components/Comment/CommentInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useState } from 'react'; 2 | import { useRecoilValue } from 'recoil'; 3 | import styled from 'styled-components'; 4 | import useGetMyInfo from '../../hooks/useGetMyInfo'; 5 | import useToast from '../../hooks/useToast'; 6 | import { tokenState } from '../../recoil/atoms/user'; 7 | import COLORS from '../../utils/colors'; 8 | import { createComment } from '../../utils/api/comment'; 9 | import { sendNotification } from '../../utils/api/notification'; 10 | import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../../utils/messages'; 11 | import type { PostResponse } from '../../types/api/post'; 12 | 13 | interface CommentInputProps extends Pick { 14 | fetchHandler?: () => Promise; 15 | } 16 | 17 | interface FormProps { 18 | children: ReactNode[]; 19 | isFocus: boolean; 20 | onSubmit: (e: React.FormEvent) => void; 21 | } 22 | 23 | const CommentInput = ({ _id, author, fetchHandler }: CommentInputProps) => { 24 | const [comment, setComment] = useState(''); 25 | const { showToast } = useToast(); 26 | const getMyInfo = useGetMyInfo(); 27 | const token = useRecoilValue(tokenState); 28 | const [isFocus, setIsFocus] = useState(false); 29 | 30 | const onInputFocus = () => setIsFocus(true); 31 | 32 | const onInputBlur = () => setIsFocus(false); 33 | 34 | const onChange = (e: React.ChangeEvent) => { 35 | setComment(e.target.value); 36 | }; 37 | 38 | const onSubmit = async ( 39 | e: React.FormEvent, 40 | content: string, 41 | postId: string 42 | ) => { 43 | e.preventDefault(); 44 | if (!token) return showToast({ message: ERROR_MESSAGES.REQUIRE_LOGIN }); 45 | if (!comment) 46 | return showToast({ message: ERROR_MESSAGES.REQUIRE_INPUT('내용을') }); 47 | try { 48 | const data = await createComment(content, postId); 49 | await getMyInfo(); 50 | sendNotification('COMMENT', data._id, author._id, postId); 51 | if (fetchHandler) fetchHandler(); 52 | showToast({ message: SUCCESS_MESSAGES.CREATE_COMMENT_SUCCESS }); 53 | setComment(''); 54 | } catch (error) { 55 | console.error(ERROR_MESSAGES.CREATE_ERROR('댓글'), error); 56 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 57 | } 58 | }; 59 | 60 | return ( 61 | 62 |
onSubmit(e, comment, _id)}> 63 | 71 | 72 |
73 |
74 | ); 75 | }; 76 | 77 | export default CommentInput; 78 | 79 | const Container = styled.div` 80 | background-color: ${COLORS.white}; 81 | width: 35%; 82 | min-width: 350px; 83 | position: fixed; 84 | bottom: 0; 85 | padding-top: 1.3rem; 86 | padding-bottom: 1.5rem; 87 | border-radius: 1.4rem 1.4rem 0 0; 88 | box-shadow: 0px -4px 5px rgba(164, 164, 164, 0.198); 89 | 90 | @media screen and (max-width: 767px) and (orientation: portrait) { 91 | width: 100%; 92 | min-width: 0; 93 | } 94 | `; 95 | 96 | const Form = styled.form` 97 | background-color: ${COLORS.white}; 98 | width: 90%; 99 | margin: 0 auto; 100 | display: flex; 101 | padding: 1.2rem 1rem; 102 | border-radius: 23.5px; 103 | align-items: center; 104 | justify-content: space-between; 105 | gap: 1.3rem; 106 | border: 1px solid; 107 | box-sizing: border-box; 108 | border-color: ${({ isFocus }) => 109 | isFocus ? COLORS.lightBrown : COLORS.lightGray}; 110 | `; 111 | 112 | const Input = styled.input` 113 | width: 100%; 114 | letter-spacing: -0.01em; 115 | color: ${COLORS.lightBrown}; 116 | font-weight: 500; 117 | font-size: 1.4rem; 118 | padding: 0; 119 | padding-left: 1.7rem; 120 | 121 | ::placeholder { 122 | color: ${COLORS.brownGray}; 123 | line-height: 1rem; 124 | font-weight: 400; 125 | } 126 | `; 127 | 128 | const Button = styled.button` 129 | width: 5rem; 130 | font-weight: 700; 131 | font-size: 1.4rem; 132 | letter-spacing: -0.01em; 133 | color: ${COLORS.brown}; 134 | cursor: pointer; 135 | 136 | @media screen and (max-width: 767px) and (orientation: portrait) { 137 | padding-right: 1rem; 138 | } 139 | `; 140 | -------------------------------------------------------------------------------- /src/components/Comment/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { useRecoilValue } from 'recoil'; 3 | import styled from 'styled-components'; 4 | import useModal from '../../hooks/useModal'; 5 | import { userState } from '../../recoil/atoms/user'; 6 | import COLORS from '../../utils/colors'; 7 | import { deleteComment } from '../../utils/api/comment'; 8 | import { formatDate } from '../../utils/formatDate'; 9 | import useGetMyInfo from '../../hooks/useGetMyInfo'; 10 | import useToast from '../../hooks/useToast'; 11 | import Divider from './../Base/Divider'; 12 | import Icon from './../Base/Icon'; 13 | import ROUTES from '../../utils/routes'; 14 | import { 15 | CONFIRM_MESSAGES, 16 | ERROR_MESSAGES, 17 | SUCCESS_MESSAGES, 18 | } from '../../utils/messages'; 19 | import type { CommentResponse } from '../../types/api/comment'; 20 | 21 | interface CommentProps extends CommentResponse { 22 | fetchHandler?: () => Promise; 23 | } 24 | 25 | interface ContainerProps { 26 | isMyComment?: boolean; 27 | } 28 | 29 | const Comment = ({ 30 | _id, 31 | comment, 32 | author, 33 | createdAt, 34 | fetchHandler, 35 | ...props 36 | }: CommentProps) => { 37 | const user = useRecoilValue(userState); 38 | const navigate = useNavigate(); 39 | const { showModal } = useModal(); 40 | const { showToast } = useToast(); 41 | const getMyInfo = useGetMyInfo(); 42 | const isMyComment = author._id === user._id; 43 | 44 | const toUserProfile = () => { 45 | navigate(ROUTES.PROFILE_BY_USER_ID(author._id)); 46 | }; 47 | 48 | const handleDeleteComment = async () => { 49 | showModal({ 50 | message: CONFIRM_MESSAGES.DELETE_CONFIRM, 51 | handleConfirm: async () => { 52 | try { 53 | await deleteComment(_id); 54 | await getMyInfo(); 55 | if (fetchHandler) fetchHandler(); 56 | showToast({ message: SUCCESS_MESSAGES.DELETE_COMMENT_SUCCESS }); 57 | } catch (error) { 58 | console.error(error, ERROR_MESSAGES.DELETE_ERROR('댓글')); 59 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 60 | } 61 | }, 62 | }); 63 | }; 64 | 65 | return ( 66 | 67 | 68 | 69 | 70 | {author.image ? ( 71 | 72 | ) : ( 73 | 74 | )} 75 | {author.fullName} 76 | 77 | {formatDate.fullDate(createdAt)} 78 | 79 | 80 | {isMyComment && ( 81 | 84 | )} 85 | 86 | 87 |
88 | {comment} 89 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | const Container = styled.div` 96 | background-color: ${COLORS.white}; 97 | border-bottom: 0.5px solid #e9e9e9; 98 | `; 99 | 100 | const CommentWrapper = styled.div` 101 | padding: 1.5rem 2.4rem; 102 | margin: 0 auto; 103 | `; 104 | 105 | const CommentHeader = styled.div` 106 | display: flex; 107 | align-items: center; 108 | justify-content: space-between; 109 | `; 110 | 111 | const Section = styled.div` 112 | margin-top: 1.5rem; 113 | `; 114 | 115 | const Text = styled.div` 116 | font-weight: 500; 117 | font-size: 1.4rem; 118 | line-height: 2.1rem; 119 | letter-spacing: -0.01em; 120 | color: ${COLORS.text}; 121 | padding: 0 0.2rem; 122 | margin: 0 auto; 123 | `; 124 | 125 | const ProfileImage = styled.img` 126 | width: 2.5rem; 127 | height: 2.5rem; 128 | border-radius: 50%; 129 | margin-right: 0.4rem; 130 | border: 0.5px solid ${COLORS.lightGray}; 131 | object-fit: cover; 132 | -webkit-user-drag: none; 133 | -khtml-user-drag: none; 134 | -moz-user-drag: none; 135 | -o-user-drag: none; 136 | `; 137 | 138 | const Wrapper = styled.div` 139 | display: flex; 140 | align-items: center; 141 | gap: 0.8rem; 142 | cursor: pointer; 143 | `; 144 | 145 | const UserName = styled.span` 146 | font-weight: 500; 147 | font-size: 1.2rem; 148 | letter-spacing: -0.01em; 149 | color: ${({ isMyComment }) => (isMyComment ? '#76a727' : COLORS.lightBrown)}; 150 | `; 151 | 152 | const Date = styled.span` 153 | font-weight: 400; 154 | font-size: 1.1rem; 155 | letter-spacing: -0.01em; 156 | color: ${COLORS.date}; 157 | `; 158 | 159 | const Button = styled.button` 160 | cursor: pointer; 161 | `; 162 | 163 | export default Comment; 164 | -------------------------------------------------------------------------------- /src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import COLORS from '../../utils/colors'; 5 | import Icon from './../Base/Icon'; 6 | import Searchbar from './Searchbar'; 7 | import ROUTES from '../../utils/routes'; 8 | import LinkButtons from './LinkButtons'; 9 | 10 | const longLogo = require('../../assets/images/logo/long.png'); 11 | const smallLogo = require('../../assets/images/logo/small.png'); 12 | 13 | const Header = () => { 14 | const [isSearchbarShow, setIsSearchbarShow] = useState(false); 15 | const [isMobile, setIsMobile] = useState(false); 16 | 17 | const onSearchbar = () => { 18 | setIsSearchbarShow(true); 19 | }; 20 | 21 | const offSearchbar = () => { 22 | setIsSearchbarShow(false); 23 | }; 24 | 25 | const resizeScreen = () => { 26 | if (window.innerWidth < 767) { 27 | setIsMobile(true); 28 | } else { 29 | setIsMobile(false); 30 | setIsSearchbarShow(false); 31 | } 32 | }; 33 | 34 | const checkIsMobile = () => { 35 | if (window.innerWidth < 767) { 36 | setIsMobile(true); 37 | } else { 38 | setIsMobile(false); 39 | } 40 | }; 41 | 42 | const handleChange = useCallback(() => { 43 | const handleSetTimeout = setTimeout(() => { 44 | offSearchbar(); 45 | clearTimeout(handleSetTimeout); 46 | }, 300); 47 | }, []); 48 | 49 | useEffect(() => { 50 | checkIsMobile(); 51 | 52 | window.addEventListener('scroll', handleChange); 53 | window.addEventListener('resize', resizeScreen); 54 | return () => { 55 | window.removeEventListener('scroll', handleChange); 56 | window.removeEventListener('resize', resizeScreen); 57 | }; 58 | }, [handleChange]); 59 | 60 | if (isMobile) 61 | return ( 62 | 63 | 64 | {isSearchbarShow ? ( 65 | 66 | 70 | 71 | ) : ( 72 | <> 73 | 74 | 75 | 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | 89 | 90 | ); 91 | 92 | return ( 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | ); 114 | }; 115 | 116 | const HeaderContainer = styled.div` 117 | width: 100%; 118 | background-color: ${COLORS.bgColor}; 119 | position: sticky; 120 | z-index: 1; 121 | top: 0; 122 | box-sizing: border-box; 123 | `; 124 | 125 | const Container = styled.div` 126 | width: 100%; 127 | display: flex; 128 | justify-content: space-between; 129 | margin: 0 auto; 130 | padding: 3rem 2.7rem; 131 | z-index: 1; 132 | height: 5rem; 133 | align-items: center; 134 | border-bottom: 1px solid #eeeeee; 135 | box-sizing: border-box; 136 | 137 | @media screen and (max-width: 767px) and (orientation: portrait) { 138 | display: flex; 139 | z-index: 1; 140 | padding: 0 2rem; 141 | justify-content: space-between; 142 | height: 6rem; 143 | align-items: center; 144 | } 145 | `; 146 | 147 | const Wrapper = styled.div` 148 | display: flex; 149 | justify-content: space-around; 150 | align-items: center; 151 | padding: 1.8rem 1rem; 152 | gap: 2rem; 153 | 154 | @media screen and (max-width: 767px) and (orientation: portrait) { 155 | gap: 1.5rem; 156 | } 157 | `; 158 | 159 | const SearchWrapper = styled.div<{ isMobile: boolean }>` 160 | ${({ isMobile }) => 161 | isMobile 162 | ? `width: 100%; 163 | padding: 1.8rem 0;` 164 | : ` 165 | display: flex; 166 | justify-content: space-around; 167 | align-items: center; 168 | gap: 3rem; 169 | `} 170 | `; 171 | 172 | const Button = styled.div` 173 | cursor: pointer; 174 | `; 175 | 176 | const LogoButton = styled(Link)``; 177 | 178 | const LogoWrapper = styled.span``; 179 | 180 | const Logo = styled.img` 181 | width: 15rem; 182 | height: 100%; 183 | content: url(${longLogo}); 184 | -webkit-user-drag: none; 185 | -khtml-user-drag: none; 186 | -moz-user-drag: none; 187 | -o-user-drag: none; 188 | 189 | @media screen and (max-width: 767px) and (orientation: portrait) { 190 | width: 3.4rem; 191 | height: 3.4rem; 192 | content: url(${smallLogo}); 193 | } 194 | `; 195 | 196 | export default Header; 197 | -------------------------------------------------------------------------------- /src/pages/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react'; 2 | import { useNavigate, useParams } from 'react-router-dom'; 3 | import { useRecoilValue } from 'recoil'; 4 | import styled from 'styled-components'; 5 | import FollowButton from '../components/Follow/FollowButton'; 6 | import FollowList from '../components/Follow/FollowList'; 7 | import PostList from '../components/Post/PostList'; 8 | import TabItem from '../components/Profile/TabItem'; 9 | import DetailHeader from '../components/Header/DetailHeader'; 10 | import { userState } from '../recoil/atoms/user'; 11 | import { getUser } from '../utils/api/user'; 12 | import COLORS from '../utils/colors'; 13 | import Icon from '../components/Base/Icon'; 14 | import ROUTES from '../utils/routes'; 15 | import Image from '../components/Base/Image'; 16 | import { queryLowImage } from '../utils/image'; 17 | import type { UserResponse } from '../types/api/user'; 18 | 19 | const defaultProfile = require('../assets/images/icon/default-profile.png'); 20 | 21 | type TabMenuItem = keyof Pick< 22 | UserResponse, 23 | 'posts' | 'followers' | 'following' 24 | >; 25 | 26 | const tabMenuItems: TabMenuItem[] = ['posts', 'followers', 'following']; 27 | 28 | const ProfilePage = () => { 29 | const { userId } = useParams() as { userId: string }; 30 | const { _id: myId } = useRecoilValue(userState); 31 | const [userInfo, setUserInfo] = useState({} as UserResponse); 32 | const profileImage = useMemo(() => { 33 | return userInfo.image 34 | ? queryLowImage(userInfo.image, 'profile') 35 | : defaultProfile; 36 | }, [userInfo]); 37 | const navigate = useNavigate(); 38 | 39 | const [activeTab, setActiveTab] = useState('posts'); 40 | 41 | const fetchUser = useCallback(async () => { 42 | const user = await getUser(userId === 'me' ? myId : userId); 43 | setUserInfo(user); 44 | }, [userId, myId]); 45 | 46 | useEffect(() => { 47 | fetchUser(); 48 | }, [fetchUser]); 49 | 50 | const renderTabContent = () => { 51 | switch (activeTab) { 52 | case 'posts': 53 | return ; 54 | case 'followers': 55 | return ( 56 | 61 | ); 62 | case 'following': 63 | return ( 64 | 69 | ); 70 | default: 71 | return null; 72 | } 73 | }; 74 | 75 | return ( 76 | <> 77 | 78 | 79 | {userId === 'me' ? ( 80 | 81 | 84 | 87 | 88 | ) : ( 89 | 90 | )} 91 | 92 | 93 | 94 | 95 | {userInfo?.fullName} 96 | 97 | {userInfo?.fullName} 98 | 99 | {tabMenuItems.map((item) => { 100 | return ( 101 | 102 | setActiveTab(item)} 107 | /> 108 | 109 | ); 110 | })} 111 | 112 | 113 | {renderTabContent()} 114 | 115 | ); 116 | }; 117 | 118 | export default ProfilePage; 119 | 120 | const TabList = styled.div` 121 | display: flex; 122 | width: 90%; 123 | background-color: ${COLORS.bgColor}; 124 | justify-content: space-between; 125 | margin-top: 2rem; 126 | `; 127 | 128 | const TabItemContainer = styled.span` 129 | display: inline-block; 130 | text-align: center; 131 | `; 132 | 133 | const Wrapper = styled.div` 134 | display: flex; 135 | flex-direction: column; 136 | align-items: center; 137 | `; 138 | 139 | const Container = styled.div` 140 | display: flex; 141 | flex-direction: column; 142 | align-items: center; 143 | position: relative; 144 | width: 35%; 145 | min-width: 350px; 146 | margin: 0 auto; 147 | margin-bottom: 1.5rem; 148 | 149 | @media screen and (max-width: 767px) and (orientation: portrait) { 150 | width: 100%; 151 | } 152 | `; 153 | 154 | const ImageContainer = styled.div` 155 | position: relative; 156 | width: 7rem; 157 | height: 7rem; 158 | border-radius: 50%; 159 | overflow: hidden; 160 | `; 161 | 162 | const Name = styled.h1` 163 | font-weight: 500; 164 | font-size: 1.7rem; 165 | color: ${COLORS.lightBrown}; 166 | margin: 2rem 0 2.5rem; 167 | `; 168 | 169 | const ButtonContainer = styled.div` 170 | display: block; 171 | `; 172 | 173 | const Button = styled.button` 174 | cursor: pointer; 175 | display: inline-block; 176 | 177 | :not(:first-child) { 178 | margin-left: 1.5rem; 179 | } 180 | `; 181 | -------------------------------------------------------------------------------- /src/components/Header/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useEffect, useState, useRef } from 'react'; 2 | import { useCallback } from 'react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import styled from 'styled-components'; 5 | import COLORS from '../../utils/colors'; 6 | import ROUTES from '../../utils/routes'; 7 | import Divider from './../Base/Divider'; 8 | import Icon from './../Base/Icon'; 9 | import useToast from '../../hooks/useToast'; 10 | import { ERROR_MESSAGES } from '../../utils/messages'; 11 | import { useRecoilState } from 'recoil'; 12 | import { SelectOption, searchState } from '../../recoil/atoms/search'; 13 | 14 | const events = ['mousedown', 'touchstart'] as const; 15 | 16 | const selectOptions: SelectOption[] = [ 17 | { 18 | label: '그라운드', 19 | value: 'posts', 20 | }, 21 | { 22 | label: '사용자', 23 | value: 'users', 24 | }, 25 | ]; 26 | 27 | interface FormProps { 28 | children: ReactNode[]; 29 | isFocus: boolean; 30 | visible?: boolean; 31 | onSubmit: (e: React.FormEvent) => void; 32 | } 33 | const Searchbar = ({ 34 | isMobile, 35 | setIsSearchbarShow, 36 | }: { 37 | isMobile: boolean; 38 | setIsSearchbarShow: React.Dispatch>; 39 | }) => { 40 | const [search, setSearchState] = useRecoilState(searchState); 41 | const [isFocus, setIsFocus] = useState(false); 42 | const [visible, setVisible] = useState(isMobile ? false : true); 43 | const ref = useRef(null); 44 | const navigate = useNavigate(); 45 | 46 | const { showToast } = useToast(); 47 | 48 | const onChange = (e: React.ChangeEvent) => { 49 | setSearchState({ 50 | ...search, 51 | value: e.target.value, 52 | }); 53 | }; 54 | 55 | const handleSelect = (e: React.ChangeEvent) => { 56 | const selectOption = selectOptions.find( 57 | (option) => option.value === e.target.value 58 | ); 59 | if (selectOption) { 60 | setSearchState({ 61 | ...search, 62 | options: selectOption, 63 | }); 64 | } 65 | }; 66 | 67 | const handleReset = () => { 68 | setSearchState({ 69 | ...search, 70 | value: '', 71 | }); 72 | }; 73 | 74 | const onSubmit = (e: React.FormEvent) => { 75 | e.preventDefault(); 76 | if (search.value) { 77 | return navigate( 78 | ROUTES.SEARCH_BY_QUERY(search.value, search.options.value) 79 | ); 80 | } 81 | showToast({ message: ERROR_MESSAGES.SEARCH_INPUT }); 82 | }; 83 | 84 | const onInputBlur = () => setIsFocus(false); 85 | 86 | const onInputFocus = () => setIsFocus(true); 87 | 88 | const handleEvent = useCallback( 89 | (e: MouseEvent | TouchEvent) => { 90 | if (!ref.current) return; 91 | if (!isMobile) return; 92 | if (!(e.target instanceof Node)) return; 93 | 94 | if (!ref.current.contains(e.target)) { 95 | setVisible(false); 96 | const timeout = setTimeout(() => { 97 | setIsSearchbarShow(false); 98 | clearTimeout(timeout); 99 | }, 300); 100 | } 101 | }, 102 | [setIsSearchbarShow, isMobile] 103 | ); 104 | 105 | useEffect(() => { 106 | setVisible(true); 107 | 108 | for (const event of events) { 109 | window.addEventListener(event, handleEvent); 110 | } 111 | 112 | const onScroll = () => { 113 | if (isMobile) { 114 | setVisible(false); 115 | } 116 | }; 117 | 118 | window.addEventListener('scroll', onScroll); 119 | 120 | return () => { 121 | window.removeEventListener('scroll', onScroll); 122 | for (const event of events) { 123 | window.removeEventListener(event, handleEvent); 124 | } 125 | }; 126 | }, [isMobile, handleEvent]); 127 | 128 | return ( 129 |
130 | 133 | 141 | {search.value && ( 142 | 145 | )} 146 | 147 | 154 | 155 | ); 156 | }; 157 | 158 | const Form = styled.form` 159 | width: 100%; 160 | display: flex; 161 | background: ${COLORS.white}; 162 | min-width: 250px; 163 | border-radius: 23.5px; 164 | align-items: center; 165 | justify-content: space-between; 166 | gap: 1.3rem; 167 | padding: 1.3rem 1.8rem; 168 | border: 1px solid; 169 | box-sizing: border-box; 170 | border-color: ${({ isFocus }) => 171 | isFocus ? COLORS.lightBrown : COLORS.lightGray}; 172 | width: ${({ visible }) => (visible ? '100%' : 0)}; 173 | opacity: ${({ visible }) => (visible ? 1 : 0)}; 174 | transition: all 0.4s ease-out; 175 | 176 | @media screen and (max-width: 767px) and (orientation: portrait) { 177 | box-shadow: 0px 2px 4px rgba(146, 113, 96, 0.11); 178 | padding: 1.4rem 1.8rem; 179 | min-width: 0; 180 | margin: 0; 181 | } 182 | `; 183 | 184 | const Button = styled.button` 185 | width: 1.3rem; 186 | height: 1.3rem; 187 | margin-left: 0.4rem; 188 | cursor: pointer; 189 | `; 190 | 191 | const Input = styled.input` 192 | width: 100%; 193 | border: none; 194 | outline: none; 195 | letter-spacing: -0.01em; 196 | color: ${COLORS.lightBrown}; 197 | font-weight: 400; 198 | font-size: 1.4rem; 199 | line-height: 1.6rem; 200 | padding: 0; 201 | 202 | ::placeholder { 203 | color: ${COLORS.brownGray}; 204 | line-height: 1.3rem; 205 | } 206 | `; 207 | 208 | const Select = styled.select` 209 | border: none; 210 | font-size: 1.2rem; 211 | color: ${COLORS.lightBrown}; 212 | outline: none; 213 | `; 214 | 215 | const Option = styled.option``; 216 | 217 | export default Searchbar; 218 | -------------------------------------------------------------------------------- /src/components/Post/PostEdit.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import COLORS from '../../utils/colors'; 4 | import Icon from './../Base/Icon'; 5 | import { getPost } from '../../utils/api/post'; 6 | import { ERROR_MESSAGES } from '../../utils/messages'; 7 | import useToast from '../../hooks/useToast'; 8 | 9 | interface Props { 10 | name?: string; 11 | postId?: string; 12 | hasId: boolean; 13 | title: string; 14 | body: string; 15 | image?: Blob | null; 16 | setTitle: (v: string) => void; 17 | setBody: (v: string) => void; 18 | setImage: (v: Blob | null) => void; 19 | setImageId: (v: string) => void; 20 | handleTitle: (e: React.ChangeEvent) => void; 21 | handleBody: (e: React.ChangeEvent) => void; 22 | handleImage: (e: React.ChangeEvent) => void; 23 | } 24 | 25 | const PostEdit = ({ 26 | name, 27 | postId, 28 | hasId, 29 | title, 30 | body, 31 | image, 32 | setTitle, 33 | setBody, 34 | setImage, 35 | setImageId, 36 | handleTitle, 37 | handleBody, 38 | handleImage, 39 | }: Props) => { 40 | const [previewImage, setPreviewImage] = useState(''); 41 | const { showToast } = useToast(); 42 | 43 | const fetchPost = useCallback(async () => { 44 | try { 45 | if (postId) { 46 | const { title, image, imagePublicId } = await getPost(postId); 47 | const data = JSON.parse(title); 48 | setTitle(data.title); 49 | setBody(data.body); 50 | image && setPreviewImage(image); 51 | imagePublicId && setImageId(imagePublicId); 52 | } 53 | } catch (error) { 54 | showToast({ message: ERROR_MESSAGES.GET_ERROR('그라운드를') }); 55 | } 56 | }, [postId, setTitle, setBody, setPreviewImage, setImageId, showToast]); 57 | 58 | const handleRemoveImage = () => { 59 | if (previewImage) { 60 | setPreviewImage(''); 61 | } 62 | setImage(null); 63 | }; 64 | 65 | useEffect(() => { 66 | if (postId) { 67 | fetchPost(); 68 | } 69 | }, [postId, fetchPost]); 70 | 71 | return ( 72 | 73 | 78 | <Body 79 | onChange={handleBody} 80 | placeholder="내용을 입력해주세요..." 81 | value={body} 82 | /> 83 | <Wrapper> 84 | <Button> 85 | <Label htmlFor="file"> 86 | <Icon 87 | name="add" 88 | size={37} 89 | style={{ 90 | filter: 'drop-shadow(0px 2px 4px rgba(191, 176, 168, 0.5))', 91 | }} 92 | /> 93 | </Label> 94 | </Button> 95 | <Input id="file" type="file" accept="image/*" onChange={handleImage} /> 96 | </Wrapper> 97 | {previewImage && !image && ( 98 | <ImageFileWrapper> 99 | <ImageFile src={previewImage} alt="첨부 이미지" /> 100 | <ImageDetail> 101 | <ImageHeader> 102 | <ImageTitle>기존 이미지</ImageTitle> 103 | <RemoveButton onClick={handleRemoveImage}> 104 | <Icon name="close" size={12} /> 105 | </RemoveButton> 106 | </ImageHeader> 107 | <ImageName>{name}</ImageName> 108 | </ImageDetail> 109 | </ImageFileWrapper> 110 | )} 111 | {image && ( 112 | <ImageFileWrapper> 113 | <ImageFile src={URL.createObjectURL(image)} alt="첨부 이미지" /> 114 | <ImageDetail> 115 | <ImageHeader> 116 | <ImageTitle>첨부 파일</ImageTitle> 117 | <RemoveButton onClick={handleRemoveImage}> 118 | <Icon name="close" size={12} /> 119 | </RemoveButton> 120 | </ImageHeader> 121 | <ImageName>{name}</ImageName> 122 | </ImageDetail> 123 | </ImageFileWrapper> 124 | )} 125 | </Container> 126 | ); 127 | }; 128 | 129 | export default PostEdit; 130 | 131 | const Container = styled.div` 132 | width: 35%; 133 | min-width: 350px; 134 | height: 90vh; 135 | background: ${COLORS.white}; 136 | position: relative; 137 | display: flex; 138 | flex-direction: column; 139 | align-items: center; 140 | gap: 2rem; 141 | box-shadow: 0px -1px 4px rgba(210, 210, 210, 0.25); 142 | 143 | @media screen and (max-width: 767px) and (orientation: portrait) { 144 | width: 100%; 145 | height: 92vh; 146 | gap: 1rem; 147 | } 148 | `; 149 | 150 | const Title = styled.textarea` 151 | width: 90%; 152 | font-weight: 500; 153 | font-size: 2.7rem; 154 | line-height: 3.5rem; 155 | letter-spacing: -0.01em; 156 | color: ${COLORS.brown}; 157 | margin-top: 3rem; 158 | 159 | ::placeholder { 160 | color: ${COLORS.brownGray}; 161 | font-weight: 400; 162 | } 163 | 164 | @media screen and (max-width: 767px) and (orientation: portrait) { 165 | width: 86%; 166 | font-size: 2.2rem; 167 | margin-top: 2rem; 168 | } 169 | `; 170 | 171 | const Body = styled.textarea` 172 | width: 90%; 173 | height: 69%; 174 | font-weight: 400; 175 | font-size: 1.8rem; 176 | line-height: 2.3rem; 177 | letter-spacing: -0.01em; 178 | color: ${COLORS.brown}; 179 | 180 | ::placeholder { 181 | color: ${COLORS.brownGray}; 182 | font-weight: 400; 183 | } 184 | 185 | @media screen and (max-width: 767px) and (orientation: portrait) { 186 | width: 86%; 187 | font-size: 1.6rem; 188 | } 189 | `; 190 | 191 | const Button = styled.button` 192 | position: absolute; 193 | right: 3rem; 194 | bottom: 2rem; 195 | z-index: 11; 196 | 197 | @media screen and (max-width: 767px) and (orientation: portrait) { 198 | right: 2rem; 199 | } 200 | `; 201 | 202 | const Label = styled.label` 203 | cursor: pointer; 204 | `; 205 | 206 | const Input = styled.input` 207 | display: none; 208 | `; 209 | 210 | const Wrapper = styled.div``; 211 | 212 | const ImageFileWrapper = styled.div` 213 | position: absolute; 214 | left: 3rem; 215 | bottom: 2.5rem; 216 | background-color: ${COLORS.white}; 217 | width: fit-content; 218 | height: 8rem; 219 | padding: 0 1.5rem; 220 | border: 0.2px solid rgba(191, 176, 168, 0.6); 221 | border-radius: 8px; 222 | box-shadow: 0px 2px 3px 1px rgba(219, 219, 219, 0.37); 223 | display: flex; 224 | justify-content: center; 225 | align-items: center; 226 | gap: 1.5rem; 227 | z-index: 10; 228 | 229 | @media screen and (max-width: 767px) and (orientation: portrait) { 230 | left: 2.5rem; 231 | height: 7.5rem; 232 | } 233 | `; 234 | 235 | const ImageFile = styled.img` 236 | width: 5.5rem; 237 | height: 5.5rem; 238 | border-radius: 10px; 239 | object-fit: cover; 240 | 241 | @media screen and (max-width: 767px) and (orientation: portrait) { 242 | width: 5rem; 243 | height: 5rem; 244 | } 245 | `; 246 | 247 | const ImageDetail = styled.div` 248 | display: flex; 249 | flex-direction: column; 250 | justify-content: center; 251 | gap: 0.8rem; 252 | `; 253 | 254 | const ImageHeader = styled.div` 255 | display: flex; 256 | align-items: center; 257 | justify-content: space-between; 258 | `; 259 | 260 | const ImageTitle = styled.h3` 261 | font-size: 1.4rem; 262 | font-weight: 700; 263 | color: ${COLORS.lightBrown}; 264 | 265 | @media screen and (max-width: 767px) and (orientation: portrait) { 266 | font-size: 1.2rem; 267 | } 268 | `; 269 | 270 | const RemoveButton = styled.button` 271 | display: flex; 272 | margin: 0 0.5rem; 273 | `; 274 | 275 | const ImageName = styled.span` 276 | font-size: 1.4rem; 277 | color: ${COLORS.lightBrown}; 278 | max-width: 17rem; 279 | overflow: hidden; 280 | white-space: nowrap; 281 | text-overflow: ellipsis; 282 | 283 | @media screen and (max-width: 767px) and (orientation: portrait) { 284 | font-size: 1.2rem; 285 | max-width: 8rem; 286 | } 287 | `; 288 | -------------------------------------------------------------------------------- /src/components/Post/Post.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useRecoilValue } from 'recoil'; 4 | import styled from 'styled-components'; 5 | import useGetMyInfo from '../../hooks/useGetMyInfo'; 6 | import useModal from '../../hooks/useModal'; 7 | import useToast from '../../hooks/useToast'; 8 | import { tokenState, userState } from '../../recoil/atoms/user'; 9 | import COLORS from '../../utils/colors'; 10 | import { formatDate } from '../../utils/formatDate'; 11 | import { createLike, deleteLike } from '../../utils/api/like'; 12 | import { sendNotification } from '../../utils/api/notification'; 13 | import { deletePost } from '../../utils/api/post'; 14 | import ROUTES from '../../utils/routes'; 15 | import Divider from './../Base/Divider'; 16 | import Icon from './../Base/Icon'; 17 | import Image from '../Base/Image'; 18 | import { 19 | CONFIRM_MESSAGES, 20 | ERROR_MESSAGES, 21 | SUCCESS_MESSAGES, 22 | } from '../../utils/messages'; 23 | import { queryLowImage } from '../../utils/image'; 24 | import PostContent from './PostContent'; 25 | import type { PostResponse } from '../../types/api/post'; 26 | 27 | interface PostProps extends PostResponse { 28 | checkIsMine?: boolean; 29 | isDetailPage?: boolean; 30 | isMyLikes?: boolean; 31 | } 32 | 33 | interface FlexContainerProps { 34 | direction?: 'row' | 'column'; 35 | color?: string; 36 | isMyLikes?: boolean; 37 | } 38 | 39 | interface ImageContainerProps { 40 | width?: string; 41 | height?: string; 42 | isMyLikes?: boolean; 43 | } 44 | 45 | const Post = ({ 46 | _id, 47 | title, 48 | createdAt, 49 | author, 50 | likes, 51 | comments, 52 | image, 53 | checkIsMine = false, 54 | isDetailPage = false, 55 | isMyLikes = false, 56 | ...props 57 | }: PostProps) => { 58 | const user = useRecoilValue(userState); 59 | const token = useRecoilValue(tokenState); 60 | const [likesState, setLikesState] = useState(likes); 61 | const { showToast } = useToast(); 62 | const { showModal } = useModal(); 63 | const getMyInfo = useGetMyInfo(); 64 | const navigate = useNavigate(); 65 | 66 | const handleShare = () => { 67 | navigator.clipboard.writeText(`${window.location.host}/posts/${_id}`); 68 | showToast({ message: SUCCESS_MESSAGES.SHARE_SUCCESS }); 69 | }; 70 | 71 | const toUserProfile = () => { 72 | const userId = author._id === user._id ? 'me' : author._id; 73 | navigate(ROUTES.PROFILE_BY_USER_ID(userId)); 74 | }; 75 | 76 | const toPostDetail = () => { 77 | navigate(ROUTES.POSTS_BY_ID(_id)); 78 | }; 79 | 80 | const handleDelete = async () => { 81 | showModal({ 82 | message: CONFIRM_MESSAGES.DELETE_CONFIRM, 83 | handleConfirm: async () => { 84 | try { 85 | await deletePost(_id); 86 | navigate(ROUTES.HOME); 87 | showToast({ message: SUCCESS_MESSAGES.DELETE_SUCCESS('그라운드가') }); 88 | } catch (error) { 89 | console.error(error); 90 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 91 | } 92 | }, 93 | }); 94 | }; 95 | 96 | const handleEdit = () => { 97 | navigate(ROUTES.POSTS_EDIT_BY_ID(_id)); 98 | }; 99 | 100 | const handleLike = async () => { 101 | const isLike = likesState.findIndex((like) => like.user === user._id); 102 | if (!token) { 103 | return showToast({ message: ERROR_MESSAGES.REQUIRE_LOGIN }); 104 | } 105 | 106 | if (isLike === -1) { 107 | try { 108 | const { data } = await createLike(_id); 109 | setLikesState([...likesState, data]); 110 | await getMyInfo(); 111 | sendNotification('LIKE', data._id, author._id, _id); 112 | showToast({ message: SUCCESS_MESSAGES.LIKE_SUCCESS }); 113 | } catch (error) { 114 | console.error(error); 115 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 116 | } 117 | } else { 118 | try { 119 | setLikesState( 120 | likesState.filter((item) => item._id !== likesState[isLike]._id) 121 | ); 122 | deleteLike(likesState[isLike]._id); 123 | if (user.likes) { 124 | getMyInfo(); 125 | } 126 | showToast({ message: SUCCESS_MESSAGES.UNLIKE_SUCCESS }); 127 | } catch (error) { 128 | console.log(error); 129 | showToast({ message: ERROR_MESSAGES.SERVER_ERROR }); 130 | } 131 | } 132 | }; 133 | 134 | return ( 135 | <Container {...props}> 136 | <PostHeader> 137 | <Wrapper onClick={toUserProfile}> 138 | {author.image ? ( 139 | <ProfileImage 140 | src={queryLowImage(author.image, 'postAuthor')} 141 | alt="author" 142 | /> 143 | ) : ( 144 | <Icon name="default-profile" size={28} /> 145 | )} 146 | <UserName>{author.fullName}</UserName> 147 | <Divider type="vertical" size={0.5} /> 148 | <Date>{formatDate.fullDate(createdAt)}</Date> 149 | </Wrapper> 150 | <Wrapper> 151 | <Button onClick={handleShare}> 152 | <Icon name="share" size={16} /> 153 | </Button> 154 | </Wrapper> 155 | </PostHeader> 156 | {isMyLikes ? ( 157 | <FlexContainer direction="row" color={COLORS.white}> 158 | {image && ( 159 | <ImageContainer isMyLikes={isMyLikes}> 160 | <Image src={queryLowImage(image, 'postList')} alt="post-image" /> 161 | </ImageContainer> 162 | )} 163 | <FlexContainer direction="column" isMyLikes={isMyLikes}> 164 | <PostContent 165 | _id={_id} 166 | title={title} 167 | image={image} 168 | isDetailPage={isDetailPage} 169 | isMyLikes={isMyLikes} 170 | /> 171 | <Footer isMyLikes={isMyLikes}> 172 | {likesState.find((like) => like.user === user._id) ? ( 173 | <IconWrapper> 174 | <Button onClick={handleLike}> 175 | <Icon name="liked" width={18} height={16} /> 176 | </Button> 177 | <SmText>{likesState.length}</SmText> 178 | </IconWrapper> 179 | ) : ( 180 | <IconWrapper> 181 | <Button onClick={handleLike}> 182 | <Icon name="unliked" width={18} height={16} /> 183 | </Button> 184 | {likesState.length > 999 ? ( 185 | <SmText>999+</SmText> 186 | ) : ( 187 | <SmText>{likesState.length}</SmText> 188 | )} 189 | </IconWrapper> 190 | )} 191 | <IconWrapper> 192 | <Button onClick={toPostDetail}> 193 | <Icon name="comment" width={18} height={16} /> 194 | </Button> 195 | {comments.length > 999 ? ( 196 | <SmText>999+</SmText> 197 | ) : ( 198 | <SmText>{comments.length}</SmText> 199 | )} 200 | </IconWrapper> 201 | </Footer> 202 | </FlexContainer> 203 | </FlexContainer> 204 | ) : ( 205 | <PostContent 206 | _id={_id} 207 | title={title} 208 | image={image} 209 | isDetailPage={isDetailPage} 210 | isMyLikes={isMyLikes} 211 | /> 212 | )} 213 | {checkIsMine && user._id === author._id ? ( 214 | <Footer> 215 | <IconContainer> 216 | <StyledDiv> 217 | {likesState.find((like) => like.user === user._id) ? ( 218 | <IconWrapper> 219 | <Button onClick={handleLike}> 220 | <Icon name="liked" width={18} height={16} /> 221 | </Button> 222 | <SmText>{likesState.length}</SmText> 223 | </IconWrapper> 224 | ) : ( 225 | <IconWrapper> 226 | <Button onClick={handleLike}> 227 | <Icon name="unliked" width={18} height={16} /> 228 | </Button> 229 | {likesState.length > 999 ? ( 230 | <SmText>999+</SmText> 231 | ) : ( 232 | <SmText>{likesState.length}</SmText> 233 | )} 234 | </IconWrapper> 235 | )} 236 | <IconWrapper> 237 | <Button onClick={toPostDetail}> 238 | <Icon name="comment" width={18} height={16} /> 239 | </Button> 240 | {comments.length > 999 ? ( 241 | <SmText>999+</SmText> 242 | ) : ( 243 | <SmText>{comments.length}</SmText> 244 | )} 245 | </IconWrapper> 246 | </StyledDiv> 247 | <StyledDiv> 248 | <IconWrapper> 249 | <Button onClick={handleDelete}> 250 | <Icon name="delete" size={16} /> 251 | </Button> 252 | </IconWrapper> 253 | <IconWrapper> 254 | <Button onClick={handleEdit}> 255 | <Icon name="edit" size={20} /> 256 | </Button> 257 | </IconWrapper> 258 | </StyledDiv> 259 | </IconContainer> 260 | </Footer> 261 | ) : ( 262 | !isMyLikes && ( 263 | <Footer> 264 | {likesState.find((like) => like.user === user._id) ? ( 265 | <IconWrapper> 266 | <Button onClick={handleLike}> 267 | <Icon name="liked" width={18} height={16} /> 268 | </Button> 269 | <SmText>{likesState.length}</SmText> 270 | </IconWrapper> 271 | ) : ( 272 | <IconWrapper> 273 | <Button onClick={handleLike}> 274 | <Icon name="unliked" width={18} height={16} /> 275 | </Button> 276 | {likesState.length > 999 ? ( 277 | <SmText>999+</SmText> 278 | ) : ( 279 | <SmText>{likesState.length}</SmText> 280 | )} 281 | </IconWrapper> 282 | )} 283 | <IconWrapper> 284 | <Button onClick={toPostDetail}> 285 | <Icon name="comment" width={18} height={16} /> 286 | </Button> 287 | {comments.length > 999 ? ( 288 | <SmText>999+</SmText> 289 | ) : ( 290 | <SmText>{comments.length}</SmText> 291 | )} 292 | </IconWrapper> 293 | </Footer> 294 | ) 295 | )} 296 | </Container> 297 | ); 298 | }; 299 | 300 | const Container = styled.div` 301 | width: 100%; 302 | margin: 2rem 0; 303 | 304 | @media screen and (max-width: 767px) and (orientation: portrait) { 305 | margin: 1.3rem auto; 306 | width: 90%; 307 | } 308 | `; 309 | 310 | const PostHeader = styled.div` 311 | display: flex; 312 | align-items: center; 313 | justify-content: space-between; 314 | padding: 1.2rem 0.6rem 1.5rem 0.3rem; 315 | margin: 0 auto; 316 | `; 317 | 318 | const FlexContainer = styled.div<FlexContainerProps>` 319 | display: flex; 320 | flex-direction: ${({ direction }) => 321 | direction === 'column' ? 'column' : 'row'}; 322 | background-color: ${({ color }) => color}; 323 | padding: ${({ isMyLikes }) => (isMyLikes ? '1.5rem' : '')}; 324 | justify-content: ${({ isMyLikes }) => (isMyLikes ? 'space-between' : '')}; 325 | `; 326 | 327 | const Footer = styled.div<{ isMyLikes?: boolean }>` 328 | display: flex; 329 | align-items: center; 330 | padding: ${({ isMyLikes }) => (isMyLikes ? '1.4rem 0 0' : '1.4rem 1.2rem;')}; 331 | `; 332 | 333 | const SmText = styled.span` 334 | font-weight: 400; 335 | font-size: 1.1rem; 336 | line-height: 1.4rem; 337 | letter-spacing: -0.01em; 338 | color: ${COLORS.brown}; 339 | `; 340 | 341 | const Wrapper = styled.div` 342 | display: flex; 343 | align-items: center; 344 | gap: 0.8rem; 345 | cursor: pointer; 346 | `; 347 | 348 | const IconWrapper = styled.div` 349 | display: flex; 350 | align-items: center; 351 | gap: 0.5rem; 352 | 353 | &:first-child { 354 | margin-right: 1.2rem; 355 | } 356 | `; 357 | 358 | const StyledDiv = styled.div` 359 | display: flex; 360 | `; 361 | 362 | const IconContainer = styled.div` 363 | width: 100%; 364 | display: flex; 365 | justify-content: space-between; 366 | `; 367 | 368 | const Button = styled.button` 369 | display: flex; 370 | cursor: pointer; 371 | `; 372 | 373 | const UserName = styled.span` 374 | font-weight: 500; 375 | font-size: 1.5rem; 376 | line-height: 2rem; 377 | letter-spacing: -0.01em; 378 | color: ${COLORS.lightBrown}; 379 | `; 380 | 381 | const Date = styled.span` 382 | font-weight: 400; 383 | font-size: 1.2rem; 384 | line-height: 2rem; 385 | letter-spacing: -0.01em; 386 | color: ${COLORS.date}; 387 | `; 388 | 389 | const ProfileImage = styled.img` 390 | width: 3rem; 391 | height: 3rem; 392 | border-radius: 50%; 393 | margin-right: 0.4rem; 394 | object-fit: cover; 395 | -webkit-user-drag: none; 396 | -khtml-user-drag: none; 397 | -moz-user-drag: none; 398 | -o-user-drag: none; 399 | `; 400 | 401 | const ImageContainer = styled.div<ImageContainerProps>` 402 | position: relative; 403 | display: inline-block; 404 | overflow: hidden; 405 | width: ${({ width }) => width ?? '12rem'}; 406 | min-width: 12rem; 407 | aspect-ratio: 1 / 1; 408 | background-color: #fafafa; 409 | border-radius: ${({ isMyLikes }) => (isMyLikes ? 0 : '15px')}; 410 | /* margin-right: ${({ isMyLikes }) => (isMyLikes ? '1.5rem' : 0)}; */ 411 | `; 412 | 413 | export default Post; 414 | --------------------------------------------------------------------------------