├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── custom.md └── pull_request_template.md ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── babel.config.js ├── package.json ├── resources ├── facebook-logo.png ├── info-window-arrow.png ├── insta-logo.png ├── kakao-logo.png ├── logo.png ├── magnifier_icon.png └── times-solid.png ├── src ├── Root.jsx ├── api │ └── index.js ├── components │ ├── Carousel │ │ ├── Carousel.jsx │ │ └── Carousel.scss │ ├── CommonBtn │ │ ├── CloseBtn.jsx │ │ ├── CloseBtn.scss │ │ ├── CommonBtn.jsx │ │ ├── CommonBtn.scss │ │ ├── IconButton.jsx │ │ └── IconButton.scss │ ├── CommonLink │ │ ├── CommonLink.jsx │ │ └── CommonLink.scss │ ├── CommonModal │ │ ├── CommonModal.jsx │ │ └── CommonModal.scss │ ├── CommonPost │ │ ├── CommonPost.jsx │ │ └── CommonPost.scss │ ├── DetailPost │ │ ├── DetailPost.jsx │ │ └── DetailPost.scss │ ├── DetailPostHeader │ │ ├── DetailPostHeader.jsx │ │ └── DetailPostHeader.scss │ ├── Header │ │ ├── DropdownMenu.jsx │ │ ├── DropdownMenu.scss │ │ ├── Header.jsx │ │ └── Header.scss │ ├── ImageEditor │ │ ├── EditorFooter.jsx │ │ ├── EditorFooter.scss │ │ ├── EditorHeader.jsx │ │ ├── EditorHeader.scss │ │ ├── ImageEditor.jsx │ │ ├── ImageEditor.scss │ │ ├── index.js │ │ └── middleware.js │ ├── Loader │ │ ├── Loader.jsx │ │ ├── Loader.scss │ │ └── index.js │ ├── MapView │ │ ├── InfoWindow.jsx │ │ ├── InfoWindow.scss │ │ └── MapView.jsx │ ├── NewPostBtn │ │ ├── NewPostBtn.jsx │ │ ├── NewPostBtn.scss │ │ ├── ToolTip.jsx │ │ └── ToolTip.scss │ ├── OAuthBtn │ │ ├── OAuthBtn.jsx │ │ └── OAuthBtn.scss │ ├── PostContainer │ │ ├── PostContainer.jsx │ │ └── PostContainer.scss │ ├── PostItem │ │ ├── PostImage.jsx │ │ ├── PostImage.scss │ │ ├── PostItem.jsx │ │ └── PostItem.scss │ ├── PostUploader │ │ ├── DescriptionUploader.jsx │ │ ├── DescriptionUploader.scss │ │ ├── ImageUploader │ │ │ ├── ImageUploader.jsx │ │ │ ├── ImageUploader.scss │ │ │ ├── OverlayButtons.jsx │ │ │ ├── OverlayButtons.scss │ │ │ ├── PreviewImages.jsx │ │ │ ├── PreviewImages.scss │ │ │ ├── SecondInputButton.jsx │ │ │ ├── SecondInputButton.scss │ │ │ ├── index.js │ │ │ └── middleware.js │ │ ├── LocationUploader │ │ │ ├── LocationFinder.jsx │ │ │ ├── LocationFinder.scss │ │ │ ├── LocationFinderPopupMessage.jsx │ │ │ ├── LocationFinderPopupMessage.scss │ │ │ ├── LocationPreview.jsx │ │ │ ├── LocationUploader.jsx │ │ │ ├── MarkerController.jsx │ │ │ ├── MarkerInfoWindow.jsx │ │ │ ├── MarkerInfoWindow.scss │ │ │ ├── SearchResultLists.jsx │ │ │ ├── SearchResultLists.scss │ │ │ ├── SearchResultMarkers.jsx │ │ │ └── react-kakao-maps │ │ │ │ ├── CustomOverlay │ │ │ │ └── index.jsx │ │ │ │ ├── CustomOverlayContainer │ │ │ │ └── index.jsx │ │ │ │ ├── Marker │ │ │ │ └── index.jsx │ │ │ │ ├── constants.js │ │ │ │ └── hooks │ │ │ │ ├── useMapContext.jsx │ │ │ │ └── usePlaceService.jsx │ │ ├── PostQuestions.jsx │ │ ├── PostQuestions.scss │ │ └── TitleUploader │ │ │ ├── CompanionInput.jsx │ │ │ ├── CompanionInput.scss │ │ │ ├── TitleUploader.jsx │ │ │ ├── TitleUploader.scss │ │ │ └── index.js │ ├── ProfileContentItem │ │ ├── ProfileContentItem.jsx │ │ └── ProfileContentItem.scss │ ├── ProfileImage │ │ ├── ProfileImage.jsx │ │ └── ProfileImage.scss │ ├── ProfileInfo │ │ ├── Follower.jsx │ │ ├── Follower.scss │ │ ├── FollowerList.jsx │ │ ├── FollowerList.scss │ │ ├── ProfileInfo.jsx │ │ └── ProfileInfo.scss │ ├── RelatedPost │ │ ├── RelatedPost.jsx │ │ ├── RelatedPost.scss │ │ ├── RelatedPostComment.jsx │ │ └── RelatedPostComment.scss │ └── ValidityMessage │ │ ├── ValidityMessage.jsx │ │ └── ValidityMessage.scss ├── configs.js ├── contexts │ └── LoginContext.jsx ├── hooks │ ├── useDebounce.jsx │ ├── useEditStatus.jsx │ ├── useFetch.jsx │ ├── useInput.jsx │ ├── useIntersectionObserver.jsx │ ├── useMediaQuerySet.jsx │ ├── useMiddleware.jsx │ ├── useModal.jsx │ ├── useModal.scss │ ├── useProfileValidation.jsx │ ├── useS3.jsx │ ├── useScript.jsx │ ├── useShakeAnimation.jsx │ ├── useShakeAnimation.scss │ ├── useTempTokenValidation.jsx │ ├── useUploadStatus.jsx │ └── useWidthAdjust.jsx ├── index.html ├── index.jsx ├── pages │ ├── DetailPage │ │ ├── DetailPage.jsx │ │ ├── DetailPage.scss │ │ └── index.js │ ├── MainPage │ │ ├── MainPage.jsx │ │ ├── MainPage.scss │ │ └── index.js │ ├── PostUploadPage │ │ ├── PostUploadPage.jsx │ │ ├── PostUploadPage.scss │ │ ├── helper.js │ │ ├── index.js │ │ └── middleware.js │ ├── ProfileEditPage │ │ ├── ProfileEditPage.jsx │ │ ├── ProfileEditPage.scss │ │ ├── index.js │ │ ├── middleware.js │ │ └── reducer.js │ ├── ProfilePage │ │ ├── ProfilePage.jsx │ │ └── index.js │ └── SignupPage │ │ ├── SignupPage.jsx │ │ ├── SignupPage.scss │ │ └── index.js ├── reducers │ └── PostUploadPage │ │ ├── ImageEditReducer.js │ │ ├── ImageUploadReducer.js │ │ ├── LocationUploadReducer.js │ │ └── index.js ├── stylesheets │ ├── base.scss │ └── util.scss └── utils │ ├── diff.js │ ├── diff.spec.js │ ├── loggerMiddleware.js │ └── utils.js ├── webpack.config.js ├── webpack.config.prod.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true 6 | }, 7 | extends: ['eslint:recommended', 'plugin:react/recommended', 'prettier'], 8 | globals: { 9 | Atomics: 'readonly', 10 | SharedArrayBuffer: 'readonly' 11 | }, 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true 15 | }, 16 | ecmaVersion: 2020, 17 | sourceType: 'module' 18 | }, 19 | plugins: ['react'], 20 | settings: { 21 | react: { 22 | createClass: 'createReactClass', // Regex for Component Factory to use, 23 | // default to "createReactClass" 24 | pragma: 'React', // Pragma to use, default to "React" 25 | version: 'detect', // React version. "detect" automatically picks the version you have installed. 26 | // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. 27 | // default to latest and warns if missing 28 | // It will default to "detect" in the future 29 | flowVersion: '0.53' // Flow version 30 | }, 31 | propWrapperFunctions: [ 32 | // The names of any function used to wrap propTypes, e.g. `forbidExtraProps`. If this isn't set, any propTypes wrapped in a function will be skipped. 33 | 'forbidExtraProps', 34 | { property: 'freeze', object: 'Object' }, 35 | { property: 'myFavoriteWrapper' } 36 | ], 37 | linkComponents: [ 38 | // Components used as alternatives to for linking, eg. 39 | 'Hyperlink', 40 | { name: 'Link', linkAttribute: 'to' } 41 | ] 42 | }, 43 | rules: { 44 | 'no-unused-vars': 0, 45 | 'react/prop-types': 0, 46 | 'react/self-closing-comp': [ 47 | 'error', 48 | { 49 | component: true, 50 | html: true 51 | } 52 | ] 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: 이슈 템플릿 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## branch name 11 | - 12 | 13 | ## 구현내용 14 | - 15 | 16 | ## 참고사항 17 | - 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### 구현사항 2 | - 3 | 4 | ### 참고사항 5 | - 6 | 7 | ### 해결된 이슈 번호 8 | - resolved 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | map_constants.js 4 | aws_s3.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12.8' 4 | before_install: # 패키지를 다운로드 받기 전 5 | - npm install -g yarn # travis 환경에 yarn을 글로벌 설치 6 | branches: 7 | only: 8 | - master 9 | 10 | script: 11 | - yarn build 12 | 13 | env: 14 | global: 15 | - KAKAO_MAP_API_URL=$KAKAO_MAP_API_URL 16 | - ALBUM_BUCKET_NAME=$ALBUM_BUCKET_NAME 17 | - BUCKET_REGION=$BUCKET_REGION 18 | - IDENTITY_POOL_ID=$IDENTITY_POOL_ID 19 | 20 | before_deploy: # 배포하기전 하는 작업들 21 | - rm -rf node_modules # travis가 설치한 node_moduels를 삭제 22 | 23 | deploy: # 배포 24 | - provider: s3 # AWS S3를 의미 25 | access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값 26 | secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값 27 | bucket: connectflavor.cf # S3에 생성한 버킷 28 | region: ap-northeast-2 29 | skip_cleanup: true 30 | local_dir: dist # dist 디렉터리에 있는 파일을 s3로 업로드 하겠다는 의미 31 | wait-until-deployed: true 32 | on: 33 | repo: codesquad-project-team/frontend #Github 주소 34 | branch: master 35 | 36 | notifications: # 성공 실패 여부 알림 37 | email: 38 | recipients: 39 | - hwrng2@gmail.com 40 | - dev.allenk@gmail.com 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connect Flavor 2 | 3 | > Connect Flavor는 **비슷한 취향을 가지고 있는 사람들을 연결**합니다. 그리고 개인의 취향에 따라 할 일을 추천합니다. 4 | 5 |

6 | 7 | 8 | 9 |

10 | 11 |

12 | 13 | connectflavor-logo 14 | 15 |

16 | 17 | - [서비스 화면 구성 및 기능 링크](https://github.com/codesquad-project-team/frontend/wiki/%ED%99%94%EB%A9%B4-%EA%B5%AC%EC%84%B1-%EB%B0%8F-%EA%B8%B0%EB%8A%A5#%ED%99%94%EB%A9%B4-%EA%B5%AC%EC%84%B1-%EB%B0%8F-%EA%B8%B0%EB%8A%A5) 18 | 19 | ## 팀 구성 20 | 21 | - connectflavor-contributors 22 | 23 | - 프론트엔드 개발자 2명, 백엔드 개발자 2명 24 | - 프론트 기술 스택: ReactJS, SCSS, webpack, travis(배포 자동화), swagger(API 설계 공유) 25 | 26 | ## 일하는 방식 27 | 28 | ### 프론트엔드 & 백엔드 팀 29 | 30 | - 스프린트 단위: 1주일 31 | - 매주 금요일 오프라인 미팅 진행 32 | 33 | - 한 주간의 결과물 및 좋았던 점, 개선해야 할 점 등을 공유하며 회고합니다. 다음 스프린트의 개발 내용을 정하고, 프론트엔드팀과 백엔드팀이 함께 필요한 API를 설계합니다. 그 외에도 서비스의 가치나 방향성에 대한 의견을 주고 받으며 서로의 생각을 확인합니다. 34 | 35 | - 매주 수요일 온라인 미팅 진행 36 | - 각 팀별 진행 상황을 공유하고, 개발 중 예상치 못한 이슈 또는 서로 다른 팀의 지원이 필요한 부분은 없는지 등을 확인합니다. 37 | 38 | ### 프론트엔드 팀 39 | 40 | - [figma](https://www.figma.com/file/3rjXMNRb7DhheV2cpCu0Ql/interest-sharing-sns?node-id=0%3A1)를 이용해 합의한 기능에 의한 프로토타입을 디자인 합니다. 41 | 42 | - Github을 현업에서 사용하는 수준과 유사한 수준으로 경험해보려고 합니다. 43 | - 매주 스프린트 회의에서 정해진 개발을 분담하여, issue로 등록합니다. 44 | - PR에 대한 코드 리뷰는 최대한 꼼꼼히 진행하며 피드백을 반영한 뒤에 merge 합니다. 45 | - Project로 각자의 진행 상황을 항상 업데이트 합니다. 46 | - Git Flow를 프로젝트 상황에 맞게 변형한 브랜치 전략을 사용합니다.[링크](https://github.com/codesquad-project-team/frontend/wiki/git-%EB%B8%8C%EB%9E%9C%EC%B9%98-%EC%A0%84%EB%9E%B5) 47 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | 4 | const presets = [['@babel/preset-env'], ['@babel/preset-react']]; 5 | 6 | const plugins = [ 7 | '@babel/plugin-transform-runtime', 8 | 'react-hot-loader/babel', 9 | 'emotion' 10 | ]; 11 | 12 | return { 13 | presets, 14 | plugins 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "dev": "webpack-dev-server --hot --open", 7 | "start": "node app.js", 8 | "build": "webpack --config webpack.config.prod.js", 9 | "dev-build": "NODE_ENV=dev webpack --config webpack.config.prod.js", 10 | "test": "exit" 11 | }, 12 | "repository": "https://github.com/codesquad-project-team/frontend.git", 13 | "author": "leehwarang ", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@babel/runtime": "^7.6.2", 17 | "aws-sdk": "^2.574.0", 18 | "classnames": "^2.2.6", 19 | "prop-types": "^15.7.2", 20 | "react": "^16.9.0", 21 | "react-cropper": "^1.3.0", 22 | "react-dom": "npm:@hot-loader/react-dom", 23 | "react-hot-loader": "^4.12.14", 24 | "react-kakao-maps": "^0.0.11", 25 | "react-responsive": "^8.0.3", 26 | "react-router-dom": "^5.1.0", 27 | "react-spinners": "^0.6.1", 28 | "styled-components": "^4.4.1" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.6.2", 32 | "@babel/plugin-transform-runtime": "^7.6.2", 33 | "@babel/preset-env": "^7.6.2", 34 | "@babel/preset-react": "^7.0.0", 35 | "babel-loader": "^8.0.6", 36 | "clean-webpack-plugin": "^3.0.0", 37 | "css-loader": "^3.2.0", 38 | "eslint": "^6.5.0", 39 | "eslint-config-prettier": "^6.3.0", 40 | "eslint-plugin-react": "^7.14.3", 41 | "html-webpack-plugin": "^3.2.0", 42 | "mini-css-extract-plugin": "^0.9.0", 43 | "node-sass": "^4.12.0", 44 | "sass-loader": "^8.0.0", 45 | "style-loader": "^1.0.0", 46 | "url-loader": "^2.1.0", 47 | "webpack": "^4.41.0", 48 | "webpack-cli": "^3.3.9", 49 | "webpack-dev-server": "^3.8.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /resources/facebook-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/facebook-logo.png -------------------------------------------------------------------------------- /resources/info-window-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/info-window-arrow.png -------------------------------------------------------------------------------- /resources/insta-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/insta-logo.png -------------------------------------------------------------------------------- /resources/kakao-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/kakao-logo.png -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/logo.png -------------------------------------------------------------------------------- /resources/magnifier_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/magnifier_icon.png -------------------------------------------------------------------------------- /resources/times-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesquad-project-team/frontend/dff69b9f793854e8e6ebe85a781cd74130d7c34a/resources/times-solid.png -------------------------------------------------------------------------------- /src/Root.jsx: -------------------------------------------------------------------------------- 1 | import { hot } from 'react-hot-loader/root'; 2 | import React, { lazy, Suspense } from 'react'; 3 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 4 | import MainPage from './pages/MainPage/MainPage'; 5 | import LoginContextProvider from './contexts/LoginContext'; 6 | import Loader from './components/Loader'; 7 | 8 | const ProfileEditPage = lazy(() => 9 | import(/* webpackChunkName: "profile-edit-page" */ './pages/ProfileEditPage') 10 | ); 11 | const ProfilePage = lazy(() => 12 | import(/* webpackChunkName: "profile-page" */ './pages/ProfilePage') 13 | ); 14 | const PostUploadPage = lazy(() => 15 | import(/* webpackChunkName: "post-upload-page" */ './pages/PostUploadPage') 16 | ); 17 | const DetailPage = lazy(() => 18 | import(/* webpackChunkName: "detail-page" */ './pages/DetailPage') 19 | ); 20 | const SignupPage = lazy(() => 21 | import(/* webpackChunkName: "signup-page" */ './pages/SignupPage') 22 | ); 23 | 24 | const Root = () => { 25 | return ( 26 | 27 | 28 | }> 29 | 30 | 31 | 32 | 33 | } /> 34 | } /> 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default hot(Root); 45 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | const API_ENDPOINT = 'http://api.connectflavor.cf/v1'; 2 | 3 | const GET = 'GET'; 4 | const POST = 'POST'; 5 | const PUT = 'PUT'; 6 | const DELETE = 'DELETE'; 7 | 8 | const TOKEN = { credentials: 'include' }; 9 | const BODY = param => ({ 10 | mode: 'cors', 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: JSON.stringify(param) 15 | }); 16 | 17 | const request = async (method, URI, options) => 18 | await fetch(`${API_ENDPOINT}${URI}`, { method, ...options }); 19 | 20 | const options = (...options) => 21 | options.reduce((acc, cur) => ({ ...acc, ...cur }), {}); 22 | 23 | const authAPI = { 24 | requestLogout: () => { 25 | return request(POST, `/auth/logout`, options(TOKEN)); 26 | }, 27 | validateTempToken: () => { 28 | return request(POST, `/auth/tempToken`, options(TOKEN)); 29 | }, 30 | signup: nickname => { 31 | return request(POST, `/auth/signup`, options(TOKEN, BODY({ nickname }))); 32 | }, 33 | hasLoggedIn: () => { 34 | return request(GET, `/auth/has-logged-in`, options(TOKEN)); 35 | } 36 | }; 37 | 38 | const postAPI = { 39 | deletePost: postId => { 40 | return request(DELETE, `/post/${postId}`, options(TOKEN)); 41 | }, 42 | getPosts: (page, writerId) => { 43 | return request( 44 | GET, 45 | `/post?page=${page}${writerId ? `&writerid=${writerId}` : ''}` 46 | ); 47 | }, 48 | getRelatedPosts: (postId, page) => { 49 | return request(GET, `/post/related-to?postid=${postId}&page=${page}`); 50 | }, 51 | uploadPost: (isEditMode, id, postData) => { 52 | return request( 53 | isEditMode ? PUT : POST, 54 | `/post${isEditMode ? `/${id}` : ''}`, 55 | options(TOKEN, BODY(postData)) 56 | ); 57 | }, 58 | getPostDetail: postId => { 59 | return request(GET, `/post/${postId}`); 60 | } 61 | }; 62 | 63 | const userAPI = { 64 | getFollowList: (userId, type) => { 65 | return request(GET, `/user/${userId}/relationship/${type}`); 66 | }, 67 | updateFollowStatus: (userId, isFollowing) => { 68 | return request( 69 | isFollowing ? DELETE : POST, 70 | `/user/follow/${userId}`, 71 | options(TOKEN) 72 | ); 73 | }, 74 | getMyProfile: () => { 75 | return request(GET, `/user/myinfo`, options(TOKEN)); 76 | }, 77 | 78 | //db의 primary key는 userId이지만, 79 | //주소창에 닉네임을 직접 입력하는 경우에도 프로필 불러오기 위해 2가지 쿼리 사용. 80 | getProfile: (targetId, nickname) => { 81 | return request( 82 | GET, 83 | `/user/profile-content?${ 84 | targetId ? `id=${targetId}` : `nickname=${nickname}` 85 | }`, 86 | options(TOKEN) 87 | ); 88 | }, 89 | updateProfile: userInfo => { 90 | return request(PUT, `/user/profile`, options(TOKEN, BODY(userInfo))); 91 | }, 92 | checkNickname: nickname => { 93 | return request( 94 | POST, 95 | `/user/checkNicknameDuplication`, 96 | options(BODY({ nickname })) 97 | ); 98 | } 99 | }; 100 | 101 | export default { ...userAPI, ...postAPI, ...authAPI }; 102 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, useReducer, useEffect } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './Carousel.scss'; 4 | 5 | const cx = classNames.bind(styles); 6 | 7 | const Carousel = ({ data, src, key, className, style }) => { 8 | const [targetIndex, setTargetIndex] = useState(0); 9 | const dataLength = useMemo(() => data.length, [data]); 10 | const isLeftEnd = !targetIndex; 11 | const isRightEnd = targetIndex === dataLength - 1; 12 | const [touchInfo, dispatch] = useReducer(reducer, {}); 13 | 14 | const showPrev = () => !isLeftEnd && setTargetIndex(index => index - 1); 15 | const showNext = () => !isRightEnd && setTargetIndex(index => index + 1); 16 | 17 | const handleTouchStart = ({ changedTouches }) => { 18 | const { pageX, pageY } = changedTouches[0]; 19 | dispatch({ type: 'touchStart', payload: { pageX, pageY } }); 20 | }; 21 | 22 | const handleTouchMove = ({ changedTouches }) => { 23 | const { pageX, pageY } = changedTouches[0]; 24 | dispatch({ type: 'touchMove', payload: { pageX, pageY } }); 25 | }; 26 | 27 | const handleTouchEnd = () => { 28 | if (!touchInfo.isHorizontalMove) return; 29 | touchInfo.direction === 'next' ? showNext() : showPrev(); 30 | dispatch({ type: 'touchEnd' }); 31 | }; 32 | 33 | useEffect(() => { 34 | const criterion = window.innerHeight / 3 / window.innerWidth; //기준기울기 = (0,0)부터 화면세로길이의 1/3지점까지의 기울기 35 | dispatch({ type: 'init', payload: criterion }); 36 | }, []); 37 | 38 | return ( 39 |
40 |
46 |
52 | {data.map((el, idx) => ( 53 | 58 | ))} 59 |
60 | {!isLeftEnd && ( 61 | 64 | )} 65 | {!isRightEnd && ( 66 | 69 | )} 70 |
71 |
72 | {data.map((el, idx) => ( 73 |
77 | ))} 78 |
79 |
80 | ); 81 | }; 82 | 83 | export default Carousel; 84 | 85 | const reducer = (state, { type, payload }) => { 86 | switch (type) { 87 | case 'init': 88 | return { criterion: payload }; 89 | case 'touchStart': 90 | return { ...state, ...payload, isTouchStarted: true }; 91 | case 'touchMove': 92 | return { ...state, ...calculateMoveType(state, payload) }; 93 | case 'touchEnd': 94 | return { criterion: state.criterion }; 95 | default: 96 | //do nothing 97 | break; 98 | } 99 | }; 100 | 101 | const calculateMoveType = ( 102 | { isTouchStarted, criterion, ...prevCoord }, 103 | currentCoord 104 | ) => { 105 | if (!isTouchStarted) return; 106 | 107 | const x = Math.abs(prevCoord.pageX - currentCoord.pageX); 108 | const y = Math.abs(prevCoord.pageY - currentCoord.pageY); 109 | const distance = x + y; 110 | 111 | //4px 미만으로 움직인 경우 아무동작 안함. 참고: https://d2.naver.com/helloworld/80243 112 | if (distance < 4) return; 113 | 114 | const slope = y / x; 115 | const isHorizontalMove = slope < criterion; 116 | const direction = prevCoord.pageX > currentCoord.pageX ? 'next' : 'prev'; 117 | 118 | return { isHorizontalMove, direction }; 119 | }; 120 | -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.scss: -------------------------------------------------------------------------------- 1 | $image-bucket-URL: 'https://team-project-s3-bucket.s3.ap-northeast-2.amazonaws.com/resources'; 2 | 3 | .viewport { 4 | position: relative; 5 | overflow: hidden; 6 | } 7 | 8 | .container { 9 | display: flex; 10 | transition: 0.3s; 11 | } 12 | 13 | .items { 14 | width: 100%; 15 | min-width: 100%; 16 | } 17 | 18 | @mixin buttons { 19 | position: absolute; 20 | top: 50%; 21 | transform: translateY(-50%); 22 | z-index: 1; 23 | margin: 0 5px; 24 | padding: 0; 25 | width: 24px; 26 | height: 24px; 27 | border: 0; 28 | border-radius: 50%; 29 | opacity: 0.8; 30 | background-color: transparent; 31 | &:hover { 32 | box-shadow: 0 0 5px 1px #e0e0e0; 33 | } 34 | } 35 | 36 | .leftButton { 37 | @include buttons; 38 | left: 0; 39 | } 40 | 41 | .rightButton { 42 | @include buttons; 43 | right: 0; 44 | } 45 | 46 | .leftChevron { 47 | width: inherit; 48 | height: inherit; 49 | z-index: inherit; 50 | background-image: url('#{$image-bucket-URL}/left-chevron.svg'); 51 | } 52 | 53 | .rightChevron { 54 | width: inherit; 55 | height: inherit; 56 | z-index: inherit; 57 | background-image: url('#{$image-bucket-URL}/right-chevron.svg'); 58 | } 59 | 60 | .pagination-wrapper { 61 | display: flex; 62 | justify-content: center; 63 | margin: 10px 0; 64 | } 65 | .pagination-dots { 66 | margin-right: 4px; 67 | width: 6px; 68 | height: 6px; 69 | border: 0; 70 | border-radius: 50%; 71 | background-color: #e0e0e0; 72 | } 73 | .pagination-dots:last-child { 74 | margin-right: 0; 75 | } 76 | .active { 77 | background-color: #3897f0; 78 | } 79 | -------------------------------------------------------------------------------- /src/components/CommonBtn/CloseBtn.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './CloseBtn.scss'; 4 | 5 | const cx = classNames.bind(styles); 6 | 7 | const CloseBtn = ({ className = '', onClick, children, ...restProps }) => { 8 | return ( 9 | 16 | ); 17 | }; 18 | 19 | export default CloseBtn; 20 | -------------------------------------------------------------------------------- /src/components/CommonBtn/CloseBtn.scss: -------------------------------------------------------------------------------- 1 | .close-btn { 2 | position: relative; 3 | width: 4rem; 4 | height: 4rem; 5 | background-color: transparent; 6 | border: 0; 7 | transition: all 0.25s; 8 | &:hover { 9 | transform: rotate(180deg); 10 | } 11 | &::before { 12 | content: ''; 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | right: 0; 17 | bottom: 0; 18 | margin: auto; 19 | width: 1px; 20 | height: 20px; 21 | background-color: #000; 22 | transform: rotate(45deg); 23 | } 24 | &::after { 25 | content: ''; 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | right: 0; 30 | bottom: 0; 31 | margin: auto; 32 | width: 1px; 33 | height: 20px; 34 | background-color: #000; 35 | transform: rotate(-45deg); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/CommonBtn/CommonBtn.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './CommonBtn.scss'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const CommonBtn = props => { 9 | const { 10 | children, 11 | className = '', 12 | styleType = 'normal', 13 | ...restProps 14 | } = props; 15 | 16 | return ( 17 | 20 | ); 21 | }; 22 | 23 | export default CommonBtn; 24 | 25 | CommonBtn.propTypes = { 26 | styleType: PropTypes.oneOf(['normal', 'emphasize', 'underline', 'none', '']) 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/CommonBtn/CommonBtn.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .common { 4 | border-radius: 5px; 5 | padding: 0.6rem 1rem; 6 | margin: 1rem; 7 | 8 | span { 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | margin: 0 1rem; 13 | font-size: 1.5rem; 14 | white-space: nowrap; 15 | } 16 | } 17 | .normal { 18 | border: 1px solid $main-color; 19 | background-color: #fff; 20 | 21 | &:hover { 22 | background-color: $main-color; 23 | color: #fff; 24 | } 25 | } 26 | 27 | .emphasize { 28 | border: 0; 29 | background-color: $main-color; 30 | color: #fff; 31 | 32 | &:hover { 33 | background-color: $main-color-light; 34 | } 35 | } 36 | 37 | .underline { 38 | border: 0; 39 | color: $main-color; 40 | font-weight: bold; 41 | 42 | &:hover { 43 | text-decoration: underline; 44 | } 45 | } 46 | 47 | .none { 48 | border: 1px solid $main-color; 49 | background-color: #fff; 50 | } 51 | -------------------------------------------------------------------------------- /src/components/CommonBtn/IconButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './IconButton.scss'; 4 | 5 | const cx = classNames.bind(styles); 6 | 7 | const IconButton = ({ 8 | type, 9 | onChange, 10 | children, 11 | className = '', 12 | multiple, 13 | src, 14 | ...props 15 | }) => { 16 | const randomId = useMemo(() => ((Math.random() + 1) * 10e4) | 0, []); 17 | 18 | return ( 19 |
20 | 24 | {type === 'addImage' && ( 25 | 33 | )} 34 |
35 | ); 36 | }; 37 | 38 | export default IconButton; 39 | -------------------------------------------------------------------------------- /src/components/CommonBtn/IconButton.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | $button-height: 4rem; 4 | $border-width: 1px; 5 | 6 | .wrapper { 7 | margin: 2rem 0; 8 | border-radius: calc(#{$button-height} / 2); 9 | height: $button-height; 10 | } 11 | 12 | .icon { 13 | margin-right: 5px; 14 | max-width: 80%; 15 | max-height: 80%; 16 | } 17 | 18 | .label { 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | 23 | padding: 0 1.5rem; 24 | width: inherit; 25 | height: inherit; 26 | 27 | line-height: calc(#{$button-height} - #{$border-width} * 2); 28 | 29 | border: $border-width solid $main-color; 30 | border-radius: inherit; 31 | background-color: $main-color-light; 32 | 33 | font-weight: bold; 34 | color: white; 35 | font-size: 1.5rem; 36 | } 37 | 38 | .hidden { 39 | display: none; 40 | } 41 | 42 | @include tablet-and-desktop { 43 | .label:hover { 44 | background-color: $main-color; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/CommonLink/CommonLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './CommonLink.scss'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const CommonLink = ({ className = '', onClick, children, ...restProps }) => { 9 | const scrollToTop = () => { 10 | window.scrollTo(0, 0); 11 | }; 12 | 13 | const handleClick = e => { 14 | e.stopPropagation(); 15 | onClick ? onClick() : null; 16 | scrollToTop(); 17 | }; 18 | 19 | return ( 20 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | export default CommonLink; 31 | -------------------------------------------------------------------------------- /src/components/CommonLink/CommonLink.scss: -------------------------------------------------------------------------------- 1 | .common-link { 2 | color: black; 3 | text-decoration: none; 4 | &:hover { 5 | color: inherit; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/CommonModal/CommonModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './CommonModal.scss'; 4 | import OAuthBtn from '../OAuthBtn/OAuthBtn'; 5 | import CommonBtn from '../CommonBtn/CommonBtn'; 6 | import useModal from '../../hooks/useModal'; 7 | import { IMAGE_BUCKET_URL, WEB_SERVER_URL } from '../../configs'; 8 | 9 | const cx = classNames.bind(styles); 10 | 11 | const CommonModal = ({ onClose, target }) => { 12 | const { Modal } = useModal(); 13 | const content = 14 | target === `signin` 15 | ? { 16 | title: '시작하기', 17 | desc: 18 | '로그인하여 비슷한 취향의 사람을 찾고, 각자의 소중한 시간을 공유하세요.', 19 | reminderMsg: '아직 계정이 없으신가요?', 20 | hyperlinkMsg: '가입하기' 21 | } 22 | : { 23 | title: '가입하기', 24 | desc: 25 | '이 곳에서 당신의 취향을 찾으세요.' + 26 | '\n' + 27 | '그리고 현재의 감정 상태에 따라 지금 할 일을 정해보세요.', 28 | reminderMsg: '이미 계정이 있으신가요?', 29 | hyperlinkMsg: '로그인하기' 30 | }; 31 | 32 | return ( 33 | 34 |
35 | 38 |
39 | 40 |

서비스 {content.title}

41 |
42 |

{content.desc}

43 |
44 | 50 | 55 | 60 |
61 |

62 | {content.reminderMsg} 63 | 64 | {content.hyperlinkMsg} 65 | 66 |

67 |
68 | 69 | ); 70 | }; 71 | 72 | export default CommonModal; 73 | -------------------------------------------------------------------------------- /src/components/CommonModal/CommonModal.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | position: relative; 5 | width: 95vw; 6 | padding: 1rem 2rem; 7 | background-color: white; 8 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); 9 | text-align: center; 10 | 11 | .header { 12 | height: 14rem; 13 | margin-bottom: 2.5rem; 14 | 15 | img { 16 | max-width: 75%; 17 | max-height: 75%; 18 | } 19 | 20 | h1 { 21 | margin: 0; 22 | font-size: 5rem; 23 | font-family: 'godoMaum'; 24 | color: $main-color; 25 | } 26 | } 27 | 28 | .oauth { 29 | max-width: 40rem; 30 | margin: 2rem auto; 31 | } 32 | 33 | p { 34 | margin-bottom: 2rem; 35 | font-size: 1.6rem; 36 | color: $gray-font-color; 37 | } 38 | } 39 | 40 | .close-btn { 41 | position: absolute; 42 | top: 1rem; 43 | right: 1rem; 44 | border: none; 45 | font-size: 1.6rem; 46 | font-weight: 200; 47 | background-color: #fff; 48 | &:hover { 49 | text-decoration: underline; 50 | } 51 | } 52 | 53 | .reminder-btn { 54 | margin: 0; 55 | background-color: #fff; 56 | 57 | span { 58 | font-size: 1.6rem; 59 | } 60 | 61 | a { 62 | color: $main-color; 63 | &:hover { 64 | color: $main-color-light; 65 | } 66 | } 67 | } 68 | @include tablet-and-desktop { 69 | .wrapper { 70 | max-width: 65rem; 71 | p { 72 | white-space: nowrap; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/components/CommonPost/CommonPost.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './CommonPost.scss'; 4 | import { getClassName } from '../../utils/utils'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const CommonPost = ({ 9 | small, 10 | medium, 11 | large, 12 | className = '', 13 | children, 14 | ...restProps 15 | }) => { 16 | const sizeClassName = getClassName({ small, medium, large }); 17 | return ( 18 |
19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | export const CommonBackground = ({ 25 | className = '', 26 | children, 27 | ...restProps 28 | }) => { 29 | return ( 30 |
31 | {children} 32 |
33 | ); 34 | }; 35 | 36 | CommonPost.background = CommonBackground; 37 | 38 | export default CommonPost; 39 | -------------------------------------------------------------------------------- /src/components/CommonPost/CommonPost.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .background { 4 | display: flex; 5 | justify-content: center; 6 | width: 100vw; 7 | min-height: calc(100vh - #{$mobile-header-height}); 8 | padding: 5rem 0; 9 | background-color: $gray-background-color; 10 | } 11 | 12 | .post { 13 | background-color: #fff; 14 | border-radius: 5px; 15 | } 16 | 17 | .small { 18 | width: 10rem; 19 | min-height: 10rem; 20 | } 21 | 22 | .medium { 23 | width: 20rem; 24 | min-height: 20rem; 25 | } 26 | 27 | .large { 28 | width: 100%; 29 | max-width: 100vw; 30 | min-height: 60rem; 31 | } 32 | 33 | @include tablet { 34 | .large { 35 | max-width: 65rem; 36 | } 37 | } 38 | 39 | @include desktop { 40 | .large { 41 | max-width: 70rem; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/DetailPost/DetailPost.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import ProfileImage from '../ProfileImage/ProfileImage'; 4 | import CommonLink from '../CommonLink/CommonLink'; 5 | import useMediaQuerySet from '../../hooks/useMediaQuerySet'; 6 | import { profilePage } from '../../utils/utils'; 7 | import styles from './DetailPost.scss'; 8 | 9 | const cx = classNames.bind(styles); 10 | 11 | const DetailPost = ({ 12 | data: { 13 | post: { place, companion, activity, description } = {}, 14 | writer: { id, nickname, profileImage } = {} 15 | } 16 | }) => { 17 | const { isMobile } = useMediaQuerySet(); 18 | return ( 19 |
20 |

21 | {place}에서{isMobile &&
} {companion} {activity} 22 |

23 |
24 | 25 | 30 | 31 |
32 | 36 | {nickname} 37 | 38 |

{description}

39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default DetailPost; 46 | -------------------------------------------------------------------------------- /src/components/DetailPost/DetailPost.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | width: 100%; 5 | margin-bottom: 3rem; 6 | } 7 | .title { 8 | font-size: $medium-title-font-size; 9 | text-align: center; 10 | margin-bottom: 1.5rem; 11 | } 12 | 13 | .content { 14 | display: flex; 15 | margin: 0 1rem; 16 | } 17 | 18 | .writer-img { 19 | margin-right: 2rem; 20 | } 21 | 22 | .writer-name { 23 | font-weight: bold; 24 | margin-bottom: 1rem; 25 | font-size: 1.6rem; 26 | } 27 | 28 | .desc { 29 | font-size: 1.6rem; 30 | } 31 | .profile-image { 32 | margin-top: 1.5rem; 33 | } 34 | 35 | @include tablet-and-desktop { 36 | .wrapper { 37 | max-width: 700px; 38 | } 39 | .title { 40 | font-size: $large-title-font-size; 41 | } 42 | .content { 43 | margin: 0 3rem; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/DetailPostHeader/DetailPostHeader.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import classNames from 'classnames/bind'; 4 | import CommonBtn from '../CommonBtn/CommonBtn'; 5 | import { useLoginContext } from '../../contexts/LoginContext'; 6 | import useFetch from '../../hooks/useFetch'; 7 | import useMediaQuerySet from '../../hooks/useMediaQuerySet'; 8 | import api from '../../api'; 9 | import styles from './DetailPostHeader.scss'; 10 | 11 | const cx = classNames.bind(styles); 12 | 13 | const DetailPostHeader = ({ data, writerId, postId }) => { 14 | const history = useHistory(); 15 | const { isMobile, isTablet } = useMediaQuerySet(); 16 | const [isFirstMouseOver, setIsFirstMouseOver] = useState(true); 17 | const { id, nickname } = useLoginContext(); 18 | const isMyPost = id === writerId; 19 | 20 | const savePostData = () => { 21 | localStorage.setItem('postData', JSON.stringify(data)); 22 | }; 23 | 24 | const handleMouseOver = () => { 25 | if (!isFirstMouseOver) return; 26 | savePostData(); 27 | setIsFirstMouseOver(false); 28 | }; 29 | 30 | const handleEdit = () => { 31 | (isMobile || isTablet) && savePostData(); 32 | history.push('/post/edit'); 33 | }; 34 | 35 | const { request } = useFetch({ 36 | onRequest: () => api.deletePost(postId), 37 | onSuccess: () => goToProfilePage(), 38 | onError: { 39 | 400: () => console.error('not exist postId'), 40 | 401: () => console.error('unthorized'), 41 | 500: '서버에서 문제가 생겼나봐요. 잠시 후에 다시 시도해주세요.' 42 | } 43 | }); 44 | 45 | const goToProfilePage = () => { 46 | alert('게시글이 삭제되었습니다.'); //TODO: 렌더링을 막지않는 모달로 띄우고 바로 페이지 이동 47 | history.push(`/profile/@${nickname}`, { targetId: id }); 48 | }; 49 | 50 | const handleDelete = () => { 51 | if (!confirm('정말 삭제하시겠어요?')) return; 52 | request(); 53 | }; 54 | 55 | return ( 56 |
57 | {isMyPost && ( 58 | <> 59 | 65 | 수정 66 | 67 | 72 | 삭제 73 | 74 | 75 | )} 76 |
77 | ); 78 | }; 79 | 80 | export default DetailPostHeader; 81 | -------------------------------------------------------------------------------- /src/components/DetailPostHeader/DetailPostHeader.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | display: flex; 5 | justify-content: flex-end; 6 | width: 100%; 7 | min-height: 2rem; 8 | margin-bottom: 2rem; 9 | padding: 0 1rem; 10 | border-bottom: 1px solid $main-color; 11 | } 12 | 13 | .btns { 14 | margin: 0.6rem 1rem; 15 | padding: 0; 16 | background-color: #fff; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Header/DropdownMenu.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import CommonLink from '../CommonLink/CommonLink'; 4 | import { useLoginContext } from '../../contexts/LoginContext'; 5 | import { profilePage } from '../../utils/utils'; 6 | import useFetch from '../../hooks/useFetch'; 7 | import api from '../../api'; 8 | import styles from './DropdownMenu.scss'; 9 | 10 | const cx = classNames.bind(styles); 11 | 12 | const DropdownMenu = ({ onClick: toggleDropdownMenu }) => { 13 | const { nickname, id, setLoggedIn, setUserInfo } = useLoginContext(); 14 | const [showsMenu, setShowsMenu] = useState(false); 15 | 16 | const resetLocalUserInfo = () => { 17 | setLoggedIn(false); 18 | setUserInfo({}); 19 | alert('로그아웃되었습니다.'); 20 | }; 21 | 22 | const serverErrorMessage = 23 | '서버에 문제가 있나봐요. 잠시 후에 다시 시도해주세요.'; 24 | 25 | const { request } = useFetch({ 26 | onRequest: api.requestLogout, 27 | onSuccess: resetLocalUserInfo, 28 | onError: { 29 | 500: serverErrorMessage 30 | } 31 | }); 32 | 33 | const handleLogout = () => { 34 | if (!confirm('로그아웃 하시겠어요?')) return; 35 | request(); 36 | }; 37 | 38 | const startOpeningAnimation = () => setShowsMenu(true); 39 | 40 | useEffect(() => { 41 | startOpeningAnimation(); 42 | }, []); 43 | 44 | return ( 45 | <> 46 |
47 |
48 | 49 |
글 작성
50 |
51 | 52 |
내 프로필
53 |
54 | 55 |
프로필 편집
56 |
57 |
58 | 로그아웃 59 |
60 |
61 | 62 | ); 63 | }; 64 | 65 | export default DropdownMenu; 66 | -------------------------------------------------------------------------------- /src/components/Header/DropdownMenu.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | $dropdown-btn-height: 3rem; 4 | $btn-quantity: 4; 5 | 6 | .background { 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | width: 100vw; 11 | height: 100vh; 12 | z-index: -1; 13 | } 14 | 15 | .wrapper { 16 | position: absolute; 17 | top: calc(#{$mobile-header-height} - 0.5rem); 18 | right: 0; 19 | width: 10rem; 20 | height: 0; 21 | border: 1px solid $main-color; 22 | border-radius: 5px; 23 | box-shadow: 0 0 10px #333; 24 | background-color: #fff; 25 | overflow: hidden; 26 | transition: height 0.2s; 27 | 28 | .btns { 29 | width: 100%; 30 | height: $dropdown-btn-height; 31 | font-size: 1.2rem; 32 | line-height: $dropdown-btn-height; 33 | border-bottom: 1px solid $main-color; 34 | text-align: center; 35 | cursor: pointer; 36 | 37 | &:hover { 38 | background-color: $point-color; 39 | } 40 | } 41 | } 42 | 43 | .animation { 44 | height: calc(#{$dropdown-btn-height} * #{$btn-quantity}); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Header/Header.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './Header.scss'; 4 | import useInput from '../../hooks/useInput'; 5 | import ProfileImage from '../ProfileImage/ProfileImage'; 6 | import CommonBtn from '../CommonBtn/CommonBtn'; 7 | import CommonModal from '../CommonModal/CommonModal'; 8 | import CommonLink from '../CommonLink/CommonLink'; 9 | import DropdownMenu from './DropdownMenu'; 10 | import useMediaQuerySet from '../../hooks/useMediaQuerySet'; 11 | import { useLoginContext } from '../../contexts/LoginContext'; 12 | import { IMAGE_BUCKET_URL } from '../../configs'; 13 | 14 | const cx = classNames.bind(styles); 15 | 16 | const Header = () => { 17 | const { isMobile } = useMediaQuerySet(); 18 | const { inputValue, handleChange, restore } = useInput(); 19 | const [showsDropdown, setShowsDropdown] = useState(false); 20 | const { 21 | loggedIn, 22 | profileImage, 23 | clickedSignup, 24 | clickedSignin, 25 | openSigninModal, 26 | closeSigninModal, 27 | toggleSignupModal 28 | } = useLoginContext(); 29 | 30 | const handleSubmit = e => { 31 | e.preventDefault(); 32 | restore('searchBar'); 33 | }; 34 | 35 | const toggleDropdownMenu = () => { 36 | setShowsDropdown(state => !state); 37 | }; 38 | 39 | return ( 40 | <> 41 |
42 |
43 |
44 |
45 | 46 | {isMobile ? ( 47 | 48 | ) : ( 49 |

Connect Flavor

50 | )} 51 |
52 |
53 | {isMobile ? ( 54 | 59 | ) : ( 60 |
61 |
62 | 67 |
68 | 73 |
74 | )} 75 |
76 | {loggedIn ? ( 77 | <> 78 | 87 | {showsDropdown && } 88 | 89 | ) : isMobile ? ( 90 | 95 | ) : ( 96 | <> 97 | 102 | 로그인 103 | 104 | 109 | 회원가입 110 | 111 | 112 | )} 113 |
114 | 115 | {!loggedIn && clickedSignin && ( 116 | 117 | )} 118 | 119 | {!loggedIn && clickedSignup && ( 120 | 121 | )} 122 |
123 |
124 | 125 | ); 126 | }; 127 | 128 | export default Header; 129 | -------------------------------------------------------------------------------- /src/components/Header/Header.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | $mobile-header-margin: 1rem; 4 | $tablet-and-desktop-header-margin: 2rem; 5 | $header-profile-image-size: 4rem; 6 | 7 | .transparent-box { 8 | height: $mobile-header-height; 9 | } 10 | 11 | //화면 크기가 커져도 헤더 내용은 가운데 정렬하고 border-bottom은 화면 전체에 그리기 위한 속성. 12 | .wrapper { 13 | position: fixed; 14 | top: 0; 15 | display: flex; 16 | justify-content: center; 17 | width: 100vw; 18 | height: $mobile-header-height; 19 | border-bottom: 1px solid #d4d4d4; 20 | background: #fff; 21 | z-index: 2; //포스트 디테일 페이지에서 헤더가 지도에 가려지지 않기 위한 속성. 22 | } 23 | 24 | .header { 25 | position: fixed; 26 | top: 0; 27 | display: flex; 28 | justify-content: flex-end; 29 | align-items: center; 30 | width: 100%; 31 | height: inherit; 32 | 33 | .title { 34 | position: absolute; 35 | top: 0; 36 | left: $mobile-header-margin; 37 | display: flex; 38 | align-items: center; 39 | width: 5.6rem; 40 | height: 100%; 41 | img { 42 | max-width: 100%; 43 | } 44 | } 45 | 46 | .searchbar-icon { 47 | width: $header-profile-image-size; 48 | height: $header-profile-image-size; 49 | padding: 0.5rem; 50 | margin-right: 0.5rem; 51 | } 52 | 53 | /* 54 | form, input : 현재 모바일에는 필요 없지만 추후 기능 추가를 위해 여기에 작성. 55 | 지금은 모바일 이외 페이지에만 렌더링 중. 56 | */ 57 | form { 58 | display: flex; 59 | height: 3.5rem; 60 | } 61 | input { 62 | border: 1px solid #d4d4d4; 63 | border-radius: 5px; 64 | padding: 0.2rem 1rem 0.2rem 2.75rem; 65 | font-size: 1.75rem; 66 | } 67 | 68 | .btns { 69 | padding-right: $mobile-header-margin; 70 | } 71 | .user-icon { 72 | width: $header-profile-image-size; 73 | height: $header-profile-image-size; 74 | padding: 0.5rem; 75 | } 76 | .profile-img { 77 | width: $header-profile-image-size; 78 | height: $header-profile-image-size; 79 | z-index: 1; 80 | &:hover { 81 | box-shadow: 0 0 5px $main-color; 82 | } 83 | } 84 | } 85 | 86 | @include tablet-and-desktop { 87 | .transparent-box, 88 | .wrapper { 89 | height: $tablet-and-desktop-header-height; 90 | } 91 | 92 | .header { 93 | justify-content: center; 94 | max-width: $desktop-page-max-width; 95 | 96 | .title { 97 | width: auto; 98 | left: $tablet-and-desktop-header-margin; 99 | h1 { 100 | margin: 0; 101 | color: $main-color; 102 | font-size: 5rem; 103 | font-family: 'godoMaum'; 104 | &:hover { 105 | color: $main-color-light; 106 | } 107 | } 108 | } 109 | 110 | .searchbar-icon-wrapper { 111 | position: relative; 112 | } 113 | .searchbar-icon { 114 | position: absolute; 115 | top: 50%; 116 | left: 0.75rem; 117 | transform: translateY(-50%); 118 | width: 1.5rem; 119 | height: 1.5rem; 120 | padding: 0; 121 | } 122 | 123 | .btns { 124 | position: absolute; 125 | right: $tablet-and-desktop-header-margin; 126 | padding-right: initial; 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/components/ImageEditor/EditorFooter.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './EditorFooter.scss'; 4 | import CommonBtn from '../CommonBtn/CommonBtn'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const EditorFooter = ({ onSave, onCancel }) => { 9 | return ( 10 |
11 | 12 | 저장 13 | 14 |
15 | 16 | 닫기 17 | 18 |
19 | ); 20 | }; 21 | 22 | export default EditorFooter; 23 | -------------------------------------------------------------------------------- /src/components/ImageEditor/EditorFooter.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | display: flex; 5 | width: 100%; 6 | } 7 | 8 | button.btns { 9 | margin: 0; 10 | width: 50%; 11 | border: none; 12 | border-top: 1px solid $gray-border-color; 13 | cursor: default; 14 | } 15 | 16 | .save-btn { 17 | border-radius: 0 0 0 5px; 18 | } 19 | 20 | .cancel-btn { 21 | border-radius: 0 0 5px 0; 22 | } 23 | 24 | .separating-line { 25 | border-right: 1px solid $gray-border-color; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ImageEditor/EditorHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './EditorHeader.scss'; 4 | import CommonBtn from '../CommonBtn/CommonBtn'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const EditorHeader = ({ onRotateLeft, onRotateRight }) => { 9 | return ( 10 |
11 | 15 | 왼쪽으로 회전 16 | 17 |
18 | 22 | 오른쪽으로 회전 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default EditorHeader; 29 | -------------------------------------------------------------------------------- /src/components/ImageEditor/EditorHeader.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | display: flex; 5 | width: 100%; 6 | } 7 | 8 | button.btns { 9 | margin: 0; 10 | width: 50%; 11 | border: none; 12 | border-bottom: 1px solid $gray-border-color; 13 | cursor: default; 14 | } 15 | 16 | .rotate-left-btn { 17 | border-radius: 5px 0 0 0; 18 | } 19 | 20 | .rotate-right-btn { 21 | border-radius: 0 5px 0 0; 22 | } 23 | 24 | .separating-line { 25 | border-right: 1px solid $gray-border-color; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ImageEditor/ImageEditor.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Suspense, lazy } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import CommonPost from '../CommonPost/CommonPost'; 4 | import EditorHeader from './EditorHeader'; 5 | import EditorFooter from './EditorFooter'; 6 | import useMediaQuerySet from '../../hooks/useMediaQuerySet'; 7 | import 'cropperjs/dist/cropper.css'; 8 | import styles from './ImageEditor.scss'; 9 | 10 | const cx = classNames.bind(styles); 11 | 12 | const Cropper = lazy(() => 13 | import(/* webpackChunkName: "react-cropper" */ 'react-cropper') 14 | ); 15 | 16 | const ImageEditor = ({ 17 | Modal, 18 | dispatch, 19 | src, 20 | originalFile: { name, type }, 21 | cropperData, 22 | targetIndex, 23 | onClose 24 | }) => { 25 | const { isMobile } = useMediaQuerySet(); 26 | const [cropper, setCropper] = useState(null); 27 | const ref = cropper => setCropper(cropper); 28 | 29 | const [isEdited, setIsEdited] = useState(false); 30 | 31 | const rotateLeft = () => { 32 | setIsEdited(true); 33 | cropper.rotate(-90); 34 | }; 35 | const rotateRight = () => { 36 | setIsEdited(true); 37 | cropper.rotate(90); 38 | }; 39 | 40 | const saveImage = () => { 41 | dispatch({ 42 | type: 'cropImage', 43 | payload: { cropper, name, type, targetIndex } 44 | }); 45 | onClose(); 46 | }; 47 | 48 | const closeEditor = () => { 49 | isEdited 50 | ? confirm('편집한 내용이 사라집니다. 창을 닫으시겠어요?') && onClose() 51 | : onClose(); 52 | }; 53 | 54 | return ( 55 | 56 | 57 | 58 | loading...
}> 59 | setIsEdited(true)} 73 | /> 74 | 75 | 76 | 77 | 78 | ); 79 | }; 80 | 81 | export default ImageEditor; 82 | -------------------------------------------------------------------------------- /src/components/ImageEditor/ImageEditor.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-between; 7 | align-items: center; 8 | width: 99vw; 9 | min-height: auto; 10 | } 11 | 12 | @include tablet-and-desktop { 13 | .wrapper { 14 | max-width: 47rem; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ImageEditor/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ImageEditor'; 2 | import * as middleware from './middleware'; 3 | export { middleware }; 4 | -------------------------------------------------------------------------------- /src/components/ImageEditor/middleware.js: -------------------------------------------------------------------------------- 1 | import { readFileAsDataURL } from '../../utils/utils'; 2 | 3 | const CANVAS_OPTIONS = { 4 | maxWidth: 4096, 5 | maxHeight: 4096, 6 | fillColor: '#fff', 7 | imageSmoothingEnabled: false, 8 | imageSmoothingQuality: 'high' 9 | }; 10 | 11 | export const cropImage = async ({ cropper, name, type, targetIndex }) => { 12 | const cropperData = cropper.getData(); 13 | const canvas = cropper.getCroppedCanvas(CANVAS_OPTIONS); //sync function 14 | const croppedFile = await convertCanvasToFile(canvas, { name, type }); 15 | const previewURL = await readFileAsDataURL(croppedFile); 16 | return { 17 | croppedFile, 18 | previewURL, 19 | cropperData, 20 | targetIndex 21 | }; 22 | }; 23 | 24 | const convertCanvasToFile = (canvasElement, { name, type }) => { 25 | return new Promise(resolve => { 26 | canvasElement.toBlob(blob => { 27 | const file = new File([blob], name, { type }); 28 | resolve(file); 29 | }, type); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './Loader.scss'; 4 | 5 | const cx = classNames.bind(styles); 6 | 7 | const Loader = ({ size, unit }) => { 8 | return ( 9 |
10 |
14 |
15 | ); 16 | }; 17 | 18 | export default Loader; 19 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | position: relative; 5 | width: 100vw; 6 | height: 100vh; 7 | } 8 | .loader { 9 | border: 3px solid $main-color-light; 10 | border-radius: 50%; 11 | border-top-color: $main-color; 12 | animation: spin 1s ease-in-out infinite; 13 | } 14 | 15 | .center { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | } 20 | 21 | @keyframes spin { 22 | to { 23 | transform: rotate(360deg); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Loader/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Loader'; 2 | -------------------------------------------------------------------------------- /src/components/MapView/InfoWindow.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames/bind'; 4 | import CloseBtn from '../CommonBtn/CloseBtn'; 5 | import styles from './InfoWindow.scss'; 6 | import { IMAGE_BUCKET_URL } from '../../configs'; 7 | 8 | const cx = classNames.bind(styles); 9 | 10 | const InfoWindow = ({ info: { name, address, link, phone } = {} }) => { 11 | return ( 12 |
13 |

14 | 15 | {name} 16 | 17 |

18 |
19 |
{address}
20 | {/* {titlePlace} 27 |
*/} 28 |
{phone}
29 | 30 | {link} 31 | 32 | 37 | 38 |
39 | ); 40 | }; 41 | 42 | export default InfoWindow; 43 | 44 | InfoWindow.propTypes = { 45 | info: PropTypes.shape({ 46 | name: PropTypes.string, 47 | address: PropTypes.string, 48 | phone: PropTypes.string, 49 | link: PropTypes.string 50 | }) 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/MapView/InfoWindow.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | position: relative; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: space-between; 8 | width: 24rem; 9 | height: 12rem; 10 | transform: translate(1rem, -14rem); 11 | padding: 1.5rem; 12 | border: 1px solid #ddd; 13 | border-radius: 5px; 14 | background-color: #fff; 15 | box-shadow: 1px 1px 10px #333; 16 | cursor: default; 17 | 18 | > a { 19 | text-decoration: none; 20 | color: #3d75cc; 21 | &:visited { 22 | color: #3d75cc; 23 | } 24 | } 25 | 26 | > hr { 27 | margin: 0; 28 | } 29 | } 30 | 31 | .location-name { 32 | margin: 0; 33 | font-size: $small-title-font-size; 34 | display: inline-block; 35 | 36 | > a { 37 | text-decoration: none; 38 | color: #000; 39 | &:visited { 40 | color: #000; 41 | } 42 | } 43 | } 44 | 45 | .arrow-image { 46 | position: absolute; 47 | top: 12rem; 48 | left: 12rem; 49 | transform: translate(5px, -2px); 50 | height: 5rem; 51 | pointer-events: none; 52 | } 53 | 54 | .close-btn { 55 | position: absolute; 56 | top: 0rem; 57 | right: 0.5rem; 58 | } 59 | 60 | @include tablet-and-desktop { 61 | .wrapper { 62 | width: 28rem; 63 | a:hover { 64 | text-decoration: underline; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/components/MapView/MapView.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { KakaoMap, Marker, CustomOverlay } from 'react-kakao-maps'; 4 | import InfoWindow from '../MapView/InfoWindow'; 5 | import useMediaQuerySet from '../../hooks/useMediaQuerySet'; 6 | 7 | const MapView = ({ 8 | data: { location: { latitude, longitude, ...info } = {} } 9 | }) => { 10 | const [infoDisplay, setInfoDisplay] = useState(true); 11 | const { isMobile } = useMediaQuerySet(); 12 | 13 | return ( 14 | 23 | 24 | {infoDisplay && ( 25 | } 27 | lat={latitude} 28 | lng={longitude} 29 | /> 30 | )} 31 | 32 | ); 33 | }; 34 | 35 | export default MapView; 36 | 37 | MapView.propTypes = { 38 | data: PropTypes.shape({ 39 | latitude: PropTypes.number, 40 | longitude: PropTypes.number 41 | }) 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/NewPostBtn/NewPostBtn.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './NewPostBtn.scss'; 4 | import ToolTip from './ToolTip.jsx'; 5 | import { NEW_POST_BTN_IMG_URL } from '../../configs'; 6 | 7 | const cx = classNames.bind(styles); 8 | 9 | const NewPostBtn = () => { 10 | return ( 11 | <> 12 | 13 | 멍멍!(어제 다녀온 곳 올리자!) 14 | 15 | 20 | 21 | ); 22 | }; 23 | 24 | export default NewPostBtn; 25 | -------------------------------------------------------------------------------- /src/components/NewPostBtn/NewPostBtn.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .new-post-btn { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | position: fixed; 8 | bottom: 10rem; 9 | right: 10rem; 10 | width: 15rem; 11 | height: 15rem; 12 | border: 1px solid $main-color; 13 | border-radius: 50%; 14 | cursor: pointer; 15 | 16 | &:hover { 17 | animation: bounce 0.75s linear; 18 | } 19 | 20 | @keyframes bounce { 21 | 0%, 22 | 50%, 23 | 100% { 24 | transform: translateY(0); 25 | } 26 | 30%, 27 | 40% { 28 | transform: translateY(-2rem); 29 | } 30 | 70%, 31 | 85% { 32 | transform: translateY(-1.5rem); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/NewPostBtn/ToolTip.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './ToolTip.scss'; 4 | 5 | const cx = classNames.bind(styles); 6 | 7 | const ToolTip = ({ place, id, children }) => { 8 | const ref = useRef(null); 9 | const [layout, setLayout] = useState(null); 10 | 11 | useEffect(() => { 12 | const { clientHeight, clientWidth } = document.documentElement; 13 | const { 14 | offsetTop, 15 | offsetLeft, 16 | offsetWidth, 17 | offsetHeight 18 | } = document.querySelector(`[data-tooltip="${id}"]`); 19 | 20 | const getLayout = () => { 21 | switch (place) { 22 | case 'top': 23 | return { 24 | bottom: `${clientHeight - offsetTop}px`, 25 | right: `${clientWidth - (offsetLeft + offsetWidth)}px`, 26 | transform: `translate(${-(offsetWidth - ref.current.offsetWidth) / 27 | 2}px, -100%)` 28 | }; 29 | // TODO: place 값에 따라 레이아웃 바꾸도록 코드 작성 30 | // case 'bottom': 31 | // return { 32 | // bottom: `${clientHeight - (offsetTop + offsetHeight)}px`, 33 | // right: `${clientWidth - (offsetLeft + offsetWidth)}px` 34 | // }; 35 | // case 'left': 36 | // return { 37 | // bottom: `${clientHeight - (offsetTop + offsetHeight)}px`, 38 | // right: `${clientWidth - (offsetLeft + offsetWidth)}px` 39 | // }; 40 | // case 'right': 41 | // return { 42 | // bottom: `${clientHeight - (offsetTop + offsetHeight)}px`, 43 | // right: `${clientWidth - (offsetLeft + offsetWidth)}px` 44 | // }; 45 | default: 46 | return { 47 | bottom: `${clientHeight - offsetTop}px`, 48 | right: `${clientWidth - (offsetLeft + offsetWidth)}px`, 49 | transform: `translate(${-(offsetWidth - ref.current.offsetWidth) / 50 | 2}px, -100%)` 51 | }; 52 | } 53 | }; 54 | 55 | const layout = getLayout(); 56 | 57 | setLayout(layout); 58 | }, [ref]); 59 | 60 | return ( 61 |
69 |
70 |
{children}
71 |
72 | ); 73 | }; 74 | 75 | export default ToolTip; 76 | -------------------------------------------------------------------------------- /src/components/NewPostBtn/ToolTip.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | $arrow-height: 13px; 4 | $arrow-width: 10px; 5 | 6 | .tooltip { 7 | &-background { 8 | background-color: #fff; 9 | border: 1px solid $main-color; 10 | border-radius: 4px; 11 | } 12 | 13 | &-arrow { 14 | position: absolute; 15 | width: 0; 16 | height: 0; 17 | bottom: calc(-#{$arrow-height} - 1px); 18 | left: 50%; 19 | margin-left: -$arrow-width; 20 | border-width: $arrow-height $arrow-width 0 $arrow-width; 21 | border-top-color: $main-color; 22 | border-right-color: transparent; 23 | border-left-color: transparent; 24 | border-style: solid; 25 | } 26 | 27 | &-label { 28 | padding: 0.25rem 1rem; 29 | font-size: 1.5rem; 30 | color: #000; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/OAuthBtn/OAuthBtn.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './OAuthBtn.scss'; 4 | import CommonBtn from '../CommonBtn/CommonBtn'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const OAuthBtn = ({ company, imgUrl, msg, href }) => { 9 | return ( 10 | 11 | 12 | 13 | {company} 계정으로 {msg} 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default OAuthBtn; 20 | -------------------------------------------------------------------------------- /src/components/OAuthBtn/OAuthBtn.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .link { 4 | display: inline-block; 5 | margin: 1.5rem 0; 6 | } 7 | 8 | .btn { 9 | margin: 0; 10 | padding-left: 0; 11 | padding-right: 0; 12 | width: 82vw; 13 | max-width: 30rem; 14 | 15 | img { 16 | width: 3rem; 17 | height: 3rem; 18 | margin-right: 1rem; 19 | } 20 | } 21 | 22 | @include tablet-and-desktop { 23 | .btn:hover { 24 | text-decoration: underline; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/PostContainer/PostContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import classNames from 'classnames/bind'; 4 | import styles from './PostContainer.scss'; 5 | import { css } from '@emotion/core'; 6 | import FadeLoader from 'react-spinners/FadeLoader'; 7 | import PostItem from '../PostItem/PostItem'; 8 | import useFetch from '../../hooks/useFetch'; 9 | import { VIEWPORT_HEIGHT, TRIGGER_POINT, MAIN_COLOR } from '../../configs'; 10 | import { throttle } from '../../utils/utils'; 11 | import api from '../../api'; 12 | 13 | const cx = classNames.bind(styles); 14 | 15 | const PostContainer = ({ headerOn, writerId = '' }) => { 16 | const history = useHistory(); 17 | const [page, setPage] = useState(1); 18 | const [response, setResponse] = useState(null); 19 | const items = response ? response.posts : null; 20 | 21 | const { loading } = useFetch({ 22 | onRequest: () => api.getPosts(page, writerId), 23 | onSuccess: json => mergeResponse(response, json), 24 | onError: { 204: () => setResponse(null) }, 25 | watch: [page, writerId], 26 | loadStatus: true 27 | }); 28 | 29 | const mergeResponse = (prevResponse, response) => { 30 | if (page === 1) { 31 | return setResponse(response); 32 | } 33 | return setResponse({ 34 | hasNextPage: response.hasNextPage, 35 | posts: [...prevResponse.posts, ...response.posts] 36 | }); 37 | }; 38 | 39 | useEffect(() => { 40 | window.addEventListener('scroll', handleScroll); 41 | return () => { 42 | window.removeEventListener('scroll', handleScroll); 43 | }; 44 | }, [loading]); 45 | 46 | const handleScroll = throttle(() => { 47 | if (!hasNextPage(response)) return; 48 | if (isScrollEnd()) { 49 | setPage(prevPage => prevPage + 1); 50 | } 51 | }); 52 | 53 | const hasNextPage = response => { 54 | return response && response.hasNextPage; 55 | }; 56 | 57 | const isScrollEnd = () => { 58 | const pageYOffset = window.pageYOffset; 59 | const documentHeight = document.body.offsetHeight; //TODO: 새로운 items를 렌더링 할 때만 값을 캐싱하도록 수정필요 60 | const scrollBottom = VIEWPORT_HEIGHT + pageYOffset; 61 | return !loading && scrollBottom + TRIGGER_POINT >= documentHeight; 62 | }; 63 | 64 | const goTo = pathname => { 65 | history.push(pathname); 66 | window.scroll(0, 0); 67 | }; 68 | 69 | return ( 70 | <> 71 |
72 | {items ? ( 73 |
74 | {items.map(item => ( 75 | goTo(`/post/${item.id}`)} 79 | {...item} 80 | /> 81 | ))} 82 |
83 | ) : ( 84 | 아직 작성한 게시글이 없어요. 85 | )} 86 |
87 | 94 | 95 | ); 96 | }; 97 | 98 | export default PostContainer; 99 | 100 | const override = css` 101 | display: block; 102 | margin: 10rem auto; 103 | `; 104 | -------------------------------------------------------------------------------- /src/components/PostContainer/PostContainer.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | display: flex; 5 | justify-content: center; 6 | width: 100%; 7 | } 8 | .no-content { 9 | font-size: 1.6rem; 10 | } 11 | .main { 12 | display: grid; 13 | width: 100%; 14 | row-gap: 2rem; 15 | } 16 | 17 | @include tablet-and-desktop { 18 | .main { 19 | grid-template-columns: 50% 50%; 20 | width: $desktop-page-max-width; 21 | row-gap: 8rem; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/PostItem/PostImage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import useIntersectionObserver from '../../hooks/useIntersectionObserver'; 4 | import styles from './PostImage.scss'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | const PostImage = ({ headerOn, src }) => { 9 | const [isLoaded, setIsLoaded] = useState(false); 10 | const target = useRef(null); 11 | 12 | const [isVisible] = useIntersectionObserver({ 13 | target, 14 | }); 15 | return ( 16 |
17 | {isVisible && ( 18 | <> 19 | setIsLoaded(true)} 22 | className={cx('img', 'full', headerOn ? '' : 'without-header')} 23 | src={src} 24 | alt="representative post image" 25 | /> 26 | 30 | {!isLoaded && ( 31 |
32 |
33 |
34 | )} 35 | 36 | )} 37 |
38 | ); 39 | }; 40 | 41 | export default PostImage; 42 | -------------------------------------------------------------------------------- /src/components/PostItem/PostImage.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: relative; 3 | padding-bottom: 100%; 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | background-color: #eeeeee; 8 | } 9 | 10 | .img { 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | .without-header { 19 | border-radius: 5px 5px 0 0; 20 | } 21 | 22 | .full { 23 | transition: opacity 0.5s ease 0s; 24 | } 25 | .thumb { 26 | filter: blur(20px); 27 | transform: scale(1.1); 28 | } 29 | 30 | .bar { 31 | width: 100%; 32 | position: absolute; 33 | animation: skeleton 1s ease-in infinite; 34 | } 35 | 36 | .indicator { 37 | width: 0; 38 | box-shadow: 0 0 75px 75px white; 39 | } 40 | 41 | .bar, 42 | .indicator { 43 | height: 100%; 44 | } 45 | 46 | @keyframes skeleton { 47 | 0% { 48 | transform: translateX(0); 49 | opacity: 0; 50 | } 51 | 52 | 20% { 53 | opacity: 0.25; 54 | } 55 | 56 | 50% { 57 | opacity: 1; 58 | } 59 | 60 | 80% { 61 | opacity: 0.5; 62 | } 63 | 64 | 100% { 65 | transform: translateX(100%); 66 | opacity: 0; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/PostItem/PostItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import ProfileImage from '../ProfileImage/ProfileImage'; 4 | import CommonLink from '../CommonLink/CommonLink'; 5 | import { profilePage } from '../../utils/utils'; 6 | import styles from './PostItem.scss'; 7 | import PostImage from './PostImage'; 8 | 9 | const cx = classNames.bind(styles); 10 | 11 | const PostItem = ({ 12 | image, 13 | place, 14 | companion, 15 | activity, 16 | description, 17 | writer: { id, profileImage, nickname } = {}, 18 | headerOn, 19 | ...props 20 | }) => { 21 | return ( 22 |
23 | {headerOn && ( 24 |
25 | 26 | 27 | 28 | 29 | {nickname} 30 | 31 |
32 | )} 33 | 34 |
35 | {place}에서 {companion} {activity} 36 |
37 |
{description}
38 |
39 | ); 40 | }; 41 | 42 | export default PostItem; 43 | -------------------------------------------------------------------------------- /src/components/PostItem/PostItem.scss: -------------------------------------------------------------------------------- 1 | @import '../../stylesheets/util.scss'; 2 | 3 | .wrapper { 4 | justify-self: center; 5 | align-self: center; 6 | width: 95%; 7 | max-width: 40rem; 8 | margin: 0.5rem; 9 | border: 1px solid #d4d4d4; 10 | border-radius: 5px; 11 | background-color: #fff; 12 | &:hover { 13 | cursor: pointer; 14 | box-shadow: 0 0 10px #a0a0a0; 15 | } 16 | 17 | .header { 18 | display: flex; 19 | align-items: center; 20 | padding: 1rem 1.5rem; 21 | img:hover { 22 | box-shadow: 0 0 5px $main-color; 23 | } 24 | span { 25 | font-size: 1.6rem; 26 | font-weight: bold; 27 | padding-left: 1rem; 28 | &:hover { 29 | text-decoration: underline; 30 | } 31 | } 32 | } 33 | 34 | .title { 35 | padding: 1rem 1.5rem 0 1.5rem; 36 | font-size: 1.6rem; 37 | } 38 | .desc { 39 | padding: 1rem 1.5rem 2rem 1.5rem; 40 | font-size: 1.5rem; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/PostUploader/DescriptionUploader.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './DescriptionUploader.scss'; 4 | 5 | const cx = classNames.bind(styles); 6 | 7 | const MAX_DESCRIPTION_BYTES = 2000; 8 | 9 | const DescriptionUploader = ({ 10 | state: { 11 | post: { description }, 12 | }, 13 | dispatch, 14 | setUploadStatus, 15 | }) => { 16 | const [showsOverLimitMessage, setShowsOverLimitMessage] = useState(false); 17 | 18 | const countBytes = (texts) => { 19 | const len = texts.length; 20 | let totalBytes = 0; 21 | 22 | for (let index = 0; index < len; index++) { 23 | const isHangeul = texts.charCodeAt(index) > 128 ? true : false; 24 | isHangeul ? (totalBytes += 2) : totalBytes++; 25 | } 26 | return totalBytes; 27 | }; 28 | 29 | const isOverLimit = (value) => { 30 | const totalBytes = countBytes(value); 31 | 32 | return totalBytes > MAX_DESCRIPTION_BYTES; 33 | }; 34 | 35 | const handleChange = ({ target: { value } }) => { 36 | dispatch({ type: 'updateDescription', payload: value }); 37 | 38 | if (isOverLimit(value)) { 39 | setShowsOverLimitMessage(true); 40 | setUploadStatus({ isOverDescLimit: true }); 41 | } else { 42 | setShowsOverLimitMessage(false); 43 | setUploadStatus({ isOverDescLimit: false }); 44 | } 45 | }; 46 | 47 | return ( 48 |
49 |