diff --git a/.github/workflows/fe-merge-dev.yml b/.github/workflows/fe-merge-dev.yml index 33e8ad8e8..a9c58c62b 100644 --- a/.github/workflows/fe-merge-dev.yml +++ b/.github/workflows/fe-merge-dev.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: - branches: [develop] + branches: [develop-FE] types: [closed] paths: frontend/** diff --git a/.github/workflows/fe-pull-request.yml b/.github/workflows/fe-pull-request.yml index 25e16481f..909a0170c 100644 --- a/.github/workflows/fe-pull-request.yml +++ b/.github/workflows/fe-pull-request.yml @@ -1,15 +1,17 @@ name: Frontend CI For Test Validation -# 어떤 이벤트가 발생하면 실행할지 결정 + on: - #pull request open과 reopen 시 실행한다. + # pull request open과 reopen 시 실행한다. pull_request: - branches: [main, develop] + branches: [main, develop-FE] paths: frontend/** + defaults: run: working-directory: ./frontend + jobs: - jest: + jest_cypress: runs-on: ubuntu-22.04 steps: @@ -19,10 +21,16 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: "18" + node-version: '18' - name: Install node modules run: npm install - name: Run Jest test run: npm run test + + - name: Run Cypress + uses: cypress-io/github-action@v5 + with: + working-directory: frontend + start: npm run dev diff --git a/frontend/babel.config.js b/frontend/babel.config.js index fda6611d4..2c635521b 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -1,15 +1,16 @@ module.exports = { presets: [ [ - "@babel/preset-env", + '@babel/preset-env', { targets: { - browsers: ["last 2 versions", "not dead", "not ie <= 11"], + browsers: ['last 2 versions', 'not dead', 'not ie <= 11'], }, + modules: false, }, ], - ["@babel/preset-react"], - "@babel/preset-typescript", + ['@babel/preset-react'], + '@babel/preset-typescript', ], - plugins: ["babel-plugin-styled-components"], + plugins: ['babel-plugin-styled-components'], }; diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 5bed49b88..d7ec6ab28 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', + defaultCommandTimeout: 20000, }, }); diff --git a/frontend/cypress/e2e/mapbefine.cy.ts b/frontend/cypress/e2e/mapbefine.cy.ts index cb5a74529..cbb675fe2 100644 --- a/frontend/cypress/e2e/mapbefine.cy.ts +++ b/frontend/cypress/e2e/mapbefine.cy.ts @@ -69,12 +69,34 @@ describe('토픽 상세 페이지', () => { if (index === 0) $el.click(); }); - cy.get('li') + cy.wait(5000); + + cy.get('span').each(($el, index) => { + if (index === 6) $el.click(); + }); + + cy.get('[data-cy="pin-detail"]').scrollTo('bottom'); + + cy.contains('내 지도에 저장하기').should('be.visible'); + }); + + it('핀 상세 페이지에서 내 지도에 저장하기 버튼 누르면 토스트 메시지가 나온다.', () => { + cy.get('[data-cy="topic-card"]') .children() .each(($el, index) => { if (index === 0) $el.click(); }); - cy.contains('내 지도에 저장하기').should('be.visible'); + cy.wait(5000); + + cy.get('span').each(($el, index) => { + if (index === 6) $el.click(); + }); + + cy.get('[data-cy="pin-detail"]').scrollTo('bottom'); + + cy.contains('내 지도에 저장하기').click(); + + cy.contains('로그인 후 사용해주세요.').should('be.visible'); }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ba9f5d188..2ae903e98 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mapbefine", - "version": "1.0.0", + "version": "0.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mapbefine", - "version": "1.0.0", + "version": "0.8.0", "license": "ISC", "dependencies": { "@types/react-router-dom": "^5.3.3", @@ -40,6 +40,7 @@ "@types/styled-components": "^5.1.26", "babel-loader": "^9.1.3", "babel-plugin-styled-components": "^2.1.4", + "browser-image-compression": "^2.0.2", "chromatic": "^6.19.9", "cypress": "^12.17.4", "dotenv-webpack": "^8.0.1", @@ -8858,8 +8859,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -8875,9 +8874,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/ajv-keywords": { "version": "3.5.2", @@ -9805,6 +9802,15 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "dev": true, + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -23319,6 +23325,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -30430,14 +30442,15 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, - "requires": {}, + "requires": { + "ajv": "^8.0.0" + }, "dependencies": { "ajv": { - "version": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "optional": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -30449,9 +30462,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "optional": true, - "peer": true + "dev": true } } }, @@ -31166,6 +31177,15 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "dev": true, + "requires": { + "uzip": "0.20201231.0" + } + }, "browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -41316,6 +41336,12 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true }, + "uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "dev": true + }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 43bd1a1f6..0c2b0d3e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "@types/styled-components": "^5.1.26", "babel-loader": "^9.1.3", "babel-plugin-styled-components": "^2.1.4", + "browser-image-compression": "^2.0.2", "chromatic": "^6.19.9", "cypress": "^12.17.4", "dotenv-webpack": "^8.0.1", diff --git a/frontend/src/__tests__/hooks/useFormValues.test.ts b/frontend/src/__tests__/hooks/useFormValues.test.ts index 00db0c230..877c6483e 100644 --- a/frontend/src/__tests__/hooks/useFormValues.test.ts +++ b/frontend/src/__tests__/hooks/useFormValues.test.ts @@ -2,20 +2,52 @@ import { renderHook } from '@testing-library/react'; import useFormValues from '../../hooks/useFormValues'; import { act } from 'react-dom/test-utils'; -describe('useFormValues 테스트', () => { +interface FormValuesProps { + name: string; + description: string; +} + +describe('useFormValues 훅 초기화 및 수정 테스트', () => { test('매개변수로 받은 초기값을 정상적으로 반환하는지 확인한다.', () => { - const { result } = renderHook(() => useFormValues('토픽 이름')); + const { result } = renderHook(() => + useFormValues({ + name: '선릉', + description: '선릉이란 무엇일까요?', + }), + ); - expect(result.current.formValues).toBe('토픽 이름'); + expect(result.current.formValues.name).toBe('선릉'); }); test('setFormValues를 통해 set할 수 있는지 확인하다.', () => { - const { result } = renderHook(() => useFormValues('토픽 이름')); + const { result } = renderHook(() => + useFormValues({ + name: '잠실역 주변 맛집', + description: '선릉역 주변에서 먹을 만한 곳들 모음집입니다.', + }), + ); act(() => { - result.current.setFormValues('인기 있는 토픽'); + result.current.setFormValues((prevState) => ({ + ...prevState, + description: '잠실역 주변에서 맛있고 유명한 곳들을 모아봤습니다.', + })); }); - expect(result.current.formValues).toBe('인기 있는 토픽'); + expect(result.current.formValues.description).toBe( + '잠실역 주변에서 맛있고 유명한 곳들을 모아봤습니다.', + ); + }); + + test('입력 받은 초기값 타입 형식에 맞게 에러 객체를 반환하는지 확인한다.', () => { + const { result } = renderHook(() => + useFormValues({ + name: '혼자 돌기 좋은 서울 산책로', + description: + '조용하고 혼자 사색에 잠겨 산책할 수 있는 코스들 모음입니다.', + }), + ); + + expect(result.current.errorMessages).toEqual({ name: '', description: '' }); }); }); diff --git a/frontend/src/__tests__/hooks/validation.test.ts b/frontend/src/__tests__/hooks/validation.test.ts new file mode 100644 index 000000000..946cb7a81 --- /dev/null +++ b/frontend/src/__tests__/hooks/validation.test.ts @@ -0,0 +1,139 @@ +import { renderHook } from '@testing-library/react'; +import { + hasErrorMessage, + hasNullValue, + validateCurse, + validatePolitically, +} from '../../validations'; +import useFormValues from '../../hooks/useFormValues'; +import { act } from 'react-dom/test-utils'; + +interface FormValuesProps { + name: string; + description: string; +} + +describe('사용자 입력 유효성 검사 테스트', () => { + test('validateCure 함수는 사용자가 욕설을 입력할 경우 true를 반환한다.', () => { + const USER_INPUT = '개새끼'; + + const result = validateCurse(USER_INPUT); + + expect(result).toBe(true); + }); + + test('validateCure 함수는 사용자가 입력한 값 중에 욕설이 포함되어 있으면 true를 반환한다.', () => { + const USER_INPUT = '달동네 개쓰레기들 모음'; + + const result = validateCurse(USER_INPUT); + + expect(result).toBe(true); + }); + + test('validatePolitically 함수는 사용자가 사회적 문제의 단어를 입력한 경우 true를 반환한다.', () => { + const USER_INPUT = '월북'; + + const result = validatePolitically(USER_INPUT); + + expect(result).toBe(true); + }); + + test('validatePolitically 함수는 사용자가 입력한 갑 중에 사회적 문제의 단어가 포함되어 있으면 true를 반환한다.', () => { + const USER_INPUT = '괴뢰군 2023년 최신 루트'; + + const result = validatePolitically(USER_INPUT); + + expect(result).toBe(true); + }); + + test('hasErrorMessage 함수는 사용자가 입력한 값 중 하나라도 유효성 검사에 걸리면 true를 반환한다.', () => { + const { result } = renderHook(() => + useFormValues({ + name: '', + description: '', + }), + ); + const userInput = { + target: { + name: 'name', + value: '개새끼', + }, + } as React.ChangeEvent; + + act(() => { + result.current.onChangeInput(userInput, true, 20); + }); + + const hasErrorMessageResult = hasErrorMessage(result.current.errorMessages); + + expect(hasErrorMessageResult).toBe(true); + }); + + test('hasErrorMessage 함수는 필수값 항목을 입력하지 않았을 경우 true를 반환한다.', () => { + const { result } = renderHook(() => + useFormValues({ + name: '', + description: '', + }), + ); + const userInput = { + target: { + name: 'name', + value: '', + }, + } as React.ChangeEvent; + + act(() => { + result.current.onChangeInput(userInput, true, 20); + }); + + const hasErrorMessageResult = hasErrorMessage(result.current.errorMessages); + + expect(hasErrorMessageResult).toBe(true); + }); + + test('hasErrorMessage 함수는 필수값이 아닌 항목을 입력하지 않으면 false를 반환한다.', () => { + const { result } = renderHook(() => + useFormValues({ + name: '', + description: '', + }), + ); + const userInput = { + target: { + name: 'name', + value: '', + }, + } as React.ChangeEvent; + + act(() => { + result.current.onChangeInput(userInput, false, 20); + }); + + const hasErrorMessageResult = hasErrorMessage(result.current.errorMessages); + + expect(hasErrorMessageResult).toBe(false); + }); + + test('hasNullValue 함수는 사용자가 아무것도 입력하지 않았을 경우 true를 반환한다.', () => { + const formValues = { + name: '', + description: '', + }; + + const result = hasNullValue(formValues); + + expect(result).toBe(true); + }); + + test('hasNullValue 함수는 사용자가 지정한 키의 값은 빈 값으로 두어도 false를 반환한다.', () => { + const formValues = { + name: '', + description: '선릉역 맛집 리스트입니다.', + }; + + const result = hasNullValue(formValues, 'name'); + + expect(result).toBe(false); + }); +}); diff --git a/frontend/src/apiHooks/useDelete.ts b/frontend/src/apiHooks/useDelete.ts new file mode 100644 index 000000000..2792552c1 --- /dev/null +++ b/frontend/src/apiHooks/useDelete.ts @@ -0,0 +1,39 @@ +import { deleteApi } from '../apis/deleteApi'; +import useToast from '../hooks/useToast'; +import { ContentTypeType } from '../types/Api'; + +interface fetchDeleteProps { + url: string; + errorMessage: string; + contentType?: ContentTypeType; + onSuccess?: () => void; + isThrow?: boolean; +} + +const useDelete = () => { + const { showToast } = useToast(); + + const fetchDelete = async ({ + url, + contentType, + errorMessage, + onSuccess, + isThrow, + }: fetchDeleteProps) => { + try { + await deleteApi(url, contentType); + + if (onSuccess) { + onSuccess(); + } + } catch (e) { + showToast('error', errorMessage); + + if (isThrow) throw e; + } + }; + + return { fetchDelete }; +}; + +export default useDelete; diff --git a/frontend/src/apiHooks/useGet.ts b/frontend/src/apiHooks/useGet.ts new file mode 100644 index 000000000..f55b4cc02 --- /dev/null +++ b/frontend/src/apiHooks/useGet.ts @@ -0,0 +1,23 @@ +import { getApi } from '../apis/getApi'; +import useToast from '../hooks/useToast'; + +const useGet = () => { + const { showToast } = useToast(); + + const fetchGet = async ( + url: string, + errorMessage: string, + onSuccess: (responseData: T) => void, + ) => { + try { + const responseData = await getApi(url); + onSuccess(responseData); + } catch (e) { + showToast('error', errorMessage); + } + }; + + return { fetchGet }; +}; + +export default useGet; diff --git a/frontend/src/apiHooks/usePost.ts b/frontend/src/apiHooks/usePost.ts new file mode 100644 index 000000000..70cbda53d --- /dev/null +++ b/frontend/src/apiHooks/usePost.ts @@ -0,0 +1,43 @@ +import { postApi } from '../apis/postApi'; +import useToast from '../hooks/useToast'; +import { ContentTypeType } from '../types/Api'; + +interface fetchPostProps { + url: string; + payload?: {} | FormData; + contentType?: ContentTypeType; + errorMessage: string; + onSuccess?: () => void; + isThrow?: boolean; +} + +const usePost = () => { + const { showToast } = useToast(); + + const fetchPost = async ({ + url, + payload, + contentType, + errorMessage, + onSuccess, + isThrow, + }: fetchPostProps) => { + try { + const responseData = await postApi(url, payload, contentType); + + if (onSuccess) { + onSuccess(); + } + + return responseData; + } catch (e) { + showToast('error', errorMessage); + + if (isThrow) throw e; + } + }; + + return { fetchPost }; +}; + +export default usePost; diff --git a/frontend/src/apiHooks/usePut.ts b/frontend/src/apiHooks/usePut.ts new file mode 100644 index 000000000..a263afdbd --- /dev/null +++ b/frontend/src/apiHooks/usePut.ts @@ -0,0 +1,43 @@ +import { putApi } from '../apis/putApi'; +import useToast from '../hooks/useToast'; +import { ContentTypeType } from '../types/Api'; + +interface fetchPutProps { + url: string; + payload: {}; + errorMessage: string; + contentType?: ContentTypeType; + onSuccess?: () => void; + isThrow?: boolean; +} + +const usePut = () => { + const { showToast } = useToast(); + + const fetchPut = async ({ + url, + payload, + contentType, + errorMessage, + onSuccess, + isThrow, + }: fetchPutProps) => { + try { + const responseData = await putApi(url, payload, contentType); + + if (onSuccess) { + onSuccess(); + } + + return responseData; + } catch (e) { + showToast('error', errorMessage); + + if (isThrow) throw e; + } + }; + + return { fetchPut }; +}; + +export default usePut; diff --git a/frontend/src/apis/deleteApi.ts b/frontend/src/apis/deleteApi.ts index 0cf943fbb..3ce0f2152 100644 --- a/frontend/src/apis/deleteApi.ts +++ b/frontend/src/apis/deleteApi.ts @@ -1,16 +1,35 @@ -// const API_URL = -// process.env.NODE_ENV === 'production' -// ? process.env.REACT_APP_API_DEFAULT_PROD -// : process.env.REACT_APP_API_DEFAULT_DEV; - import { DEFAULT_PROD_URL } from '../constants'; +import { ContentTypeType } from '../types/Api'; +import withTokenRefresh from './utils'; + +interface Headers { + 'content-type': string; + [key: string]: string; +} + +export const deleteApi = async (url: string, contentType?: ContentTypeType) => { + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + const headers: Headers = { + 'content-type': 'application/json', + }; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + if (contentType) { + headers['content-type'] = contentType; + } + + const response = await fetch(apiUrl, { + method: 'DELETE', + headers, + }); -export const deleteApi = async (url: string, contentType?: string) => { - await fetch(`${DEFAULT_PROD_URL + url}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${localStorage.getItem('userToken') || ''}`, - 'Content-Type': contentType || 'application/json', - }, + if (response.status >= 400) { + throw new Error('[SERVER] DELETE 요청에 실패했습니다.'); + } }); }; diff --git a/frontend/src/apis/getApi.ts b/frontend/src/apis/getApi.ts index 804e138e1..f4c99b910 100644 --- a/frontend/src/apis/getApi.ts +++ b/frontend/src/apis/getApi.ts @@ -1,36 +1,25 @@ -// const API_URL = -// process.env.NODE_ENV === 'production' -// ? process.env.REACT_APP_API_DEFAULT_PROD -// : process.env.REACT_APP_API_DEFAULT_DEV; - import { DEFAULT_PROD_URL } from '../constants'; +import withTokenRefresh from './utils'; + +export const getApi = async (url: string) => { + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + const headers: any = { + 'content-type': 'application/json', + }; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + const response = await fetch(apiUrl, { method: 'GET', headers }); -interface Headers { - 'Content-Type': string; - [key: string]: string; -} -export const getApi = async ( - type: 'tMap' | 'default' | 'login', - url: string, -): Promise => { - const apiUrl = - type === 'tMap' || type === 'login' ? url : `${DEFAULT_PROD_URL + url}`; + if (response.status >= 400) { + throw new Error('[SERVER] GET 요청에 실패했습니다.'); + } - const userToken = localStorage.getItem('userToken'); - const headers: Headers = { - 'Content-Type': 'application/json', - }; - if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; - } - const response = await fetch(apiUrl, { - method: 'GET', - headers: headers, + const responseData: T = await response.json(); + return responseData; }); - const responseData: T = await response.json(); - if (response.status >= 400) { - //todo: status 상태별로 로그인 토큰 유효 검증 - throw new Error('API 요청에 실패했습니다.'); - } - return responseData; }; diff --git a/frontend/src/apis/getLoginApi.ts b/frontend/src/apis/getLoginApi.ts new file mode 100644 index 000000000..2466e95a1 --- /dev/null +++ b/frontend/src/apis/getLoginApi.ts @@ -0,0 +1,16 @@ +export const getLoginApi = async (url: string) => { + const response = await fetch(url, { + method: 'GET', + headers: { + 'content-type': 'application/json', + }, + }); + + if (response.status >= 400) { + throw new Error('[KAKAO] GET 요청에 실패했습니다.'); + } + + const responseData: T = await response.json(); + + return responseData; +}; diff --git a/frontend/src/apis/getMapApi.ts b/frontend/src/apis/getMapApi.ts index 5e552b9d7..c68832783 100644 --- a/frontend/src/apis/getMapApi.ts +++ b/frontend/src/apis/getMapApi.ts @@ -1,13 +1,16 @@ -export const getMapApi = (url: string) => - fetch(url, { +export const getMapApi = async (url: string) => { + const response = await fetch(url, { method: 'GET', headers: { - 'Content-type': 'application/json', + 'content-type': 'application/json', }, - }) - .then((data) => { - return data.json(); - }) - .catch((error) => { - throw new Error(`${error.message}`); - }); + }); + + if (response.status >= 400) { + throw new Error('[MAP] GET 요청에 실패했습니다.'); + } + + const responseData: T = await response.json(); + + return responseData; +}; diff --git a/frontend/src/apis/postApi.ts b/frontend/src/apis/postApi.ts index 0b4284b88..8df139ce6 100644 --- a/frontend/src/apis/postApi.ts +++ b/frontend/src/apis/postApi.ts @@ -1,31 +1,58 @@ -// const API_URL = -// process.env.NODE_ENV === 'production' -// ? process.env.REACT_APP_API_DEFAULT_PROD -// : process.env.REACT_APP_API_DEFAULT_DEV; - import { DEFAULT_PROD_URL } from '../constants'; +import { ContentTypeType } from '../types/Api'; +import withTokenRefresh from './utils'; + +export const postApi = async ( + url: string, + payload?: {} | FormData, + contentType?: ContentTypeType, +) => { + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + + if (payload instanceof FormData) { + const headers: any = {}; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: payload, + }); + + if (response.status >= 400) { + throw new Error('[SERVER] POST 요청에 실패했습니다.'); + } + + return response; + } + + const headers: any = { + 'content-type': 'application/json', + }; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + if (contentType) { + headers['content-type'] = contentType; + } + + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (response.status >= 400) { + throw new Error('[SERVER] POST 요청에 실패했습니다.'); + } -interface Headers { - 'Content-Type': string; - [key: string]: string; -} -export const postApi = async (url: string, data?: {}, contentType?: string) => { - const userToken = localStorage.getItem('userToken'); - const headers: Headers = { - 'Content-Type': `${contentType || 'application/json'}`, - }; - if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; - } - - const response = await fetch(`${DEFAULT_PROD_URL + url}`, { - method: 'POST', - headers: headers, - body: JSON.stringify(data), + return response; }); - if (response.status >= 400) { - //todo: status 상태별로 로그인 토큰 유효 검증 - throw new Error('API 요청에 실패했습니다.'); - } - return response; }; diff --git a/frontend/src/apis/putApi.ts b/frontend/src/apis/putApi.ts index 5fffb8725..6335503c9 100644 --- a/frontend/src/apis/putApi.ts +++ b/frontend/src/apis/putApi.ts @@ -1,33 +1,37 @@ -// const API_URL = -// process.env.NODE_ENV === 'production' -// ? process.env.REACT_APP_API_DEFAULT_PROD -// : process.env.REACT_APP_API_DEFAULT_DEV; - import { DEFAULT_PROD_URL } from '../constants'; +import { ContentTypeType } from '../types/Api'; +import withTokenRefresh from './utils'; -interface Headers { - 'Content-Type': string; - [key: string]: string; -} export const putApi = async ( url: string, - data: { name: string; images: string[]; description: string }, + data: {}, + contentType?: ContentTypeType, ) => { - const userToken = localStorage.getItem('userToken'); - const headers: Headers = { - 'Content-Type': 'application/json', - }; - if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; - } + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + const headers: any = { + 'content-type': 'application/json', + }; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + if (contentType) { + headers['content-type'] = contentType; + } + + const response = await fetch(apiUrl, { + method: 'PUT', + headers, + body: JSON.stringify(data), + }); + + if (response.status >= 400) { + throw new Error('[SERVER] PUT 요청에 실패했습니다.'); + } - const response = await fetch(`${DEFAULT_PROD_URL + url}`, { - method: 'PUT', - headers: headers, - body: JSON.stringify(data), + return response; }); - if (response.status >= 400) { - throw new Error('API 요청에 실패했습니다.'); - } - return response; }; diff --git a/frontend/src/apis/utils.ts b/frontend/src/apis/utils.ts new file mode 100644 index 000000000..c5b69af36 --- /dev/null +++ b/frontend/src/apis/utils.ts @@ -0,0 +1,84 @@ +import { DEFAULT_PROD_URL } from '../constants'; + +let refreshResponse: Promise | null = null; + +const decodeToken = (token: string) => { + const tokenParts = token.split('.'); + if (tokenParts.length !== 3) { + throw new Error('토큰이 잘못되었습니다.'); + } + + const decodedPayloadString = atob(tokenParts[1]); + + return JSON.parse(decodedPayloadString); +}; + +async function refreshToken(headers: Headers): Promise { + if (refreshResponse !== null) { + return refreshResponse; + } + + const accessToken = localStorage.getItem('userToken'); + try { + // 서버에 새로운 엑세스 토큰을 요청하기 위한 네트워크 요청을 보냅니다. + refreshResponse = fetch(`${DEFAULT_PROD_URL}/refresh-token`, { + method: 'POST', + headers, + body: JSON.stringify({ + accessToken: accessToken, + }), + }); + + const responseData = await refreshResponse; + refreshResponse = null; + + // 서버 응답이 성공적인지 확인합니다. + if (!responseData.ok) { + throw new Error('Failed to refresh access token.'); + } + + // 새로운 엑세스 토큰을 반환합니다. + return responseData; + } catch (error) { + // 네트워크 요청 실패 또는 예외 발생 시 예외를 캐치하여 처리합니다. + console.error('네트워크 요청 실패 또는 예외 발생:', error); + throw error; // 예외를 다시 throw하여 상위 코드로 전파합니다. + } +} + +const isTokenExpired = (token: string) => { + const decodedPayloadObject = decodeToken(token); + return decodedPayloadObject.exp * 1000 < Date.now(); +}; + +async function updateToken(headers: Headers) { + const response = await refreshToken(headers); + const responseCloned = response.clone(); + + try { + const newToken = await responseCloned.json(); + + localStorage.setItem('userToken', newToken.accessToken); + } catch (e) { + console.error(e); + + return; + } +} + +export default async function withTokenRefresh( + callback: () => Promise, +): Promise { + const userToken = localStorage.getItem('userToken'); + + if (userToken && isTokenExpired(userToken)) { + const headers: any = { + 'content-type': 'application/json', + Authorization: `Bearer ${userToken}`, + }; + + await updateToken(headers); + } + + return callback(); +} diff --git a/frontend/src/assets/ModifyMyInfoIcon.svg b/frontend/src/assets/ModifyMyInfoIcon.svg deleted file mode 100644 index dbc9b1b95..000000000 --- a/frontend/src/assets/ModifyMyInfoIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/src/assets/My.svg b/frontend/src/assets/My.svg deleted file mode 100644 index 509ca1a2e..000000000 --- a/frontend/src/assets/My.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/src/assets/InfoDefalutImg.svg b/frontend/src/assets/profile_defaultImage.svg similarity index 100% rename from frontend/src/assets/InfoDefalutImg.svg rename to frontend/src/assets/profile_defaultImage.svg diff --git a/frontend/src/components/AddFavorite/index.tsx b/frontend/src/components/AddFavorite/index.tsx index 5fc7dc60f..fe46e1c3a 100644 --- a/frontend/src/components/AddFavorite/index.tsx +++ b/frontend/src/components/AddFavorite/index.tsx @@ -6,14 +6,14 @@ import { deleteApi } from '../../apis/deleteApi'; interface AddFavoriteProps { id: number; isBookmarked: boolean; - setTopicsFromServer: () => void; + getTopicsFromServer: () => void; children: React.ReactNode; } const AddFavorite = ({ id, isBookmarked, - setTopicsFromServer, + getTopicsFromServer, children, }: AddFavoriteProps) => { const { showToast } = useToast(); @@ -24,7 +24,7 @@ const AddFavorite = ({ try { await postApi(`/bookmarks/topics?id=${id}`, {}, 'x-www-form-urlencoded'); - setTopicsFromServer(); + getTopicsFromServer(); showToast('info', '즐겨찾기에 추가되었습니다.'); } catch { @@ -38,18 +38,12 @@ const AddFavorite = ({ try { await deleteApi(`/bookmarks/topics?id=${id}`, 'x-www-form-urlencoded'); - setTopicsFromServer(); + getTopicsFromServer(); showToast('info', '해당 지도를 즐겨찾기에서 제외했습니다.'); } catch { showToast('error', '로그인 후 사용해주세요.'); } - - await deleteApi(`/bookmarks/topics?id=${id}`, 'x-www-form-urlencoded'); - - setTopicsFromServer(); - - showToast('info', '해당 지도를 즐겨찾기에서 제외했습니다.'); }; return ( diff --git a/frontend/src/components/AddSeeTogether/index.tsx b/frontend/src/components/AddSeeTogether/index.tsx index 401c3e401..9acfed43e 100644 --- a/frontend/src/components/AddSeeTogether/index.tsx +++ b/frontend/src/components/AddSeeTogether/index.tsx @@ -3,7 +3,7 @@ import { postApi } from '../../apis/postApi'; import useToast from '../../hooks/useToast'; import { useContext } from 'react'; import { getApi } from '../../apis/getApi'; -import { TopicType } from '../../types/Topic'; +import { TopicCardProps } from '../../types/Topic'; import { SeeTogetherContext } from '../../context/SeeTogetherContext'; import { deleteApi } from '../../apis/deleteApi'; @@ -11,14 +11,14 @@ interface AddSeeTogetherProps { isInAtlas: boolean; id: number; children: React.ReactNode; - setTopicsFromServer: () => void; + getTopicsFromServer: () => void; } const AddSeeTogether = ({ isInAtlas, id, children, - setTopicsFromServer, + getTopicsFromServer, }: AddSeeTogetherProps) => { const { showToast } = useToast(); const { seeTogetherTopics, setSeeTogetherTopics } = @@ -35,12 +35,12 @@ const AddSeeTogether = ({ await postApi(`/atlas/topics?id=${id}`, {}, 'x-www-form-urlencoded'); - const topics = await getApi('default', '/members/my/atlas'); + const topics = await getApi('/members/my/atlas'); setSeeTogetherTopics(topics); // TODO: 모아보기 페이지에서는 GET /members/my/atlas 두 번 됨 - setTopicsFromServer(); + getTopicsFromServer(); showToast('info', '모아보기에 추가했습니다.'); } catch { @@ -54,12 +54,12 @@ const AddSeeTogether = ({ try { await deleteApi(`/atlas/topics?id=${id}`, 'x-www-form-urlencoded'); - const topics = await getApi('default', '/members/my/atlas'); + const topics = await getApi('/members/my/atlas'); setSeeTogetherTopics(topics); // TODO: 모아보기 페이지에서는 GET /members/my/atlas 두 번 됨 - setTopicsFromServer(); + getTopicsFromServer(); showToast('info', '해당 지도를 모아보기에서 제외했습니다.'); } catch { diff --git a/frontend/src/components/AuthorityRadioContainer/index.tsx b/frontend/src/components/AuthorityRadioContainer/index.tsx new file mode 100644 index 000000000..f1458b2cf --- /dev/null +++ b/frontend/src/components/AuthorityRadioContainer/index.tsx @@ -0,0 +1,301 @@ +import styled from 'styled-components'; +import Text from '../common/Text'; +import Space from '../common/Space'; +import Flex from '../common/Flex'; +import { useContext, useEffect, useState } from 'react'; +import { + TopicAuthorMember, + TopicAuthorMemberWithAuthorId, +} from '../../types/Topic'; +import { ModalContext } from '../../context/ModalContext'; +import Box from '../common/Box'; +import Modal from '../Modal'; +import Button from '../common/Button'; +import Checkbox from '../common/CheckBox'; +import useGet from '../../apiHooks/useGet'; + +interface AuthorityRadioContainer { + isPrivate: boolean; + isAllPermissioned: boolean; + authorizedMemberIds: number[]; + setIsPrivate: React.Dispatch>; + setIsAllPermissioned: React.Dispatch>; + setAuthorizedMemberIds: React.Dispatch>; + permissionedMembers?: TopicAuthorMemberWithAuthorId[]; +} + +const AuthorityRadioContainer = ({ + isPrivate, + isAllPermissioned, + authorizedMemberIds, + setIsPrivate, + setIsAllPermissioned, + setAuthorizedMemberIds, + permissionedMembers, +}: AuthorityRadioContainer) => { + const { openModal, closeModal } = useContext(ModalContext); + const { fetchGet } = useGet(); + + const [members, setMembers] = useState([]); + const viewPrevAuthorMembersCondition = + authorizedMemberIds.length === 0 && !isAllPermissioned; + + useEffect(() => { + fetchGet( + '/members', + '사용자 목록을 가져오는데 실패했습니다.', + (response) => { + setMembers(response); + }, + ); + }, []); + + const onChangeInitAuthMembers = () => { + setIsAllPermissioned(false); + openModal('newTopic'); + setAuthorizedMemberIds([]); + }; + + const onChangeInitAuthMembersWithSetIsAllPermissioned = () => { + setIsAllPermissioned(true); + setAuthorizedMemberIds([]); + }; + + const onChangeMemberChecked = (isChecked: boolean, id: number) => { + setAuthorizedMemberIds((prev: TopicAuthorMember['id'][]) => + isChecked ? [...prev, id] : prev.filter((n: number) => n !== id), + ); + }; + + return ( + <> + + 지도 종류 + + + + + setIsPrivate(false)} + tabIndex={4} + /> + + + + + + + setIsPrivate(true)} + tabIndex={4} + /> + + + + + + + + + 핀 생성 및 수정 권한 부여 + + + + + + + + + {isPrivate ? ( + + ) : ( + + )} + + + + + { + isAllPermissioned === false && openModal('newTopic'); + }} + tabIndex={5} + /> + + + + + {authorizedMemberIds.length > 0 && ( + <> + + + + + 선택한 친구들 + + + {members.map((member) => { + if (authorizedMemberIds.includes(member.id)) + return ( + + • {member.nickName} + + ); + })} + + + )} + + {permissionedMembers && viewPrevAuthorMembersCondition && ( + <> + + + + + 이전에 권한을 부여한 친구들 + + + {permissionedMembers.length > 0 ? ( + permissionedMembers.map((member) => ( + + • {member.memberResponse.nickName} + + )) + ) : ( + + • 없음 + + )} + + + )} + + + + + + 멤버 선택 + + + {authorizedMemberIds.length}명 선택됨 + + + + + {members.map((member) => ( + + + + ))} + + + + + + + + + + + + + + + + ); +}; + +const ModalContentsWrapper = styled.div` + width: 100%; + height: 100%; + background-color: white; + display: flex; + flex-direction: column; +`; + +const CheckboxList = styled.div` + flex: 1; + overflow-y: scroll; +`; + +const CheckboxListItem = styled.div` + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin-bottom: 1rem; + padding: 1rem; + border-radius: 5px; + background-color: white; + + &:last-child { + margin-bottom: 0; + border-bottom: none; + } + + &:hover { + background-color: #f8f9fa; + } +`; + +export default AuthorityRadioContainer; diff --git a/frontend/src/components/BookmarksList/index.tsx b/frontend/src/components/BookmarksList/index.tsx deleted file mode 100644 index aea2420cb..000000000 --- a/frontend/src/components/BookmarksList/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Fragment, useEffect, useState } from 'react'; -import { styled } from 'styled-components'; -import { getApi } from '../../apis/getApi'; -import TopicCard from '../TopicCard'; -import { TopicType } from '../../types/Topic'; -import useToast from '../../hooks/useToast'; - -interface BookmarksListProps { - bookmarks: TopicType[]; - setTopicsFromServer: () => void; -} - -const BookmarksList = ({ - bookmarks, - setTopicsFromServer, -}: BookmarksListProps) => { - return ( - - {bookmarks.map((topic) => ( - - - - ))} - - ); -}; - -const Wrapper = styled.ul` - display: flex; - flex-wrap: wrap; - gap: 20px; -`; - -export default BookmarksList; diff --git a/frontend/src/components/InputContainer/index.tsx b/frontend/src/components/InputContainer/index.tsx index 74f53c733..7cc764349 100644 --- a/frontend/src/components/InputContainer/index.tsx +++ b/frontend/src/components/InputContainer/index.tsx @@ -111,7 +111,7 @@ const InputContainer = ({ }; const ErrorText = styled.span` display: block; - height: 20px; + min-height: 20px; font-size: 14px; color: #ff4040; `; diff --git a/frontend/src/components/Layout/Navbar.tsx b/frontend/src/components/Layout/Navbar.tsx index 10de9879f..085afe282 100644 --- a/frontend/src/components/Layout/Navbar.tsx +++ b/frontend/src/components/Layout/Navbar.tsx @@ -1,10 +1,9 @@ import { useContext } from 'react'; -import { styled } from 'styled-components'; +import { css, styled } from 'styled-components'; import useNavigator from '../../hooks/useNavigator'; import Flex from '../common/Flex'; import Button from '../common/Button'; import Space from '../common/Space'; -import Text from '../common/Text'; import Home from '../../assets/nav_home.svg'; import SeeTogether from '../../assets/nav_seeTogether.svg'; import Favorite from '../../assets/nav_favorite.svg'; @@ -16,152 +15,74 @@ import FocusFavorite from '../../assets/nav_favorite_focus.svg'; import FocusAddMapOrPin from '../../assets/nav_addMapOrPin_focus.svg'; import FocusProfile from '../../assets/nav_profile_focus.svg'; import Modal from '../Modal'; -import { ModalContext } from '../../context/ModalContext'; -import { NavbarHighlightsContext } from '../../context/NavbarHighlightsContext'; -import { useParams } from 'react-router-dom'; -import SeeTogetherCounter from '../SeeTogetherCounter'; -import useKeyDown from '../../hooks/useKeyDown'; +import { + NavbarHighlightKeys, + NavbarHighlightsContext, +} from '../../context/NavbarHighlightsContext'; +import NavbarItem from './NavbarItem'; interface NavBarProps { $layoutWidth: '100vw' | '372px'; } -const Navbar = ({ $layoutWidth }: NavBarProps) => { - const { routePage } = useNavigator(); - const { topicId } = useParams(); - const { openModal, closeModal } = useContext(ModalContext); - const { navbarHighlights } = useContext(NavbarHighlightsContext); - const { elementRef: firstElement, onElementKeyDown: firstKeyDown } = - useKeyDown(); - const { elementRef: secondElement, onElementKeyDown: secondKeyDown } = - useKeyDown(); - const { elementRef: thirdElement, onElementKeyDown: thirdKeyDown } = - useKeyDown(); - const { elementRef: fourElement, onElementKeyDown: fourKeyDown } = - useKeyDown(); - const { elementRef: FifthElement, onElementKeyDown: FifthKeyDown } = - useKeyDown(); - - const goToHome = () => { - routePage('/'); - }; - - const goToSeeTogether = () => { - routePage('/see-together'); - }; - - const onClickAddMapOrPin = () => { - openModal('addMapOrPin'); - }; - - const goToFavorite = () => { - routePage('/favorite'); - }; - - const goToProfile = () => { - routePage('/my-page'); - }; - - const goToNewTopic = () => { - routePage('/new-topic'); - closeModal('addMapOrPin'); - }; +interface NavbarItemProps { + key: NavbarHighlightKeys; + label: string; + icon: React.FunctionComponent; + focusIcon: React.FunctionComponent; +} - const goToNewPin = () => { - routePage('/new-pin', topicId); - closeModal('addMapOrPin'); - }; +const NAV_ITEMS: NavbarItemProps[] = [ + { key: 'home', label: '홈', icon: Home, focusIcon: FocusHome }, + { + key: 'seeTogether', + label: '모아보기', + icon: SeeTogether, + focusIcon: FocusSeeTogether, + }, + { + key: 'addMapOrPin', + label: '추가하기', + icon: AddMapOrPin, + focusIcon: FocusAddMapOrPin, + }, + { + key: 'favorite', + label: '즐겨찾기', + icon: Favorite, + focusIcon: FocusFavorite, + }, + { + key: 'profile', + label: '내 정보', + icon: Profile, + focusIcon: FocusProfile, + }, +]; +const Navbar = ({ $layoutWidth }: NavBarProps) => { + const { routingHandlers } = useNavigator(); + const { navbarHighlights } = useContext(NavbarHighlightsContext); return ( - - - {navbarHighlights.home ? : } - - 홈 - - - - - - - {navbarHighlights.seeTogether ? : } - - 모아보기 - - - - - - - - {navbarHighlights.addMapOrPin ? : } - - 추가하기 - - - - - - + - {navbarHighlights.favorite ? : } - - 즐겨찾기 - - - - - - - {navbarHighlights.profile ? : } - - 내 정보 - - + {NAV_ITEMS.map((item) => { + return ( + routingHandlers[item.key]()} + $layoutWidth={$layoutWidth} + /> + ); + })} + { left={$layoutWidth === '100vw' ? '' : `${372 / 2}px`} > - + 지도 추가하기 - + 핀 추가하기 - + ); }; -const Wrapper = styled.nav<{ $layoutWidth: '100vw' | '372px' }>` +const Wrapper = styled.nav<{ + $isAddPage: boolean; + $layoutWidth: '100vw' | '372px'; +}>` width: 100%; - height: 64px; + min-height: 56px; display: flex; justify-content: ${({ $layoutWidth }) => $layoutWidth === '100vw' ? 'center' : 'space-around'}; align-items: center; -`; - -const IconWrapper = styled.div` - position: relative; - display: flex; - flex-direction: column; - align-items: center; - width: 52px; - cursor: pointer; -`; - -const IconSpace = styled(Space)<{ $layoutWidth: '100vw' | '372px' }>` - display: ${({ $layoutWidth }) => - $layoutWidth === '100vw' ? 'block' : 'none'}; + background-color: ${({ theme }) => theme.color.white}; + z-index: 2; + box-shadow: 0 -1px 8px rgba(0, 0, 0, 0.3); + + @media (max-width: 1076px) { + justify-content: space-around; + + ${({ $isAddPage }) => + $isAddPage && + css` + position: fixed; + bottom: 0; + `} + } `; const RouteButton = styled(Button)` box-shadow: 2px 4px 4px rgba(0, 0, 0, 0.5); `; -const ModalWrapper = styled(Flex)` - width: 100%; - height: 100%; -`; export default Navbar; diff --git a/frontend/src/components/Layout/NavbarItem.tsx b/frontend/src/components/Layout/NavbarItem.tsx new file mode 100644 index 000000000..c637db2c2 --- /dev/null +++ b/frontend/src/components/Layout/NavbarItem.tsx @@ -0,0 +1,66 @@ +import { FunctionComponent } from 'react'; +import styled from 'styled-components'; +import useKeyDown from '../../hooks/useKeyDown'; +import Text from '../common/Text'; +import SeeTogetherCounter from '../SeeTogetherCounter'; + +interface NavbarItemProps { + label: string; + icon: FunctionComponent; + focusIcon: FunctionComponent; + isHighlighted?: boolean; + onClick: () => void; + $layoutWidth: '100vw' | '372px'; +} + +const NavbarItem = ({ + label, + icon: Icon, + focusIcon: FocusIcon, + isHighlighted = false, + onClick, + $layoutWidth, +}: NavbarItemProps) => { + const { elementRef, onElementKeyDown } = useKeyDown(); + + return ( + + {isHighlighted ? : } + + {label} + + {label === '모아보기' ? : null} + + ); +}; + +const IconWrapper = styled.div<{ $layoutWidth: '100vw' | '372px' }>` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 52px; + cursor: pointer; + margin-right: ${({ $layoutWidth }) => + $layoutWidth === '100vw' ? '48px' : '0'}; + + &:last-of-type { + margin-right: 0; + } + + @media (max-width: 1076px) { + margin-right: 0; + } +`; + +export default NavbarItem; diff --git a/frontend/src/components/Layout/index.tsx b/frontend/src/components/Layout/index.tsx index 5e5d36fb3..39af46f4e 100644 --- a/frontend/src/components/Layout/index.tsx +++ b/frontend/src/components/Layout/index.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useRef, useState } from 'react'; +import { useContext } from 'react'; import Map from '../Map'; import Flex from '../common/Flex'; import Logo from './Logo'; @@ -6,107 +6,108 @@ import CoordinatesProvider from '../../context/CoordinatesContext'; import MarkerProvider from '../../context/MarkerContext'; import ToastProvider from '../../context/ToastContext'; import Toast from '../Toast'; -import { styled } from 'styled-components'; +import { css, styled } from 'styled-components'; import { LayoutWidthContext } from '../../context/LayoutWidthContext'; import SeeTogetherProvider from '../../context/SeeTogetherContext'; import Space from '../common/Space'; import Navbar from './Navbar'; import ModalProvider from '../../context/ModalContext'; -import NavbarHighlightsProvider from '../../context/NavbarHighlightsContext'; +import { NavbarHighlightsContext } from '../../context/NavbarHighlightsContext'; import TagProvider from '../../context/TagContext'; -import InfoDefalutImg from '../../assets/InfoDefalutImg.svg'; import Box from '../common/Box'; type LayoutProps = { children: React.ReactNode; }; -declare global { - interface Window { - Tmapv3: any; - daum: any; - } -} - const Layout = ({ children }: LayoutProps) => { - const { Tmapv3 } = window; - const mapContainer = useRef(null); const { width } = useContext(LayoutWidthContext); - const isLogined = localStorage.getItem('userToken'); - - const loginButtonClick = () => { - window.location.href = 'https://mapbefine.kro.kr/api/oauth/kakao'; - }; - - const [map, setMap] = useState(null); - - useEffect(() => { - const map = new Tmapv3.Map(mapContainer.current, { - center: new Tmapv3.LatLng(37.5154, 127.1029), - }); - map.setZoomLimit(7, 17); - setMap(map); - return () => { - map.destroy(); - }; - }, []); - + const { navbarHighlights } = useContext(NavbarHighlightsContext); return ( - - - - - - - + + + + + + + + + + + + - - - - - - - - {children} - - - - - - - - - - - + {children} + + + + + + + + + + ); }; -const LayoutFlex = styled(Flex)` - transition: all ease 0.3s; +const LogoWrapper = styled.section<{ + $layoutWidth: '372px' | '100vw'; +}>` + width: 372px; + display: flex; + padding: 12px 20px 0 20px; + @media (max-width: 1076px) { + ${({ $layoutWidth }) => + $layoutWidth === '372px' && + css` + width: 100vw; + background-color: white; + position: fixed; + top: 0; + z-index: 1; + `}; + } `; -const MyInfoImg = styled.img` - width: 40px; - height: 40px; - - border-radius: 50%; +const MediaWrapper = styled.section<{ + $isAddPage: boolean; + $layoutWidth: '372px' | '100vw'; +}>` + display: flex; + width: 100vw; + overflow: hidden; + @media (max-width: 1076px) { + flex-direction: ${({ $isAddPage, $layoutWidth }) => { + if ($isAddPage) return 'column'; + if ($layoutWidth === '372px') return 'column-reverse'; + }}; + } `; +const LayoutFlex = styled(Flex)<{ $layoutWidth: '372px' | '100vw' }>` + transition: all ease 0.3s; + @media (max-width: 1076px) { + height: ${({ $layoutWidth }) => $layoutWidth === '372px' && '50vh'}; + transition: none; + } +`; export default Layout; diff --git a/frontend/src/components/Loader/index.tsx b/frontend/src/components/Loader/index.tsx index 90f7ed3c4..430668936 100644 --- a/frontend/src/components/Loader/index.tsx +++ b/frontend/src/components/Loader/index.tsx @@ -9,7 +9,7 @@ const Loader = () => { }; const Rotate = keyframes` - 0% { + 0% { transform: rotate(0deg); } 100% { diff --git a/frontend/src/components/Map/index.tsx b/frontend/src/components/Map/index.tsx index 157a46b61..5c95e2036 100644 --- a/frontend/src/components/Map/index.tsx +++ b/frontend/src/components/Map/index.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useContext } from 'react'; +import { useContext, useLayoutEffect, useRef, useState } from 'react'; import Flex from '../common/Flex'; import { MarkerContext } from '../../context/MarkerContext'; import useMapClick from '../../hooks/useMapClick'; @@ -9,24 +9,46 @@ import useAnimateClickedPin from '../../hooks/useAnimateClickedPin'; import { styled } from 'styled-components'; import { LayoutWidthContext } from '../../context/LayoutWidthContext'; -const Map = (props: any, ref: any) => { - const { map } = props; +const Map = () => { + const { Tmapv3 } = window; + const { markers } = useContext(MarkerContext); const { width } = useContext(LayoutWidthContext); + const [mapInstance, setMapInstance] = useState(null); + + const mapContainer = useRef(null); + + useLayoutEffect(() => { + if (!Tmapv3 || !mapContainer.current) return; + + const map = new Tmapv3.Map(mapContainer.current, { + center: new Tmapv3.LatLng(37.5154, 127.1029), + }); + + if (!map) return; - useMapClick(map); - useClickedCoordinate(map); - useUpdateCoordinates(map); + map.setZoomLimit(7, 17); - useFocusToMarker(map, markers); - useAnimateClickedPin(map, markers); + setMapInstance(map); + + return () => { + map.destroy(); + }; + }, []); + + useMapClick(mapInstance); + useClickedCoordinate(mapInstance); + useUpdateCoordinates(mapInstance); + + useFocusToMarker(mapInstance, markers); + useAnimateClickedPin(mapInstance, markers); return ( @@ -40,6 +62,10 @@ const MapFlex = styled(Flex)` $minWidth === '100vw' ? '0' : 'calc(100vw - 400px)'}; } } + + @media (max-width: 1076px) { + max-height: 50vh; + } `; -export default forwardRef(Map); +export default Map; diff --git a/frontend/src/components/Modal/index.tsx b/frontend/src/components/Modal/index.tsx index ee2d83905..f3acc5199 100644 --- a/frontend/src/components/Modal/index.tsx +++ b/frontend/src/components/Modal/index.tsx @@ -3,10 +3,8 @@ import ReactDOM from 'react-dom'; import { css, keyframes, styled } from 'styled-components'; import { ModalContext } from '../../context/ModalContext'; import Box from '../common/Box'; -type ModalWrapperType = Omit< - ModalProps, - 'modalKey' | 'children' | '$dimmedColor' ->; + +type ModalWrapperType = Omit; interface ModalProps { modalKey: string; @@ -61,6 +59,7 @@ const Modal = ({ onClick={onClickDimmedCloseModal} /> ` - width: ${({ width }) => width || '400px'}; - height: ${({ height }) => height || '400px'}; - ${({ position }) => getModalPosition(position)}; - top: ${({ top }) => top && top}; - left: ${({ left }) => left && left}; - z-index: 2; -`; - -const WrapperDimmed = styled.div<{ $dimmedColor: string }>` - width: 100%; - height: 100%; - position: fixed; - top: 0; - background-color: ${({ $dimmedColor }) => $dimmedColor}; - z-index: 2; -`; const translateModalAnimation = keyframes` from { @@ -113,7 +95,7 @@ const openModalAnimation = keyframes` } `; -const getModalPosition = (position: 'center' | 'bottom' | 'absolute') => { +const getModalPosition = (position: 'center' | 'bottom') => { switch (position) { case 'center': return css` @@ -130,8 +112,8 @@ const getModalPosition = (position: 'center' | 'bottom' | 'absolute') => { return css` position: fixed; left: 50%; - transform: translate(-50%, 0); bottom: 0; + transform: translate(-50%, 0); border-top-left-radius: ${({ theme }) => theme.radius.medium}; border-top-right-radius: ${({ theme }) => theme.radius.medium}; animation: ${translateModalAnimation} 0.3s ease 1; @@ -139,4 +121,48 @@ const getModalPosition = (position: 'center' | 'bottom' | 'absolute') => { } }; +const addMapOrPinPostion = (modalKey: string) => { + console.log(modalKey); + if (modalKey === 'addMapOrPin') { + return css` + width: 252px; + height: inherit; + + transform: translate(-50%, -30%); + `; + } else { + return css` + width: 100%; + height: inherit; + + transform: translate(-50%, 0); + `; + } +}; + +const Wrapper = styled.div` + width: ${({ width }) => width || '400px'}; + height: ${({ height }) => height || '400px'}; + ${({ position }) => getModalPosition(position)}; + top: ${({ top }) => top && top}; + left: ${({ left }) => left && left}; + z-index: 2; + + @media (max-width: 1076px) { + ${getModalPosition('bottom')}; + width: 100%; + height: inherit; + ${({ modalKey }) => addMapOrPinPostion(modalKey)}; + } +`; + +const WrapperDimmed = styled.div<{ $dimmedColor: string }>` + width: 100%; + height: 100%; + position: fixed; + top: 0; + background-color: ${({ $dimmedColor }) => $dimmedColor}; + z-index: 2; +`; + export default Modal; diff --git a/frontend/src/components/ModalMyTopicList/addToMyTopicList.tsx b/frontend/src/components/ModalMyTopicList/addToMyTopicList.tsx index d9f1ed806..7fb88f697 100644 --- a/frontend/src/components/ModalMyTopicList/addToMyTopicList.tsx +++ b/frontend/src/components/ModalMyTopicList/addToMyTopicList.tsx @@ -1,65 +1,78 @@ import { Fragment, useContext, useEffect, useState } from 'react'; -import { ModalMyTopicType } from '../../types/Topic'; -import { getApi } from '../../apis/getApi'; +import { TopicCardProps } from '../../types/Topic'; import { styled } from 'styled-components'; -import ModalTopicCard from '../ModalTopicCard'; +import TopicCard from '../TopicCard'; import { ModalContext } from '../../context/ModalContext'; -import { postApi } from '../../apis/postApi'; import useToast from '../../hooks/useToast'; +import useGet from '../../apiHooks/useGet'; +import usePost from '../../apiHooks/usePost'; +import Space from '../common/Space'; + +interface OnClickDesignatedProps { + topicId: number; + topicName: string; +} const AddToMyTopicList = ({ pin }: any) => { - const [myTopics, setMyTopics] = useState([]); + const [myTopics, setMyTopics] = useState(null); const { closeModal } = useContext(ModalContext); + const { fetchGet } = useGet(); + const { fetchPost } = usePost(); const { showToast } = useToast(); - const getMyTopicFromServer = async () => { - const serverMyTopic = await getApi( - 'default', + const getMyTopicsFromServer = async () => { + fetchGet( '/members/my/topics', + '내가 만든 지도를 가져오는데 실패했습니다. 잠시 후 다시 시도해주세요.', + (response) => { + setMyTopics(response); + }, ); - setMyTopics(serverMyTopic); }; useEffect(() => { - getMyTopicFromServer(); + getMyTopicsFromServer(); }, []); - const addPinToTopic = async (topicId: any) => { - try { - await postApi(`/pins`, { - topicId: topicId.topicId, - name: pin.name, - description: pin.description, - address: pin.address, - latitude: pin.latitude, - longitude: pin.longitude, - legalDongCode: '', - }); - closeModal('addToMyTopicList'); - showToast('info', '내 지도에 핀이 추가되었습니다.'); - } catch (error) { - showToast('error', '내 지도에 핀 추가를 실패했습니다.'); - } + const addPinToTopic = async (topic: OnClickDesignatedProps) => { + const url = `/topics/${topic.topicId}/copy?pinIds=${pin.id}`; + + fetchPost({ + url, + errorMessage: + '내 지도에 핀 추가를 실패하였습니다. 잠시 후 다시 시도해주세요.', + onSuccess: () => { + closeModal('addToMyTopicList'); + showToast('info', '내 지도에 핀이 추가되었습니다.'); + }, + }); }; + if (!myTopics) return <>; return ( - - {myTopics.map((topic) => ( - - - - ))} - + <> + + {myTopics.map((topic) => ( + + + + ))} + + + ); }; @@ -69,6 +82,12 @@ const ModalMyTopicListWrapper = styled.ul` display: flex; flex-wrap: wrap; gap: 20px; + + @media (max-width: 744px) { + width: 100%; + justify-content: center; + margin-bottom: 48px; + } `; export default AddToMyTopicList; diff --git a/frontend/src/components/ModalMyTopicList/index.tsx b/frontend/src/components/ModalMyTopicList/index.tsx index a5e90e919..1d78e36cc 100644 --- a/frontend/src/components/ModalMyTopicList/index.tsx +++ b/frontend/src/components/ModalMyTopicList/index.tsx @@ -1,33 +1,39 @@ -import React, { Fragment, useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { styled } from 'styled-components'; -import { getApi } from '../../apis/getApi'; -import { ModalMyTopicType } from '../../types/Topic'; -import ModalTopicCard from '../ModalTopicCard'; +import { TopicCardProps } from '../../types/Topic'; +import TopicCard from '../TopicCard'; import Space from '../common/Space'; +import useGet from '../../apiHooks/useGet'; -interface ModalMyTopicList { +interface ModalMyTopicListProps { topicId: string; topicClick: any; } -const ModalMyTopicList = ({ topicId, topicClick }: ModalMyTopicList) => { - const [myTopics, setMyTopics] = useState([]); +const ModalMyTopicList = ({ topicId, topicClick }: ModalMyTopicListProps) => { + const [myTopics, setMyTopics] = useState(null); + const { fetchGet } = useGet(); const getMyTopicFromServer = async () => { if (topicId && topicId.split(',').length > 1) { - const topics = await getApi( - 'default', + fetchGet( `/topics/ids?ids=${topicId}`, + '모아보기로 선택한 지도 목록을 조회하는데 실패했습니다.', + (response) => { + setMyTopics(response); + }, ); - setMyTopics(topics); return; } - const serverMyTopic = await getApi( - 'default', + + fetchGet( '/members/my/topics', + '나의 지도 목록을 조회하는데 실패했습니다.', + (response) => { + setMyTopics(response); + }, ); - setMyTopics(serverMyTopic); }; useEffect(() => { @@ -41,15 +47,18 @@ const ModalMyTopicList = ({ topicId, topicClick }: ModalMyTopicList) => { {myTopics.map((topic) => ( - ))} @@ -65,6 +74,12 @@ const ModalMyTopicListWrapper = styled.ul` display: flex; flex-wrap: wrap; gap: 20px; + + @media (max-width: 744px) { + width: 100%; + justify-content: center; + margin-bottom: 48px; + } `; export default ModalMyTopicList; diff --git a/frontend/src/components/ModalTopicCard/index.tsx b/frontend/src/components/ModalTopicCard/index.tsx deleted file mode 100644 index 9bd693337..000000000 --- a/frontend/src/components/ModalTopicCard/index.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { styled } from 'styled-components'; -import Text from '../common/Text'; -import useNavigator from '../../hooks/useNavigator'; -import Box from '../common/Box'; -import Image from '../common/Image'; -import { SyntheticEvent, useContext } from 'react'; -import Space from '../common/Space'; -import Flex from '../common/Flex'; -import SmallTopicPin from '../../assets/smallTopicPin.svg'; -import SmallTopicStar from '../../assets/smallTopicStar.svg'; -import { DEFAULT_TOPIC_IMAGE } from '../../constants'; -import { ModalContext } from '../../context/ModalContext'; - -const FAVORITE_COUNT = 10; - -export interface ModalTopicCardProps { - topicId: number; - topicImage: string; - topicTitle: string; - topicCreator: string; - topicUpdatedAt: string; - topicPinCount: number; - topicClick: any; - topicBookmarkCount: number; -} - -const ModalTopicCard = ({ - topicId, - topicImage, - topicTitle, - topicCreator, - topicUpdatedAt, - topicPinCount, - topicClick, - topicBookmarkCount, -}: ModalTopicCardProps) => { - const { routePage } = useNavigator(); - const { closeModal } = useContext(ModalContext); - const goToSelectedTopic = (topic: any, type: 'newPin' | 'addToTopic') => { - if (type === 'newPin') { - topicClick(topic); - closeModal('newPin'); - } - if (type === 'addToTopic') { - topicClick(topic); - } - }; - - return ( - { - goToSelectedTopic({ topicId, topicTitle }, 'newPin'); - }} - > - - ) => { - e.currentTarget.src = DEFAULT_TOPIC_IMAGE; - }} - /> - - - - - {topicTitle} - - - - - {topicCreator} - - - - - - {topicUpdatedAt.split('T')[0].replaceAll('-', '.')} 업데이트 - - - - - - - - - - {topicPinCount > 999 ? '+999' : topicPinCount}개 - - - - - - - {topicBookmarkCount > 999 ? '+999' : topicBookmarkCount}명 - - - - - - - ); -}; - -const Wrapper = styled.li` - width: 332px; - height: 140px; - cursor: pointer; - border: 1px solid ${({ theme }) => theme.color.gray}; - border-radius: ${({ theme }) => theme.radius.small}; -`; - -const TopicImage = styled(Image)` - border-radius: ${({ theme }) => theme.radius.small}; -`; - -export default ModalTopicCard; diff --git a/frontend/src/components/MyInfo/UpdateMyInfo.tsx b/frontend/src/components/MyInfo/UpdateMyInfo.tsx index 04441b4dc..7268ca821 100644 --- a/frontend/src/components/MyInfo/UpdateMyInfo.tsx +++ b/frontend/src/components/MyInfo/UpdateMyInfo.tsx @@ -1,19 +1,16 @@ import { styled } from 'styled-components'; import Flex from '../common/Flex'; -import InfoDefalutImg from '../../assets/InfoDefalutImg.svg'; -import ModifyMyInfoIcon from '../../assets/ModifyMyInfoIcon.svg'; +import ProfileDefaultImage from '../../assets/profile_defaultImage.svg'; import Box from '../common/Box'; -import Text from '../common/Text'; import Space from '../common/Space'; -import { useEffect, useState } from 'react'; -import { MyInfoType } from '../../types/MyInfo'; +import { ProfileProps } from '../../types/Profile'; import Button from '../common/Button'; interface UpdateMyInfoProps { isThereImg: boolean; - myInfoNameAndEmail: MyInfoType; + myInfoNameAndEmail: ProfileProps; setIsModifyMyInfo: React.Dispatch>; - setMyInfoNameAndEmail: React.Dispatch>; + setMyInfoNameAndEmail: React.Dispatch>; } const UpdateMyInfo = ({ @@ -23,12 +20,12 @@ const UpdateMyInfo = ({ setMyInfoNameAndEmail, }: UpdateMyInfoProps) => { const onChangeMyInfoName = (e: React.ChangeEvent) => { - if(e.target.value.length >= 20) return; + if (e.target.value.length >= 20) return; setMyInfoNameAndEmail({ ...myInfoNameAndEmail, name: e.target.value }); }; const onChangeMyInfoEmail = (e: React.ChangeEvent) => { - if(e.target.value.length >= 35) return; + if (e.target.value.length >= 35) return; setMyInfoNameAndEmail({ ...myInfoNameAndEmail, email: e.target.value }); }; @@ -47,7 +44,7 @@ const UpdateMyInfo = ({ {isThereImg ? ( ) : ( - + )} diff --git a/frontend/src/components/MyInfo/index.tsx b/frontend/src/components/MyInfo/index.tsx index 1d2706e0a..e1e331ec1 100644 --- a/frontend/src/components/MyInfo/index.tsx +++ b/frontend/src/components/MyInfo/index.tsx @@ -4,19 +4,47 @@ import Box from '../common/Box'; import Text from '../common/Text'; import Space from '../common/Space'; import { useState } from 'react'; -import { MyInfoType } from '../../types/MyInfo'; +import { ProfileProps } from '../../types/Profile'; import UpdateMyInfo from './UpdateMyInfo'; +import Button from '../common/Button'; +import useToast from '../../hooks/useToast'; +import { DEFAULT_PROD_URL } from '../../constants'; const user = JSON.parse(localStorage.getItem('user') || '{}'); +const accessToken = localStorage.getItem('userToken'); const MyInfo = () => { + const { showToast } = useToast(); + const [isThereImg, setIsThereImg] = useState(true); const [isModifyMyInfo, setIsModifyMyInfo] = useState(false); - const [myInfoNameAndEmail, setMyInfoNameAndEmail] = useState({ + const [myInfoNameAndEmail, setMyInfoNameAndEmail] = useState({ name: user.nickName, email: user.email, }); + const onClickLogout = async (e: React.MouseEvent) => { + try { + fetch(`${DEFAULT_PROD_URL}/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + accessToken: accessToken, + }), + }); + + localStorage.removeItem('user'); + localStorage.removeItem('userToken'); + window.location.href = '/'; + showToast('info', '로그아웃 되었습니다.'); + } catch { + showToast('error', '로그아웃에 실패했습니다'); + } + }; + if (isModifyMyInfo) { return ( { $alignItems="center" > - + - - {user.nickName} - + + + {user.nickName} + + + {user.email} diff --git a/frontend/src/components/MyInfoContainer/MyInfoList/index.tsx b/frontend/src/components/MyInfoContainer/MyInfoList/index.tsx deleted file mode 100644 index 94a05e42e..000000000 --- a/frontend/src/components/MyInfoContainer/MyInfoList/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Fragment, useEffect, useState } from 'react'; -import { styled } from 'styled-components'; -import { getApi } from '../../../apis/getApi'; -import PinCard from '../../PinCard'; -import TopicCard from '../../TopicCard'; -import { TopicType } from '../../../types/Topic'; -import useToast from '../../../hooks/useToast'; - -const MyInfoList = () => { - const [myInfoTopics, setMyInfoTopics] = useState([]); - const { showToast } = useToast(); - - const getMyInfoListFromServer = async () => { - try { - const serverMyInfoTopics = await getApi( - 'default', - '/members/my/topics', - ); - - setMyInfoTopics(serverMyInfoTopics); - } catch { - showToast('error', '로그인 후 이용해주세요.'); - } - }; - - useEffect(() => { - getMyInfoListFromServer(); - }, []); - - if (!myInfoTopics) return <>; - - return ( - - {myInfoTopics.map((topic, index) => { - return ( - - - - ); - })} - - ); -}; - -const MyInfoListWrapper = styled.ul` - display: flex; - flex-wrap: wrap; - gap: 20px; -`; - -export default MyInfoList; diff --git a/frontend/src/components/MyInfoContainer/index.tsx b/frontend/src/components/MyInfoContainer/index.tsx deleted file mode 100644 index b2e38cfd2..000000000 --- a/frontend/src/components/MyInfoContainer/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { styled } from 'styled-components'; -import Flex from '../common/Flex'; -import Text from '../common/Text'; -import Box from '../common/Box'; -import Space from '../common/Space'; -import { lazy, Suspense } from 'react'; -import TopicCardListSkeleton from '../TopicCardList/TopicCardListSkeleton'; -import Button from '../common/Button'; - -const MyInfoList = lazy(() => import('./MyInfoList')); - -interface MyInfoContainerProps { - containerTitle: string; - containerDescription: string; -} - -const MyInfoContainer = ({ - containerTitle, - containerDescription, -}: MyInfoContainerProps) => ( -
- - - - {containerTitle} - - - - {containerDescription} - - - - - - - }> - - -
-); - -const SeeAllButton = styled(Button)` - cursor: pointer; -`; - -export default MyInfoContainer; diff --git a/frontend/src/components/PinImageContainer/index.tsx b/frontend/src/components/PinImageContainer/index.tsx new file mode 100644 index 000000000..a62b9ff4b --- /dev/null +++ b/frontend/src/components/PinImageContainer/index.tsx @@ -0,0 +1,41 @@ +import styled from 'styled-components'; +import { ImageProps } from '../../types/Pin'; +import Image from '../common/Image'; + +interface PinImageContainerProps { + images: ImageProps[]; +} + +const PinImageContainer = ({ images }: PinImageContainerProps) => { + return ( + <> + + {images.map( + (image, index) => + index < 3 && ( + + + + ), + )} + + + ); +}; + +const FilmList = styled.ul` + width: 330px; + display: flex; + flex-direction: row; +`; + +const ImageWrapper = styled.li` + margin-right: 10px; +`; + +export default PinImageContainer; diff --git a/frontend/src/components/PinsOfTopic/index.tsx b/frontend/src/components/PinsOfTopic/index.tsx index da2aae33b..64e9a5867 100644 --- a/frontend/src/components/PinsOfTopic/index.tsx +++ b/frontend/src/components/PinsOfTopic/index.tsx @@ -1,12 +1,12 @@ -import { DEFAULT_TOPIC_IMAGE } from '../../constants'; -import { TopicDetailType } from '../../types/Topic'; +import { styled } from 'styled-components'; +import { TopicDetailProps } from '../../types/Topic'; import PinPreview from '../PinPreview'; import TopicInfo from '../TopicInfo'; interface PinsOfTopicProps { topicId: string; idx: number; - topicDetail: TopicDetailType; + topicDetail: TopicDetailProps; setSelectedPinId: React.Dispatch>; setIsEditPinDetail: React.Dispatch>; setTopicsFromServer: () => void; @@ -21,9 +21,8 @@ const PinsOfTopic = ({ setTopicsFromServer, }: PinsOfTopicProps) => { return ( -
    + ))} -
+
); }; +const Wrapper = styled.ul``; + export default PinsOfTopic; diff --git a/frontend/src/components/PullPin/index.tsx b/frontend/src/components/PullPin/index.tsx index 58f2e4183..7264d05b5 100644 --- a/frontend/src/components/PullPin/index.tsx +++ b/frontend/src/components/PullPin/index.tsx @@ -21,20 +21,11 @@ const PullPin = ({ if (tags.length === 0) return <>; return ( - + @@ -96,8 +87,29 @@ const PullPin = ({ ); }; -const Wrapper = styled(Flex)` - border-bottom: 2px solid ${({ theme }) => theme.color.black}; +const Wrapper = styled.section` + width: 332px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: white; + position: fixed; + border-radius: ${({ theme }) => theme.radius.small}; + z-index: 1; + border-bottom: 4px solid ${({ theme }) => theme.color.black}; + + @media (max-width: 1076px) { + width: calc(50vw - 40px); + } + + @media (max-width: 744px) { + width: calc(100vw - 40px); + } + + @media (max-width: 372px) { + width: 332px; + } `; export default PullPin; diff --git a/frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx b/frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx deleted file mode 100644 index d78b972ef..000000000 --- a/frontend/src/components/SeeAllCardList/SeeAllCardListSkeleton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Flex from '../common/Flex'; -import Space from '../common/Space'; -import TopicCardSkeleton from '../TopicCardSkeleton'; - -const SeeAllCardListSkeleton = () => { - return ( - - - - - - - - - - ); -}; - -export default SeeAllCardListSkeleton; diff --git a/frontend/src/components/SeeAllCardList/index.tsx b/frontend/src/components/SeeAllCardList/index.tsx deleted file mode 100644 index 78c228647..000000000 --- a/frontend/src/components/SeeAllCardList/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Fragment, useEffect, useState } from 'react'; -import { TopicType } from '../../types/Topic'; -import { getApi } from '../../apis/getApi'; -import TopicCard from '../TopicCard'; -import Flex from '../common/Flex'; -import { styled } from 'styled-components'; - -interface SeeAllCardListProps { - url: string; -} - -const SeeAllCardList = ({ url }: SeeAllCardListProps) => { - const [topics, setTopics] = useState([]); - - const getAndSetDataFromServer = async () => { - const topics = await getApi('default', url); - setTopics(topics); - }; - - useEffect(() => { - getAndSetDataFromServer(); - }, []); - - return ( - - {topics && - topics.map((topic) => ( - - - - ))} - - ); -}; - -const Wrapper = styled.ul` - display: flex; - flex-wrap: wrap; - gap: 20px; -`; - -export default SeeAllCardList; diff --git a/frontend/src/components/SeeTogetherCounter/index.tsx b/frontend/src/components/SeeTogetherCounter/index.tsx index ef720be87..4c3729168 100644 --- a/frontend/src/components/SeeTogetherCounter/index.tsx +++ b/frontend/src/components/SeeTogetherCounter/index.tsx @@ -2,7 +2,7 @@ import { useContext, useEffect } from 'react'; import { SeeTogetherContext } from '../../context/SeeTogetherContext'; import { keyframes, styled } from 'styled-components'; import { getApi } from '../../apis/getApi'; -import { TopicType } from '../../types/Topic'; +import { TopicCardProps } from '../../types/Topic'; import useToast from '../../hooks/useToast'; const SeeTogetherCounter = () => { @@ -15,7 +15,7 @@ const SeeTogetherCounter = () => { try { if (!userToken) return; - const topics = await getApi('default', '/members/my/atlas'); + const topics = await getApi('/members/my/atlas'); setSeeTogetherTopics(topics); } catch { showToast( diff --git a/frontend/src/components/PinPreviewSkeleton/index.tsx b/frontend/src/components/Skeletons/PinPreviewSkeleton.tsx similarity index 100% rename from frontend/src/components/PinPreviewSkeleton/index.tsx rename to frontend/src/components/Skeletons/PinPreviewSkeleton.tsx diff --git a/frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx b/frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx similarity index 79% rename from frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx rename to frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx index 0d2c5b191..ffe99d46c 100644 --- a/frontend/src/components/PinsOfTopic/PinsOfTopicSkeleton.tsx +++ b/frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx @@ -1,7 +1,7 @@ import Flex from '../common/Flex'; -import PinPreviewSkeleton from '../PinPreviewSkeleton'; +import PinPreviewSkeleton from './PinPreviewSkeleton'; import Space from '../common/Space'; -import TopicInfoSkeleton from '../TopicInfoSkeleton'; +import TopicInfoSkeleton from './TopicInfoSkeleton'; const PinsOfTopicSkeleton = () => { return ( diff --git a/frontend/src/components/TopicCardSkeleton/index.tsx b/frontend/src/components/Skeletons/TopicCardSkeleton.tsx similarity index 100% rename from frontend/src/components/TopicCardSkeleton/index.tsx rename to frontend/src/components/Skeletons/TopicCardSkeleton.tsx diff --git a/frontend/src/components/TopicInfoSkeleton/index.tsx b/frontend/src/components/Skeletons/TopicInfoSkeleton.tsx similarity index 100% rename from frontend/src/components/TopicInfoSkeleton/index.tsx rename to frontend/src/components/Skeletons/TopicInfoSkeleton.tsx diff --git a/frontend/src/components/TopicCardList/TopicCardListSkeleton.tsx b/frontend/src/components/Skeletons/TopicListSkeleton.tsx similarity index 73% rename from frontend/src/components/TopicCardList/TopicCardListSkeleton.tsx rename to frontend/src/components/Skeletons/TopicListSkeleton.tsx index d274e085a..5c30b815c 100644 --- a/frontend/src/components/TopicCardList/TopicCardListSkeleton.tsx +++ b/frontend/src/components/Skeletons/TopicListSkeleton.tsx @@ -1,7 +1,7 @@ import { styled } from 'styled-components'; -import TopicCardSkeleton from '../TopicCardSkeleton'; +import TopicCardSkeleton from './TopicCardSkeleton'; -const TopicCardListSkeleton = () => { +const TopicCardContainerSkeleton = () => { return ( @@ -22,4 +22,4 @@ const Wrapper = styled.section` height: 300px; `; -export default TopicCardListSkeleton; +export default TopicCardContainerSkeleton; diff --git a/frontend/src/components/Toast/index.tsx b/frontend/src/components/Toast/index.tsx index fa286ac04..6d2ac743a 100644 --- a/frontend/src/components/Toast/index.tsx +++ b/frontend/src/components/Toast/index.tsx @@ -14,7 +14,12 @@ const Toast = () => { return ReactDOM.createPortal( toast.show && ( - + {toast.message} ), @@ -62,6 +67,10 @@ const Wrapper = styled(Flex)<{ type: string }>` color: ${({ theme }) => theme.color.white}; z-index: 2; + + @media (max-width: 588px) { + width: 80%; + } `; export default Toast; diff --git a/frontend/src/components/TopicCard/index.tsx b/frontend/src/components/TopicCard/index.tsx index 19038b314..91f54434f 100644 --- a/frontend/src/components/TopicCard/index.tsx +++ b/frontend/src/components/TopicCard/index.tsx @@ -3,7 +3,7 @@ import Text from '../common/Text'; import useNavigator from '../../hooks/useNavigator'; import Box from '../common/Box'; import Image from '../common/Image'; -import { SyntheticEvent } from 'react'; +import { SyntheticEvent, useContext } from 'react'; import Space from '../common/Space'; import Flex from '../common/Flex'; import FavoriteSVG from '../../assets/favoriteBtn_filled.svg'; @@ -15,14 +15,23 @@ import SmallTopicStar from '../../assets/smallTopicStar.svg'; import { DEFAULT_TOPIC_IMAGE } from '../../constants'; import AddSeeTogether from '../AddSeeTogether'; import AddFavorite from '../AddFavorite'; -import { TopicType } from '../../types/Topic'; +import { TopicCardProps } from '../../types/Topic'; import useKeyDown from '../../hooks/useKeyDown'; +import { ModalContext } from '../../context/ModalContext'; -interface TopicCardProps extends TopicType { - setTopicsFromServer: () => void; +interface OnClickDesignatedProps { + topicId: number; + topicName: string; +} + +interface TopicCardExtendedProps extends TopicCardProps { + cardType: 'default' | 'modal'; + onClickDesignated?: ({ topicId, topicName }: OnClickDesignatedProps) => void; + getTopicsFromServer?: () => void; } const TopicCard = ({ + cardType, id, image, creator, @@ -32,19 +41,29 @@ const TopicCard = ({ bookmarkCount, isInAtlas, isBookmarked, - setTopicsFromServer, -}: TopicCardProps) => { + onClickDesignated, + getTopicsFromServer, +}: TopicCardExtendedProps) => { const { routePage } = useNavigator(); + const { closeModal } = useContext(ModalContext); const { elementRef, onElementKeyDown } = useKeyDown(); const goToSelectedTopic = () => { routePage(`/topics/${id}`, [id]); }; + const addPinToThisTopic = () => { + if (onClickDesignated) { + onClickDesignated({ topicId: id, topicName: name }); + } + + closeModal('newPin'); + }; + return ( @@ -53,7 +72,7 @@ const TopicCard = ({ height="138px" width="138px" src={image} - alt="토픽 이미지" + alt="사진 이미지" $objectFit="cover" onError={(e: SyntheticEvent) => { e.currentTarget.src = DEFAULT_TOPIC_IMAGE; @@ -116,22 +135,24 @@ const TopicCard = ({ - - - {isInAtlas ? : } - - - {isBookmarked ? : } - - + {cardType === 'default' && getTopicsFromServer && ( + + + {isInAtlas ? : } + + + {isBookmarked ? : } + + + )} diff --git a/frontend/src/components/TopicCardContainer/index.tsx b/frontend/src/components/TopicCardContainer/index.tsx new file mode 100644 index 000000000..9041ed379 --- /dev/null +++ b/frontend/src/components/TopicCardContainer/index.tsx @@ -0,0 +1,121 @@ +import { styled } from 'styled-components'; +import Flex from '../common/Flex'; +import Text from '../common/Text'; +import Box from '../common/Box'; +import Space from '../common/Space'; +import { Fragment, useEffect, useState } from 'react'; +import { TopicCardProps } from '../../types/Topic'; +import useKeyDown from '../../hooks/useKeyDown'; +import TopicCard from '../TopicCard'; +import useGet from '../../apiHooks/useGet'; + +interface TopicCardContainerProps { + url: string; + containerTitle: string; + containerDescription: string; + routeWhenSeeAll: () => void; +} + +const TopicCardContainer = ({ + url, + containerTitle, + containerDescription, + routeWhenSeeAll, +}: TopicCardContainerProps) => { + const [topics, setTopics] = useState(null); + const { elementRef, onElementKeyDown } = useKeyDown(); + const { fetchGet } = useGet(); + + const setTopicsFromServer = async () => { + await fetchGet( + url, + '지도를 가져오는데 실패했습니다. 잠시 후 다시 시도해주세요.', + (response) => { + setTopics(response); + }, + ); + }; + + useEffect(() => { + setTopicsFromServer(); + }, []); + + return ( +
+ + + + {containerTitle} + + + + {containerDescription} + + + + + 전체 보기 + + + + + + + {topics && + topics.map((topic, index) => { + return ( + index < 6 && ( + + + + ) + ); + })} + +
+ ); +}; + +const PointerText = styled(Text)` + cursor: pointer; +`; + +const TopicsWrapper = styled.ul` + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 20px; +`; + +export default TopicCardContainer; diff --git a/frontend/src/components/TopicCardList/index.tsx b/frontend/src/components/TopicCardList/index.tsx index 48fec5d78..74c15110a 100644 --- a/frontend/src/components/TopicCardList/index.tsx +++ b/frontend/src/components/TopicCardList/index.tsx @@ -1,53 +1,98 @@ -import { Fragment, useContext, useEffect, useState } from 'react'; -import { getApi } from '../../apis/getApi'; -import { TopicType } from '../../types/Topic'; +import { Fragment, useEffect, useState } from 'react'; +import { styled } from 'styled-components'; import TopicCard from '../TopicCard'; -import { MarkerContext } from '../../context/MarkerContext'; +import { TopicCardProps } from '../../types/Topic'; +import useGet from '../../apiHooks/useGet'; import Flex from '../common/Flex'; +import Space from '../common/Space'; +import Text from '../common/Text'; +import Button from '../common/Button'; -interface TopicCardList { - topics: TopicType[]; - setTopicsFromServer: () => void; +interface TopicCardListProps { + url: string; + errorMessage: string; + commentWhenEmpty: string; + pageCommentWhenEmpty: string; + routePage: () => void; + children?: React.ReactNode; } -const TopicCardList = ({ topics, setTopicsFromServer }: TopicCardList) => { - const { markers, removeMarkers, removeInfowindows } = - useContext(MarkerContext); +const TopicCardList = ({ + url, + errorMessage, + commentWhenEmpty, + pageCommentWhenEmpty, + routePage, + children, +}: TopicCardListProps) => { + const [topics, setTopics] = useState(null); + const { fetchGet } = useGet(); + + const getTopicsFromServer = async () => { + fetchGet(url, errorMessage, (response) => { + setTopics(response); + }); + }; useEffect(() => { - if (markers.length > 0) { - removeMarkers(); - removeInfowindows(); - } + getTopicsFromServer(); }, []); + if (!topics) return <>; + + if (topics.length === 0) { + return ( + + + {children} + + + {commentWhenEmpty} + + + + + + + ); + } + return ( -
    - - {topics && - topics.map((topic, index) => { - return ( - index < 6 && ( - - - - ) - ); - })} - -
+ + {topics.map((topic) => ( + + + + ))} + ); }; +const EmptyWrapper = styled.section` + height: 240px; + display: flex; + flex-direction: column; + align-items: center; +`; + +const Wrapper = styled.ul` + display: flex; + flex-wrap: wrap; + gap: 20px; +`; + export default TopicCardList; diff --git a/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx b/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx new file mode 100644 index 000000000..b5e3eb91a --- /dev/null +++ b/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx @@ -0,0 +1,187 @@ +import styled from 'styled-components'; +import InputContainer from '../InputContainer'; +import useFormValues from '../../hooks/useFormValues'; +import Space from '../common/Space'; +import Flex from '../common/Flex'; +import Button from '../common/Button'; +import { useEffect, useState } from 'react'; +import useGet from '../../apiHooks/useGet'; +import { TopicAuthorInfo } from '../../types/Topic'; +import AuthorityRadioContainer from '../AuthorityRadioContainer'; +import usePost from '../../apiHooks/usePost'; +import useDelete from '../../apiHooks/useDelete'; +import usePut from '../../apiHooks/usePut'; +import useToast from '../../hooks/useToast'; + +interface UpdatedTopicInfoProp { + id: number; + image: string; + name: string; + description: string; + setIsUpdate: React.Dispatch>; +} + +interface FormValues { + name: string; + description: string; +} + +const UpdatedTopicInfo = ({ + id, + image, + name, + description, + setIsUpdate, +}: UpdatedTopicInfoProp) => { + const { fetchPost } = usePost(); + const { fetchGet } = useGet(); + const { fetchDelete } = useDelete(); + const { fetchPut } = usePut(); + const { showToast } = useToast(); + const { formValues, errorMessages, onChangeInput } = + useFormValues({ + name, + description, + }); + + const [topicAuthorInfo, setTopicAuthorInfo] = + useState(null); + const [isPrivate, setIsPrivate] = useState(false); // 혼자 볼 지도 : 같이 볼 지도 + const [isAllPermissioned, setIsAllPermissioned] = useState(true); // 모두 : 지정 인원 + const [authorizedMemberIds, setAuthorizedMemberIds] = useState([]); + + const updateTopicInfo = async () => { + try { + await fetchPut({ + url: `/topics/${id}`, + payload: { + name: formValues.name, + image, + description: formValues.description, + publicity: isPrivate ? 'PRIVATE' : 'PUBLIC', + permissionType: + isAllPermissioned && !isPrivate ? 'ALL_MEMBERS' : 'GROUP_ONLY', + }, + errorMessage: `권한은 '공개 ➡️ 비공개', '모두 ➡️ 친구', '친구 ➡️ 혼자' 로 변경할 수 없습니다.`, + isThrow: true, + }); + + // // TODO : 수정해야하는 로직 + // if (authorizedMemberIds.length === 0) { + // await deleteTopicPermissionMembers(); + // } + + // if (authorizedMemberIds.length > 0) { + // await deleteTopicPermissionMembers(); + // await updateTopicPermissionMembers(); + // } + + showToast('info', '지도를 수정하였습니다.'); + setIsUpdate(false); + } catch {} + }; + + const deleteTopicPermissionMembers = async () => { + if (topicAuthorInfo && topicAuthorInfo.permissionedMembers[0]) { + await fetchDelete({ + url: `/permissions/${topicAuthorInfo.permissionedMembers[0].id}`, + errorMessage: '권한 삭제에 실패했습니다.', + isThrow: true, + }); + } + }; + + const updateTopicPermissionMembers = async () => { + await fetchPost({ + url: '/permissions', + payload: { + topicId: id, + memberIds: authorizedMemberIds, + }, + errorMessage: '권한 설정에 실패했습니다.', + isThrow: true, + }); + }; + + const cancelUpdateTopicInfo = () => { + setIsUpdate(false); + }; + + useEffect(() => { + fetchGet( + `/permissions/topics/${id}`, + '지도 권한 설정 정보를 가져오는데 실패했습니다.', + (response) => { + setTopicAuthorInfo(response); + setIsPrivate(response.publicity === 'PRIVATE'); + + setIsAllPermissioned(response.permissionedMembers.length === 0); + }, + ); + }, []); + + return ( + + + + + + + + {/* */} + + + + + + + + + + + + ); +}; + +const Wrapper = styled.section``; + +export default UpdatedTopicInfo; diff --git a/frontend/src/components/TopicInfo/index.tsx b/frontend/src/components/TopicInfo/index.tsx index 40192c0d5..4337205c7 100644 --- a/frontend/src/components/TopicInfo/index.tsx +++ b/frontend/src/components/TopicInfo/index.tsx @@ -2,7 +2,6 @@ import Flex from '../common/Flex'; import Text from '../common/Text'; import Image from '../common/Image'; import Space from '../common/Space'; -import useNavigator from '../../hooks/useNavigator'; import useToast from '../../hooks/useToast'; import SmallTopicPin from '../../assets/smallTopicPin.svg'; import SmallTopicStar from '../../assets/smallTopicStar.svg'; @@ -15,9 +14,11 @@ import { DEFAULT_TOPIC_IMAGE } from '../../constants'; import AddSeeTogether from '../AddSeeTogether'; import AddFavorite from '../AddFavorite'; import { styled } from 'styled-components'; +import Box from '../common/Box'; +import { useEffect, useState } from 'react'; +import UpdatedTopicInfo from './UpdatedTopicInfo'; export interface TopicInfoProps { - fullUrl?: string; topicId: string; idx: number; topicImage: string; @@ -27,13 +28,13 @@ export interface TopicInfoProps { topicPinCount: number; topicBookmarkCount: number; topicDescription: string; + canUpdate: boolean; isInAtlas: boolean; isBookmarked: boolean; setTopicsFromServer: () => void; } const TopicInfo = ({ - fullUrl, topicId, idx, topicImage, @@ -43,15 +44,16 @@ const TopicInfo = ({ topicPinCount, topicBookmarkCount, topicDescription, + canUpdate, isInAtlas, isBookmarked, setTopicsFromServer, }: TopicInfoProps) => { - const { routePage } = useNavigator(); + const [isUpdate, setIsUpdate] = useState(false); const { showToast } = useToast(); - const goToNewPin = () => { - routePage(`/new-pin?topic-id=${topicId}`, fullUrl); + const updateTopicInfo = () => { + setIsUpdate(true); }; const copyContent = async () => { @@ -64,6 +66,22 @@ const TopicInfo = ({ } }; + useEffect(() => { + if (!isUpdate) setTopicsFromServer(); + }, [isUpdate]); + + if (isUpdate) { + return ( + + ); + } + return ( - 토픽 이미지) => { e.currentTarget.src = DEFAULT_TOPIC_IMAGE; @@ -86,21 +104,30 @@ const TopicInfo = ({ - - - - - - {topicPinCount > 999 ? '+999' : topicPinCount}개 - - - - - - - {topicBookmarkCount > 999 ? '+999' : topicBookmarkCount}명 - + + + + + + + {topicPinCount > 999 ? '+999' : topicPinCount}개 + + + + + + + {topicBookmarkCount > 999 ? '+999' : topicBookmarkCount}명 + + + {canUpdate && ( + + + 수정하기 + + + )} @@ -127,7 +154,7 @@ const TopicInfo = ({ {isInAtlas ? ( @@ -139,7 +166,7 @@ const TopicInfo = ({ {isBookmarked ? : } @@ -158,4 +185,8 @@ const ButtonsWrapper = styled.div` align-items: center; `; +const TopicImage = styled(Image)` + border-radius: ${({ theme }) => theme.radius.medium}; +`; + export default TopicInfo; diff --git a/frontend/src/components/TopicListContainer/index.tsx b/frontend/src/components/TopicListContainer/index.tsx deleted file mode 100644 index 54f4a4c6b..000000000 --- a/frontend/src/components/TopicListContainer/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { styled } from 'styled-components'; -import Flex from '../common/Flex'; -import Text from '../common/Text'; -import Box from '../common/Box'; -import Space from '../common/Space'; -import { lazy, Suspense } from 'react'; -import TopicCardListSkeleton from '../TopicCardList/TopicCardListSkeleton'; -import { TopicType } from '../../types/Topic'; -import useKeyDown from '../../hooks/useKeyDown'; - -const TopicCardList = lazy(() => import('../TopicCardList')); - -interface TopicListContainerProps { - containerTitle: string; - containerDescription: string; - routeWhenSeeAll: () => void; - topics: TopicType[]; - setTopicsFromServer: () => void; -} - -const TopicListContainer = ({ - containerTitle, - containerDescription, - routeWhenSeeAll, - topics, - setTopicsFromServer, -}: TopicListContainerProps) => { - const { elementRef, onElementKeyDown } = useKeyDown(); - - return ( -
- - - - {containerTitle} - - - - {containerDescription} - - - - - 전체 보기 - - - - - - }> - - -
- ); -}; - -const PointerText = styled(Text)` - cursor: pointer; -`; - -export default TopicListContainer; diff --git a/frontend/src/constants/responsive.ts b/frontend/src/constants/responsive.ts new file mode 100644 index 000000000..2e41cf3d5 --- /dev/null +++ b/frontend/src/constants/responsive.ts @@ -0,0 +1,13 @@ +import { css } from 'styled-components'; + +export const setFullScreenResponsive = () => { + return css` + @media (max-width: 1076px) { + width: 684px; + } + + @media (max-width: 724px) { + width: 332px; + } + `; +}; diff --git a/frontend/src/context/CoordinatesContext.tsx b/frontend/src/context/CoordinatesContext.tsx index 33d81b30f..ab4090782 100644 --- a/frontend/src/context/CoordinatesContext.tsx +++ b/frontend/src/context/CoordinatesContext.tsx @@ -7,9 +7,10 @@ import { } from 'react'; export interface Coordinate { - latitude: string; - longitude: string; + latitude: number; + longitude: number; address?: string; + topicId?: string; } export interface CoordinatesContextType { @@ -22,7 +23,7 @@ export interface CoordinatesContextType { export const CoordinatesContext = createContext({ coordinates: [], setCoordinates: () => {}, - clickedCoordinate: { latitude: '', longitude: '', address: '' }, + clickedCoordinate: { latitude: 0, longitude: 0, address: '' }, setClickedCoordinate: () => {}, }); @@ -32,18 +33,18 @@ interface Props { const CoordinatesProvider = ({ children }: Props): JSX.Element => { const [coordinates, setCoordinates] = useState([ - { latitude: '37.5055', longitude: '127.0509' }, + { latitude: 37.5055, longitude: 127.0509 }, ]); const [clickedCoordinate, setClickedCoordinate] = useState({ - latitude: '', - longitude: '', + latitude: 0, + longitude: 0, address: '', }); // new-pin페이지가 아닌 경우 address를 =''로 고정 useEffect(() => { if (location.pathname !== '/new-pin') { - setClickedCoordinate({ ...clickedCoordinate, address: '' }); + setClickedCoordinate((prevState) => ({ ...prevState, address: '' })); } }, [location.pathname]); diff --git a/frontend/src/context/MarkerContext.tsx b/frontend/src/context/MarkerContext.tsx index 362ad41a7..11adc7f30 100644 --- a/frontend/src/context/MarkerContext.tsx +++ b/frontend/src/context/MarkerContext.tsx @@ -1,27 +1,31 @@ import { createContext, useContext, useState } from 'react'; -import { CoordinatesContext } from './CoordinatesContext'; +import { Coordinate, CoordinatesContext } from './CoordinatesContext'; import { useParams } from 'react-router-dom'; import useNavigator from '../hooks/useNavigator'; import { pinColors, pinImageMap } from '../constants/pinImage'; type MarkerContextType = { - markers: any[]; - clickedMarker: any; - createMarkers: (map: any) => void; + markers: Marker[]; + clickedMarker: Marker | null; + createMarkers: (map: TMap) => void; removeMarkers: () => void; removeInfowindows: () => void; - createInfowindows: (map: any) => void; - displayClickedMarker: (map: any) => void; + createInfowindows: (map: TMap) => void; + displayClickedMarker: (map: TMap) => void; +}; + +const defaultMarkerContext = () => { + throw new Error('MarkerContext가 제공되지 않았습니다.'); }; export const MarkerContext = createContext({ markers: [], clickedMarker: null, - createMarkers: () => {}, - removeMarkers: () => {}, - displayClickedMarker: () => {}, - removeInfowindows: () => {}, - createInfowindows: () => {}, + createMarkers: defaultMarkerContext, + removeMarkers: defaultMarkerContext, + removeInfowindows: defaultMarkerContext, + createInfowindows: defaultMarkerContext, + displayClickedMarker: defaultMarkerContext, }); interface Props { @@ -29,20 +33,33 @@ interface Props { } const MarkerProvider = ({ children }: Props): JSX.Element => { - const [markers, setMarkers] = useState([]); - const [infoWindows, setInfoWindows] = useState([]); - const [clickedMarker, setClickedMarker] = useState(null); + const { Tmapv3 } = window; + const [markers, setMarkers] = useState([]); + const [infoWindows, setInfoWindows] = useState(null); + const [clickedMarker, setClickedMarker] = useState(null); const { coordinates, clickedCoordinate } = useContext(CoordinatesContext); const { topicId } = useParams<{ topicId: string }>(); const { routePage } = useNavigator(); + const createMarker = ( + coordinate: Coordinate, + map: TMap, + markerType: number, + ) => { + return new Tmapv3.Marker({ + position: new Tmapv3.LatLng(coordinate.latitude, coordinate.longitude), + icon: pinImageMap[markerType + 1], + map, + }); + }; + // 현재 클릭된 좌표의 마커 생성 - const displayClickedMarker = (map: any) => { + const displayClickedMarker = (map: TMap) => { if (clickedMarker) { clickedMarker.setMap(null); } - const marker = new window.Tmapv3.Marker({ - position: new window.Tmapv3.LatLng( + const marker = new Tmapv3.Marker({ + position: new Tmapv3.LatLng( clickedCoordinate.latitude, clickedCoordinate.longitude, ), @@ -54,40 +71,29 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { }; //coordinates를 받아서 marker를 생성하고, marker를 markers 배열에 추가 - const createMarkers = (map: any) => { + const createMarkers = (map: TMap) => { let markerType = -1; let currentTopicId = '-1'; - const newMarkers = coordinates.map((coordinate: any) => { - // coordinate.topicId를 나누기 7한 나머지를 문자열로 변환 + let newMarkers = coordinates.map((coordinate: any) => { if (currentTopicId !== coordinate.topicId) { markerType = (markerType + 1) % 7; currentTopicId = coordinate.topicId; } - - const marker = new window.Tmapv3.Marker({ - position: new window.Tmapv3.LatLng( - coordinate.latitude, - coordinate.longitude, - ), - icon: pinImageMap[markerType + 1], - map, - }); + let marker = createMarker(coordinate, map, markerType); marker.id = String(coordinate.id); return marker; }); - //newMarkers 각각에 onClick 이벤트를 추가 - newMarkers.forEach((marker: any) => { + newMarkers.forEach((marker: Marker) => { marker.on('click', () => { routePage(`/topics/${topicId}?pinDetail=${marker.id}`); }); }); - setMarkers(newMarkers); }; - const createInfowindows = (map: any) => { + const createInfowindows = (map: TMap) => { let markerType = -1; let currentTopicId = '-1'; @@ -97,16 +103,13 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { currentTopicId = coordinate.topicId; } - const infoWindow = new window.Tmapv3.InfoWindow({ - position: new window.Tmapv3.LatLng( - coordinate.latitude, - coordinate.longitude, - ), + const infoWindow = new Tmapv3.InfoWindow({ + position: new Tmapv3.LatLng(coordinate.latitude, coordinate.longitude), content: `
${coordinate.pinName}
`, - offset: new window.Tmapv3.Point(0, -60), + offset: new Tmapv3.Point(0, -60), type: 2, map: map, }); @@ -117,12 +120,12 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { }; const removeMarkers = () => { - markers.forEach((marker: any) => marker.setMap(null)); + markers?.forEach((marker: Marker) => marker.setMap(null)); setMarkers([]); }; const removeInfowindows = () => { - infoWindows.forEach((infowindow: any) => infowindow.setMap(null)); + infoWindows?.forEach((infoWindow: InfoWindow) => infoWindow.setMap(null)); setInfoWindows([]); }; diff --git a/frontend/src/context/NavbarHighlightsContext.tsx b/frontend/src/context/NavbarHighlightsContext.tsx index 8d71d0793..95db38798 100644 --- a/frontend/src/context/NavbarHighlightsContext.tsx +++ b/frontend/src/context/NavbarHighlightsContext.tsx @@ -6,39 +6,39 @@ import { useState, } from 'react'; -interface NavbarHighlightsProps { - home: boolean; - seeTogether: boolean; - addMapOrPin: boolean; - favorite: boolean; - profile: boolean; -} +export type NavbarHighlightKeys = + | 'home' + | 'seeTogether' + | 'addMapOrPin' + | 'favorite' + | 'profile'; -interface NavbarHighlightsContextProps { - navbarHighlights: NavbarHighlightsProps; - setNavbarHighlights: Dispatch>; -} +export type NavbarHighlights = { + [key in NavbarHighlightKeys]: boolean; +}; interface NavbarHighlightsProviderProps { children: ReactNode; } -export const NavbarHighlightsContext = - createContext({ - navbarHighlights: { - home: true, - seeTogether: false, - addMapOrPin: false, - favorite: false, - profile: false, - }, - setNavbarHighlights: () => {}, - }); +export const NavbarHighlightsContext = createContext<{ + navbarHighlights: NavbarHighlights; + setNavbarHighlights: Dispatch>; +}>({ + navbarHighlights: { + home: true, + seeTogether: false, + addMapOrPin: false, + favorite: false, + profile: false, + }, + setNavbarHighlights: () => {}, +}); const NavbarHighlightsProvider = ({ children, }: NavbarHighlightsProviderProps) => { - const [navbarHighlights, setNavbarHighlights] = useState({ + const [navbarHighlights, setNavbarHighlights] = useState({ home: true, seeTogether: false, addMapOrPin: false, @@ -48,10 +48,7 @@ const NavbarHighlightsProvider = ({ return ( {children} diff --git a/frontend/src/context/SeeTogetherContext.tsx b/frontend/src/context/SeeTogetherContext.tsx index 311fc7cfd..81af69af7 100644 --- a/frontend/src/context/SeeTogetherContext.tsx +++ b/frontend/src/context/SeeTogetherContext.tsx @@ -5,11 +5,11 @@ import { createContext, useState, } from 'react'; -import { TopicType } from '../types/Topic'; +import { TopicCardProps } from '../types/Topic'; interface SeeTogetherContextProps { - seeTogetherTopics: TopicType[] | null; - setSeeTogetherTopics: Dispatch>; + seeTogetherTopics: TopicCardProps[] | null; + setSeeTogetherTopics: Dispatch>; } interface SeeTogetherProviderProps { @@ -23,7 +23,7 @@ export const SeeTogetherContext = createContext({ const SeeTogetherProvider = ({ children }: SeeTogetherProviderProps) => { const [seeTogetherTopics, setSeeTogetherTopics] = useState< - TopicType[] | null + TopicCardProps[] | null >(null); return ( diff --git a/frontend/src/hooks/useAnimateClickedPin.ts b/frontend/src/hooks/useAnimateClickedPin.ts index 615912dec..50e86dcc0 100644 --- a/frontend/src/hooks/useAnimateClickedPin.ts +++ b/frontend/src/hooks/useAnimateClickedPin.ts @@ -1,18 +1,15 @@ import { useEffect } from 'react'; -const useAnimateClickedPin = (map: any, markers: any) => { +const useAnimateClickedPin = (map: TMap | null, markers: Marker[]) => { useEffect(() => { const queryParams = new URLSearchParams(location.search); if (queryParams.has('pinDetail')) { const pinId = queryParams.get('pinDetail'); - const marker = markers.find((marker: any) => marker.id === pinId); - if (marker) { + const marker = markers.find((marker: Marker) => marker.id === pinId); + if (marker && map) { map.setCenter(marker.getPosition()); map.setZoom(17); - // marker._marker_data.options.animation = - // window.Tmapv3.MarkerOptions.ANIMATE_FLICKER; - // marker._marker_data.options.animationLength = 350; } } }, [markers, map]); diff --git a/frontend/src/hooks/useClickedCoordinate.ts b/frontend/src/hooks/useClickedCoordinate.ts index 874cc8890..00eafe57a 100644 --- a/frontend/src/hooks/useClickedCoordinate.ts +++ b/frontend/src/hooks/useClickedCoordinate.ts @@ -2,7 +2,8 @@ import { useContext, useEffect } from 'react'; import { CoordinatesContext } from '../context/CoordinatesContext'; import { MarkerContext } from '../context/MarkerContext'; -export default function useClickedCoordinate(map: any) { +export default function useClickedCoordinate(map: TMap | null) { + const { Tmapv3 } = window; const { clickedCoordinate } = useContext(CoordinatesContext); const { displayClickedMarker } = useContext(MarkerContext); @@ -13,7 +14,7 @@ export default function useClickedCoordinate(map: any) { // 선택된 좌표가 있으면 해당 좌표로 지도의 중심을 이동 if (clickedCoordinate.latitude && clickedCoordinate.longitude) { map.panTo( - new window.Tmapv3.LatLng( + new Tmapv3.LatLng( clickedCoordinate.latitude, clickedCoordinate.longitude, ), diff --git a/frontend/src/hooks/useCompressImage.ts b/frontend/src/hooks/useCompressImage.ts new file mode 100644 index 000000000..52579a0cc --- /dev/null +++ b/frontend/src/hooks/useCompressImage.ts @@ -0,0 +1,30 @@ +import imageCompression from 'browser-image-compression'; + +const useCompressImage = () => { + const compressImage = async (file: File) => { + const resizingBlob = await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 750, + useWebWorker: true, + }); + const resizingFile = new File([resizingBlob], file.name, { + type: file.type, + }); + return resizingFile; + }; + + const compressImageList = async (files: FileList) => { + const compressedImageList: File[] = []; + + for (const file of files) { + const compressedImage = await compressImage(file); + compressedImageList.push(compressedImage); + } + + return compressedImageList; + }; + + return { compressImage, compressImageList }; +}; + +export default useCompressImage; diff --git a/frontend/src/hooks/useFocusToMarkers.ts b/frontend/src/hooks/useFocusToMarkers.ts index 30e287143..a0aa77042 100644 --- a/frontend/src/hooks/useFocusToMarkers.ts +++ b/frontend/src/hooks/useFocusToMarkers.ts @@ -1,16 +1,17 @@ import { useEffect, useRef } from 'react'; -const useFocusToMarker = (map: any, markers: any) => { - const bounds = useRef(new window.Tmapv3.LatLngBounds()); +const useFocusToMarker = (map: TMap | null, markers: Marker[]) => { + const { Tmapv3 } = window; + const bounds = useRef(new Tmapv3.LatLngBounds()); useEffect(() => { - if (markers.length === 1) { + if (map && markers && markers.length === 1) { map.panTo(markers[0].getPosition()); } - if (markers.length > 1) { - bounds.current = new window.Tmapv3.LatLngBounds(); - markers.forEach((marker: any) => { + if (map && markers && markers.length > 1) { + bounds.current = new Tmapv3.LatLngBounds(); + markers.forEach((marker: Marker) => { bounds.current.extend(marker.getPosition()); }); diff --git a/frontend/src/hooks/useMapClick.ts b/frontend/src/hooks/useMapClick.ts index 4a7f9bc0c..0e8dfdc8d 100644 --- a/frontend/src/hooks/useMapClick.ts +++ b/frontend/src/hooks/useMapClick.ts @@ -3,28 +3,30 @@ import { CoordinatesContext } from '../context/CoordinatesContext'; import getAddressFromServer from '../lib/getAddressFromServer'; import useToast from './useToast'; -export default function useMapClick(map: any) { +export default function useMapClick(map: TMap | null) { const { setClickedCoordinate } = useContext(CoordinatesContext); const { showToast } = useToast(); - useEffect(() => { - if (!map) return; - const clickHandler = async (evt: any) => { + const clickHandler = async (evt: evt) => { + try { const roadName = await getAddressFromServer( evt.data.lngLat._lat, evt.data.lngLat._lng, ); - if (roadName.id) { - showToast('error', `제공되지 않는 주소 범위입니다.`); - } - setClickedCoordinate({ latitude: evt.data.lngLat._lat, longitude: evt.data.lngLat._lng, address: roadName, }); - }; + } catch (e) { + showToast('error', `제공되지 않는 주소 범위입니다.`); + } + }; + + useEffect(() => { + if (!map) return; + map.on('Click', clickHandler); return () => { diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts index 957b19378..77f76cde4 100644 --- a/frontend/src/hooks/useNavigator.ts +++ b/frontend/src/hooks/useNavigator.ts @@ -1,14 +1,38 @@ -import { useNavigate } from 'react-router-dom'; +import { useContext } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ModalContext } from '../context/ModalContext'; const useNavigator = () => { const navigator = useNavigate(); + const { openModal, closeModal } = useContext(ModalContext); + const { topicId } = useParams(); const routePage = (url: string | -1, value?: string | number | number[]) => { if (typeof url === 'string') navigator(url, { state: value }); if (url === -1) navigator(url); }; - return { routePage }; + return { + routingHandlers: { + home: () => routePage('/'), + seeTogether: () => routePage('/see-together'), + addMapOrPin: () => openModal('addMapOrPin'), + favorite: () => routePage('/favorite'), + profile: () => routePage('/my-page'), + newTopic: () => { + routePage('/new-topic'); + closeModal('addMapOrPin'); + }, + newPin: () => { + routePage('/new-pin', topicId); + closeModal('addMapOrPin'); + }, + goToPopularTopics: () => routePage('see-all/popularity'), + goToNearByMeTopics: () => routePage('see-all/near'), + goToLatestTopics: () => routePage('see-all/latest'), + }, + routePage, + }; }; export default useNavigator; diff --git a/frontend/src/hooks/useSetNavbarHighlight.ts b/frontend/src/hooks/useSetNavbarHighlight.ts index b45fecd0d..6a8887653 100644 --- a/frontend/src/hooks/useSetNavbarHighlight.ts +++ b/frontend/src/hooks/useSetNavbarHighlight.ts @@ -1,7 +1,11 @@ import { useContext, useEffect } from 'react'; -import { NavbarHighlightsContext } from '../context/NavbarHighlightsContext'; +import { + NavbarHighlightKeys, + NavbarHighlights, + NavbarHighlightsContext, +} from '../context/NavbarHighlightsContext'; -const navbarPageNames = [ +const navbarPageNames: NavbarHighlightKeys[] = [ 'home', 'seeTogether', 'addMapOrPin', @@ -9,84 +13,19 @@ const navbarPageNames = [ 'profile', ]; -const useSetNavbarHighlight = (pageName: string) => { +const useSetNavbarHighlight = (pageName: NavbarHighlightKeys) => { const { navbarHighlights, setNavbarHighlights } = useContext( NavbarHighlightsContext, ); useEffect(() => { - if (!navbarPageNames.includes(pageName)) { - setNavbarHighlights({ - home: false, - seeTogether: false, - addMapOrPin: false, - favorite: false, - profile: false, - }); + const newNavbarHighlights: NavbarHighlights = navbarPageNames.reduce( + (acc, curr) => ({ ...acc, [curr]: curr === pageName }), + {} as NavbarHighlights, + ); - return; - } - - if (pageName === 'home') { - setNavbarHighlights({ - home: true, - seeTogether: false, - addMapOrPin: false, - favorite: false, - profile: false, - }); - - return; - } - - if (pageName === 'seeTogether') { - setNavbarHighlights({ - home: false, - seeTogether: true, - addMapOrPin: false, - favorite: false, - profile: false, - }); - - return; - } - - if (pageName === 'addMapOrPin') { - setNavbarHighlights({ - home: false, - seeTogether: false, - addMapOrPin: true, - favorite: false, - profile: false, - }); - - return; - } - - if (pageName === 'favorite') { - setNavbarHighlights({ - home: false, - seeTogether: false, - addMapOrPin: false, - favorite: true, - profile: false, - }); - - return; - } - - if (pageName === 'profile') { - setNavbarHighlights({ - home: false, - seeTogether: false, - addMapOrPin: false, - favorite: false, - profile: true, - }); - - return; - } - }, []); + setNavbarHighlights(newNavbarHighlights); + }, [pageName]); return { navbarHighlights }; }; diff --git a/frontend/src/hooks/useUpdateCoordinates.ts b/frontend/src/hooks/useUpdateCoordinates.ts index 8e52bf9e3..6bc33d86a 100644 --- a/frontend/src/hooks/useUpdateCoordinates.ts +++ b/frontend/src/hooks/useUpdateCoordinates.ts @@ -2,11 +2,7 @@ import { useContext, useEffect } from 'react'; import { CoordinatesContext } from '../context/CoordinatesContext'; import { MarkerContext } from '../context/MarkerContext'; -interface UseUpdateCoordinatesProps { - map: any; -} - -export default function useUpdateCoordinates(map: UseUpdateCoordinatesProps) { +export default function useUpdateCoordinates(map: TMap | null) { const { coordinates } = useContext(CoordinatesContext); const { markers, @@ -18,7 +14,7 @@ export default function useUpdateCoordinates(map: UseUpdateCoordinatesProps) { useEffect(() => { if (!map) return; - if (markers.length > 0) { + if (markers && markers.length > 0) { removeMarkers(); removeInfowindows(); } diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index d5d0e6e6d..314ac3d8f 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -4,7 +4,7 @@ import { ThemeProvider } from 'styled-components'; import theme from './themes'; import GlobalStyle from './GlobalStyle'; import ErrorBoundary from './components/ErrorBoundary'; -import NotFound from './components/NotFound'; +import NotFound from './pages/NotFound'; const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Failed to find the root element'); diff --git a/frontend/src/lib/getAddressFromServer.ts b/frontend/src/lib/getAddressFromServer.ts index 42ad45e25..3703a9b2b 100644 --- a/frontend/src/lib/getAddressFromServer.ts +++ b/frontend/src/lib/getAddressFromServer.ts @@ -1,19 +1,17 @@ import { getMapApi } from '../apis/getMapApi'; +import { MapAddressProps } from '../types/Map'; -const getAddressFromServer = async (lat: any, lng: any) => { +const getAddressFromServer = async (lat: number, lng: number) => { const version = '1'; const coordType = 'WGS84GEO'; const addressType = 'A10'; const callback = 'result'; - const addressData = await getMapApi( + + const addressData = await getMapApi( `https://apis.openapi.sk.com/tmap/geo/reversegeocoding?version=${version}&lat=${lat}&lon=${lng}&coordType=${coordType}&addressType=${addressType}&callback=${callback}&appKey=P2MX6F1aaf428AbAyahIl9L8GsIlES04aXS9hgxo `, ); - if (addressData.error) { - return addressData.error; - } - const addressResult = addressData.addressInfo.fullAddress.split(','); return addressResult[2]; }; diff --git a/frontend/src/mocks/db/atlas.js b/frontend/src/mocks/db/atlas.js new file mode 100644 index 000000000..05c4972ac --- /dev/null +++ b/frontend/src/mocks/db/atlas.js @@ -0,0 +1,3 @@ +const atlas = []; + +export default atlas; diff --git a/frontend/src/mocks/db/bestTopics.js b/frontend/src/mocks/db/bestTopics.js new file mode 100644 index 000000000..6d3dcdd34 --- /dev/null +++ b/frontend/src/mocks/db/bestTopics.js @@ -0,0 +1,26 @@ +const bestTopics = [ + { + id: 1, + name: '준팍의 또 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 3, + isInAtlas: false, + bookmarkCount: 5, + isBookmarked: true, + updatedAt: '2023-08-17T20:44:58.712930015', + }, + { + id: 2, + name: '준팍의 두번째 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 5, + isInAtlas: false, + bookmarkCount: 3, + isBookmarked: false, + updatedAt: '2023-08-17T20:44:58.712945115', + }, +]; + +export default bestTopics \ No newline at end of file diff --git a/frontend/src/mocks/db/bookmarks.js b/frontend/src/mocks/db/bookmarks.js new file mode 100644 index 000000000..5f4e0eec3 --- /dev/null +++ b/frontend/src/mocks/db/bookmarks.js @@ -0,0 +1,15 @@ +const bookmarks = [ + { + id: 1, + name: '준팍의 또 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 5, + isInAtlas: false, + bookmarkCount: 0, + isBookmarked: true, + updatedAt: '2023-08-17T20:44:43.857108576', + }, +]; + +export default bookmarks; diff --git a/frontend/src/mocks/db/detailTopic.js b/frontend/src/mocks/db/detailTopic.js index 1252b5a8b..617b0fd1a 100644 --- a/frontend/src/mocks/db/detailTopic.js +++ b/frontend/src/mocks/db/detailTopic.js @@ -1,75 +1,65 @@ const detailTopic = [ { - id: '1', - name: '선릉 직장인이 추천하는 맛집', - description: '선릉 직장인이 돌아다니면서 기록한 맛집 리스트예요.', - image: 'image', - pinCount: 3, - updatedAt: '2023-07-12', + id: 1, + name: '준팍의 또 토픽', + description: '준팍이 막 만든 또 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 2, + isInAtlas: false, + bookmarkCount: 0, + isBookmarked: false, + updatedAt: '2023-08-01T17:23:00.123284785', pins: [ { - id: '1', - name: '잇쇼우', - address: '서울특별시 선릉 테헤란로 127길 16', - description: - '돈까스와 모밀, 우동 등 다양한 일식 메뉴가 있어요. 돈까스가 특히 맛있습니다.', - latitude: 37.512, - longitude: 127.102, + id: 1, + name: '매튜의 산스장', + address: '지번 주소', + description: '매튜가 사랑하는 산스장', + creator: '매튜', + latitude: 36.0, + longitude: 128.0, }, { - id: '2', - name: '오또상스시', - address: '서울특별시 선릉 테헤란로 192-46', - description: - '초밥을 파는 곳입니다. 점심 특선 있고 초밥 질이 괜찮습니다. 가격대도 다른 곳에 비해서 양호한 편이고 적당히 생각날...', - latitude: 37.541, - longitude: 127.0782, + id: 2, + name: '매튜의 안갈집', + address: '지번 주소', + description: '매튜가 두번은 안 갈 집', + creator: '매튜', + latitude: 37.0, + longitude: 127.0, }, ], }, { - id: '2', - name: '산스장 모음', - description: - '초밥을 파는 곳입니다. 점심 특선 있고 초밥 질이 괜찮습니다. 가격대도 다른 곳에 비해서 양호한 편이고 적당히 생각날...', - image: 'image', - pinCount: 5, - updatedAt: '2022-12-25', + id: 2, + name: '준팍의 두번째 토픽', + description: '준팍이 막 만든 두번째 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 2, + isInAtlas: false, + bookmarkCount: 0, + isBookmarked: false, + updatedAt: '2023-08-17T20:45:00.123284785', pins: [ { - id: '1', - name: '잇쇼우', - address: '서울특별시 선릉 테헤란로 127길 16', - description: - '돈까스와 모밀, 우동 등 다양한 일식 메뉴가 있어요. 돈까스가 특히 맛있습니다.', - latitude: 37.5788, - longitude: 126.977, + id: 1, + name: '매튜의 산스장', + address: '지번 주소', + description: '매튜가 사랑하는 산스장', + creator: '매튜', + latitude: 36.0, + longitude: 124.0, }, { - id: '2', - name: '오또상스시', - address: '서울특별시 선릉 테헤란로 192-46', - description: - '초밥을 파는 곳입니다. 점심 특선 있고 초밥 질이 괜찮습니다. 가격대도 다른 곳에 비해서 양호한 편이고 적당히 생각날...', - latitude: 37.5658, - longitude: 126.9753, - }, - { - id: '3', - name: '피양콩할마니', - address: '서울특별시 선릉 테헤란로 127길 16', - description: - '백색깔의 콩비지 맛집입니다~ MSG 안들어가요 고소하니 좋네요~', - latitude: 37.5792, - longitude: 126.9943, - }, - { - id: '4', - name: '용용선생', - address: '서울특별시 선릉 테헤란로 192-46', - description: '마라탕 맛집~ 든든하이 회식하기에도 좋습니다.', - latitude: 37.5722, - longitude: 126.9794, + id: 2, + name: '매튜의 안갈집', + address: '지번 주소', + description: '매튜가 두번은 안 갈 집', + creator: '매튜', + latitude: 37.0, + longitude: 127.0, }, ], }, diff --git a/frontend/src/mocks/db/login.js b/frontend/src/mocks/db/login.js new file mode 100644 index 000000000..b2b175377 --- /dev/null +++ b/frontend/src/mocks/db/login.js @@ -0,0 +1,13 @@ +const resLogin = { + accessToken: + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MjIzMzcyMDM2ODU0Nzc1ODA3IiwiaWF0IjoxNjkyMjcyNjg3LCJleHAiOjE2OTIyNzYyODd9.FWB1RFvk2uGInqGQDkw0SU4Lghzcggh9TSfuDEZvIUo', + member: { + id: 9223372036854775807, + nickName: '모험가03fcb0d', + email: 'yshert@naver.com', + imageUrl: 'https://map-befine-official.github.io/favicon.png', + updatedAt: '2023-08-17T20:44:47.535382743', + }, +}; + +export default resLogin \ No newline at end of file diff --git a/frontend/src/mocks/db/myTopics.js b/frontend/src/mocks/db/myTopics.js new file mode 100644 index 000000000..0f4d41111 --- /dev/null +++ b/frontend/src/mocks/db/myTopics.js @@ -0,0 +1,26 @@ +const myTopics = [ + { + id: 1, + name: '준팍의 또 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 5, + isInAtlas: false, + bookmarkCount: 0, + isBookmarked: true, + updatedAt: '2023-08-17T20:44:43.786554794', + }, + { + id: 2, + name: '준팍의 두번째 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 3, + isInAtlas: false, + bookmarkCount: 0, + isBookmarked: false, + updatedAt: '2023-08-17T20:44:43.786578894', + }, +]; + +export default myTopics \ No newline at end of file diff --git a/frontend/src/mocks/db/newestTopics.js b/frontend/src/mocks/db/newestTopics.js new file mode 100644 index 000000000..a50b1a785 --- /dev/null +++ b/frontend/src/mocks/db/newestTopics.js @@ -0,0 +1,26 @@ +const newestTopics = [ + { + id: 1, + name: '준팍의 또 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 3, + isInAtlas: false, + bookmarkCount: 5, + isBookmarked: true, + updatedAt: '2023-08-17T20:44:58.712930015', + }, + { + id: 2, + name: '준팍의 두번째 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', + pinCount: 5, + isInAtlas: false, + bookmarkCount: 3, + isBookmarked: false, + updatedAt: '2023-08-17T20:44:58.712945115', + }, +]; + +export default newestTopics \ No newline at end of file diff --git a/frontend/src/mocks/db/resLogin.js b/frontend/src/mocks/db/resLogin.js new file mode 100644 index 000000000..b2b175377 --- /dev/null +++ b/frontend/src/mocks/db/resLogin.js @@ -0,0 +1,13 @@ +const resLogin = { + accessToken: + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MjIzMzcyMDM2ODU0Nzc1ODA3IiwiaWF0IjoxNjkyMjcyNjg3LCJleHAiOjE2OTIyNzYyODd9.FWB1RFvk2uGInqGQDkw0SU4Lghzcggh9TSfuDEZvIUo', + member: { + id: 9223372036854775807, + nickName: '모험가03fcb0d', + email: 'yshert@naver.com', + imageUrl: 'https://map-befine-official.github.io/favicon.png', + updatedAt: '2023-08-17T20:44:47.535382743', + }, +}; + +export default resLogin \ No newline at end of file diff --git a/frontend/src/mocks/db/topics.js b/frontend/src/mocks/db/topics.js index a9c328347..15b89ee92 100644 --- a/frontend/src/mocks/db/topics.js +++ b/frontend/src/mocks/db/topics.js @@ -1,17 +1,25 @@ const topics = [ { - id: '1', - name: '선릉 직장인이 추천하는 맛집', - image: 'image', + id: 1, + name: '준팍의 또 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', pinCount: 3, - updatedAt: '2023-07-12', + isInAtlas: false, + bookmarkCount: 5, + isBookmarked: true, + updatedAt: '2023-08-17T20:44:58.712930015', }, { - id: '2', - name: '산스장 모음', - image: 'image', + id: 2, + name: '준팍의 두번째 토픽', + image: 'https://map-befine-official.github.io/favicon.png', + creator: '준팍', pinCount: 5, - updatedAt: '2022-12-25', + isInAtlas: false, + bookmarkCount: 3, + isBookmarked: false, + updatedAt: '2023-08-17T20:44:58.712945115', }, ]; diff --git a/frontend/src/mocks/handlers.js b/frontend/src/mocks/handlers.js index 4ef03a5f6..be36c323c 100644 --- a/frontend/src/mocks/handlers.js +++ b/frontend/src/mocks/handlers.js @@ -1,7 +1,13 @@ import { rest } from 'msw'; import topics from './db/topics'; +import newestTopics from './db/newestTopics'; +import bestTopics from './db/bestTopics'; import detailTopic from './db/detailTopic'; import tempPins from './db/tempPins'; +import resLogin from './db/resLogin'; +import bookmarks from './db/bookmarks'; +import myTopics from './db/myTopics'; +import atlas from './db/atlas'; export const handlers = [ // 포스트 목록 @@ -25,10 +31,52 @@ export const handlers = [ ); }), + // 인기 급상승 토픽 목록 + rest.get('/topics/bests', (req, res, ctx) => { + const data = bestTopics; + + if (!data) { + return res(ctx.status(403), ctx.json(data)); + } + + return res( + ctx.set('Content-Type', 'application/json'), + ctx.status(200), + ctx.json(data), + ); + }), + + // 최신 토픽 목록 + rest.get('/topics/newest', (req, res, ctx) => { + const data = newestTopics; + + if (!data) { + return res(ctx.status(403), ctx.json(data)); + } + + return res( + ctx.set('Content-Type', 'application/json'), + ctx.status(200), + ctx.json(data), + ); + }), + // 토픽 디테일 보기 - rest.get('/topics/:id', (req, res, ctx) => { - const topicId = Number(req.params.id); - const data = detailTopic.filter((pin) => Number(pin.id) === topicId); + rest.get('/topics/ids?ids=:id', (req, res, ctx) => { + let data = []; + if (req.url.searchParams.get('ids').split(',').length > 1) { + const topicId = req.url.searchParams.get('ids').split(','); + topicId.forEach((id) => { + detailTopic.forEach((topic) => { + if (Number(topic.id) === Number(id)) { + data.push(topic); + } + }); + }); + } else { + const topicId = Number(req.url.searchParams.get('ids')); + data = detailTopic.filter((topic) => Number(topic.id) === topicId); + } if (!data) { return res(ctx.status(403), ctx.json(data)); @@ -37,22 +85,92 @@ export const handlers = [ return res( ctx.set('Content-Type', 'application/json'), ctx.status(200), - ctx.json(data[0]), + ctx.json(data), + ); + }), + + // login + rest.get('/login', (req, res, ctx) => { + const data = resLogin; + + return res( + ctx.set('Content-Type', 'application/json'), + ctx.status(200), + ctx.json(data), + ); + }), + + // bookmarks + rest.get('/members/my/bookmarks', (req, res, ctx) => { + const data = bookmarks; + + return res( + ctx.set('Content-Type', 'application/json'), + ctx.status(200), + ctx.json(data), + ); + }), + + // 나의 지도 목록 + rest.get('/members/my/topics', (req, res, ctx) => { + const data = myTopics; + + return res( + ctx.set('Content-Type', 'application/json'), + ctx.status(200), + ctx.json(data), + ); + }), + + rest.get('/members/my/atlas', (req, res, ctx) => { + const data = atlas; + + return res( + ctx.set('Content-Type', 'application/json'), + ctx.status(200), + ctx.json(data), ); }), // 토픽 생성 rest.post('/topics/new', async (req, res, ctx) => { - const { name, image, description } = await req.json(); + const { name, image, description, pins, publicity, permissionType } = + await req.json(); topics.push({ id: `${topics.length + 1}`, image, name, - description, - pins: [], + creator: '패트릭', + isInAtlas: false, + isBookmarked: false, + bookmarkCount: 5, + pinCount: 0, + updatedAt: '2023-08-17T20:45:00.123284785', + }); + + newestTopics.push({ + id: `${newestTopics.length + 1}`, + image, + name, + creator: '패트릭', + isInAtlas: false, + isBookmarked: false, + bookmarkCount: 5, pinCount: 0, - updatedAt: '2023-07-19', + updatedAt: '2023-08-17T20:45:00.123284785', + }); + + bestTopics.push({ + id: `${bestTopics.length + 1}`, + image, + name, + creator: '패트릭', + isInAtlas: false, + isBookmarked: false, + bookmarkCount: 5, + pinCount: 0, + updatedAt: '2023-08-17T20:45:00.123284785', }); detailTopic.push({ @@ -60,9 +178,13 @@ export const handlers = [ image, name, description, - pins: [], + creator: '패트릭', + isInAtlas: false, + pins: pins, + isBookmarked: false, + bookmarkCount: 5, pinCount: 0, - updatedAt: '2023-07-19', + updatedAt: '2023-08-17T20:45:00.123284785', }); if (!name) { @@ -77,15 +199,18 @@ export const handlers = [ // 핀 생성 rest.post('/pins', async (req, res, ctx) => { - const { topicId, name, address, description } = await req.json(); + const { topicId, name, address, description, latitude, longitude } = + await req.json(); const newPin = { id: `${detailTopic[topicId - 1].pins.length + 1}`, name, description, address, - latitude: '37', - longitude: '127', + latitude: latitude, + longitude: longitude, + legalDongCode: '', + images: [], }; detailTopic[topicId - 1].pins.push(newPin); @@ -101,6 +226,42 @@ export const handlers = [ ); }), + // 즐겨찾기 추가 + rest.post('/bookmarks/topics?id=:id', async (req, res, ctx) => { + const id = req.url.searchParams.get('id'); + + const bookmarkTopic = []; + topics.forEach((topic) => { + if (topic.id === Number(id)) { + bookmarkTopic.push(topic); + } + }); + + bookmarks.push(bookmarkTopic[0]); + + return res( + ctx.status(201), + ctx.set('Location', `/bookmarks/topics/${id}}`), + ); + }), + + // 모아보기 추가 + rest.post('/atlas/topics?id=:id', async (req, res, ctx) => { + const id = req.url.searchParams.get('id'); + + const atlasTopic = []; + topics.forEach((topic) => { + if (topic.id === Number(id)) { + atlasTopic.push(topic); + } + }); + + atlas.push(atlasTopic[0]); + + return res(ctx.status(201)); + }), + + // pin 변경 rest.put('/pins/:id', async (req, res, ctx) => { const { id } = req.params; const { name, image, description } = await req.json(); diff --git a/frontend/src/pages/LoginError.tsx b/frontend/src/pages/AskLogin.tsx similarity index 94% rename from frontend/src/pages/LoginError.tsx rename to frontend/src/pages/AskLogin.tsx index e6d11fe9c..c1c7ef3df 100644 --- a/frontend/src/pages/LoginError.tsx +++ b/frontend/src/pages/AskLogin.tsx @@ -7,10 +7,10 @@ import Text from '../components/common/Text'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import { DEFAULT_PROD_URL, FULLSCREEN } from '../constants'; -const LoginError = () => { +const AskLogin = () => { const { width } = useSetLayoutWidth(FULLSCREEN); - const loginButtonClick = () => { + const loginButtonClick = async () => { window.location.href = `${DEFAULT_PROD_URL}/oauth/kakao`; }; @@ -53,4 +53,4 @@ const NotFoundButton = styled(Button)` border: 1px solid ${({ theme }) => theme.color.white}; `; -export default LoginError; +export default AskLogin; diff --git a/frontend/src/pages/AskLoginPage.tsx b/frontend/src/pages/AskLoginPage.tsx deleted file mode 100644 index bae9f9581..000000000 --- a/frontend/src/pages/AskLoginPage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { keyframes, styled } from 'styled-components'; -import Image from '../components/common/Image'; -import Text from '../components/common/Text'; -import Space from '../components/common/Space'; -import { DEFAULT_PROD_URL } from '../constants'; - -export default function AskLoginPage() { - const loginButtonClick = () => { - window.location.href = `${DEFAULT_PROD_URL}/oauth/kakao`; - }; - - return ( - - - 로그인해서 쓰면 더 재밌지롱~ - - - - - - - ); -} - -const ErrorContainer = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100vh; - text-align: center; -`; - -const pulseAnimation = keyframes` - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.2); - } - 100% { - transform: scale(1); - } -`; - -const RetryButton = styled.button` - display: flex; - justify-content: center; - align-items: center; - width: 200px; - height: 200px; - border: none; - border-radius: 50%; - background-color: #fee500; - font-size: 16px; - font-weight: 700; - color: black; - cursor: pointer; - animation: ${pulseAnimation} 1.5s infinite; -`; diff --git a/frontend/src/pages/Bookmark.tsx b/frontend/src/pages/Bookmark.tsx index ff62111d2..f84d6a42e 100644 --- a/frontend/src/pages/Bookmark.tsx +++ b/frontend/src/pages/Bookmark.tsx @@ -6,120 +6,70 @@ import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; -import { Suspense, lazy, useEffect, useState } from 'react'; -import FavoriteNotFilledSVG from '../assets/favoriteBtn_notFilled.svg'; -import TopicCardListSkeleton from '../components/TopicCardList/TopicCardListSkeleton'; -import useToast from '../hooks/useToast'; -import { TopicType } from '../types/Topic'; -import { getApi } from '../apis/getApi'; -import Button from '../components/common/Button'; +import { Suspense, lazy } from 'react'; +import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; import useNavigator from '../hooks/useNavigator'; +import FavoriteNotFilledSVG from '../assets/favoriteBtn_notFilled.svg'; +import { setFullScreenResponsive } from '../constants/responsive'; -const BookmarksList = lazy(() => import('../components/BookmarksList')); +const TopicCardList = lazy(() => import('../components/TopicCardList')); const Bookmark = () => { - const { width } = useSetLayoutWidth(FULLSCREEN); - const { navbarHighlights: __ } = useSetNavbarHighlight('favorite'); - const [bookmarks, setBookmarks] = useState(null); - const { showToast } = useToast(); const { routePage } = useNavigator(); - - const getBookmarksFromServer = async () => { - try { - const serverBookmarks = await getApi( - 'default', - '/members/my/bookmarks', - ); - setBookmarks(serverBookmarks); - } catch { - showToast('error', '로그인 후 이용해주세요.'); - } - }; + useSetLayoutWidth(FULLSCREEN); + useSetNavbarHighlight('favorite'); const goToHome = () => { routePage('/'); }; - useEffect(() => { - getBookmarksFromServer(); - }, []); - - if (!bookmarks) return <>; - - if (bookmarks.length === 0) { - return ( - - - - - - 버튼을 눌러 지도를 추가해보세요. - - - - - - - ); - } - return ( - -
- - - - - 즐겨찾기 - - - - 즐겨찾기한 지도들을 한 눈에 보세요. - - - + + + + + + 즐겨찾기 + + + + 즐겨찾기한 지도들을 한 눈에 보세요. + + + - + - }> - - -
-
+ }> + + + + +
); }; -const WrapperWhenEmpty = styled.section<{ width: '372px' | '100vw' }>` - width: ${({ width }) => `calc(${width} - 40px)`}; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -`; - -const PointerText = styled(Text)` - cursor: pointer; -`; - -const BookMarksWrapper = styled(Box)` +const Wrapper = styled.article` width: 1036px; margin: 0 auto; + + ${setFullScreenResponsive()} `; export default Bookmark; diff --git a/frontend/src/pages/ErrorPage.tsx b/frontend/src/pages/ErrorPage.tsx deleted file mode 100644 index 24c7fe9a4..000000000 --- a/frontend/src/pages/ErrorPage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useRouteError } from 'react-router-dom'; -import { keyframes, styled } from 'styled-components'; -import useNavigator from '../hooks/useNavigator'; - -interface Error { - statusText: string; - status: number; -} - -export default function RootErrorPage() { - const { routePage } = useNavigator(); - - const error: Error = useRouteError() as Error; - - return ( - -

Oops!

-

Sorry, an unexpected error has occurred.

- routePage('/')}> - {error.status || error.statusText} - -
- ); -} - -const ErrorContainer = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100vh; - text-align: center; -`; - -const pulseAnimation = keyframes` - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.2); - } - 100% { - transform: scale(1); - } -`; - -const RetryButton = styled.button` - display: flex; - justify-content: center; - align-items: center; - width: 150px; - height: 150px; - border: none; - border-radius: 50%; - background-color: #ff3b3b; - font-size: 16px; - font-weight: 700; - color: #ffffff; - cursor: pointer; - animation: ${pulseAnimation} 1.5s infinite; -`; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index d51222539..5ca139b9d 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,156 +1,81 @@ import Space from '../components/common/Space'; -import Box from '../components/common/Box'; import useNavigator from '../hooks/useNavigator'; -import TopicListContainer from '../components/TopicListContainer'; -import { styled } from 'styled-components'; +import { css, styled } from 'styled-components'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import { FULLSCREEN } from '../constants'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import useToast from '../hooks/useToast'; -import { TopicType } from '../types/Topic'; -import { getApi } from '../apis/getApi'; -import { useEffect, useState } from 'react'; -import Text from '../components/common/Text'; +import { Suspense, lazy, useContext, useEffect } from 'react'; +import { MarkerContext } from '../context/MarkerContext'; +import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; +import { setFullScreenResponsive } from '../constants/responsive'; -const Home = () => { - const [popularTopics, setPopularTopics] = useState(null); - const [nearTopics, setNearTopics] = useState(null); - const [newestTopics, setNewestTopics] = useState(null); - const { routePage } = useNavigator(); - const { width: _ } = useSetLayoutWidth(FULLSCREEN); - const { navbarHighlights: __ } = useSetNavbarHighlight('home'); - const { showToast } = useToast(); - - const goToPopularTopics = () => { - routePage('see-all/popularity'); - }; - - const goToNearByMeTopics = () => { - routePage('see-all/near'); - }; - - const goToLatestTopics = () => { - routePage('see-all/latest'); - }; - - const getNearTopicsFromServer = async () => { - try { - const topics = await getApi('default', `/topics`); - setNearTopics(topics); - } catch { - showToast( - 'error', - '로그인 정보가 만료되었습니다. 로그아웃 후 다시 로그인 해주세요.', - ); - } - }; +const TopicListContainer = lazy( + () => import('../components/TopicCardContainer'), +); - const getNewestTopicsFromServer = async () => { - try { - const topics = await getApi('default', '/topics/newest'); - setNewestTopics(topics); - } catch { - showToast( - 'error', - '로그인 정보가 만료되었습니다. 로그아웃 후 다시 로그인 해주세요.', - ); - } - }; +const Home = () => { + const { routingHandlers } = useNavigator(); + const { goToPopularTopics, goToLatestTopics, goToNearByMeTopics } = + routingHandlers; - const getPopularTopicsFromServer = async () => { - try { - const topics = await getApi('default', '/topics/bests'); - setPopularTopics(topics); - } catch { - showToast( - 'error', - '로그인 정보가 만료되었습니다. 로그아웃 후 다시 로그인 해주세요.', - ); - } - }; + const { markers, removeMarkers, removeInfowindows } = + useContext(MarkerContext); - const topicsFetchingFromServer = async () => { - await getPopularTopicsFromServer(); - await getNearTopicsFromServer(); - await getNewestTopicsFromServer(); - }; + useSetLayoutWidth(FULLSCREEN); + useSetNavbarHighlight('home'); useEffect(() => { - topicsFetchingFromServer(); + if (markers && markers.length > 0) { + removeMarkers(); + removeInfowindows(); + } }, []); - if (!(popularTopics && nearTopics && newestTopics)) return <>; - - if ( - popularTopics.length === 0 && - nearTopics.length === 0 && - newestTopics.length === 0 - ) { - return ( - - - 추가하기 버튼을 눌러 토픽을 추가해보세요! - - - - 토픽이 없습니다. - - - ); - } - return ( - <> - - + + + }> - + + + + + }> - + + + + + }> - - - + + + + ); }; -const EmptyWrapper = styled.section` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; +const Wrapper = styled.article` width: 1036px; - height: 100vh; margin: 0 auto; -`; - -const Wrapper = styled(Box)` - width: 1036px; - margin: 0 auto; -`; + position: relative; -const ModalWrapper = styled.div` - width: 300px; - height: 300px; - background-color: white; + ${setFullScreenResponsive()} `; export default Home; diff --git a/frontend/src/pages/KaKaoRedirectPage.tsx b/frontend/src/pages/KakaoRedirect.tsx similarity index 91% rename from frontend/src/pages/KaKaoRedirectPage.tsx rename to frontend/src/pages/KakaoRedirect.tsx index 9c4fbbcb2..f645214ca 100644 --- a/frontend/src/pages/KaKaoRedirectPage.tsx +++ b/frontend/src/pages/KakaoRedirect.tsx @@ -1,9 +1,9 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import { getApi } from '../apis/getApi'; import { keyframes, styled } from 'styled-components'; import useNavigator from '../hooks/useNavigator'; import { DEFAULT_PROD_URL } from '../constants'; +import { getLoginApi } from '../apis/getLoginApi'; // const API_URL = // process.env.NODE_ENV === 'production' @@ -13,7 +13,7 @@ import { DEFAULT_PROD_URL } from '../constants'; export const handleOAuthKakao = async (code: string) => { try { const url = `${DEFAULT_PROD_URL}/oauth/login/kakao?code=${code}`; - const data = await getApi('login', url); + const data = await getLoginApi(url); localStorage.setItem('userToken', data.accessToken); localStorage.setItem('user', JSON.stringify(data.member)); @@ -23,7 +23,7 @@ export const handleOAuthKakao = async (code: string) => { } }; -const KakaoRedirectPage = () => { +const KakaoRedirect = () => { const { routePage } = useNavigator(); const routerLocation = useLocation(); @@ -48,7 +48,7 @@ const KakaoRedirectPage = () => { ); }; -export default KakaoRedirectPage; +export default KakaoRedirect; const KakaoRedirectPageWrapper = styled.div` display: flex; diff --git a/frontend/src/pages/NewPin.tsx b/frontend/src/pages/NewPin.tsx index 73af429ec..c639f2e3f 100644 --- a/frontend/src/pages/NewPin.tsx +++ b/frontend/src/pages/NewPin.tsx @@ -6,7 +6,7 @@ import Button from '../components/common/Button'; import { postApi } from '../apis/postApi'; import { FormEvent, useContext, useEffect, useState } from 'react'; import { getApi } from '../apis/getApi'; -import { TopicType } from '../types/Topic'; +import { TopicCardProps } from '../types/Topic'; import useNavigator from '../hooks/useNavigator'; import { NewPinFormProps } from '../types/FormValues'; import useFormValues from '../hooks/useFormValues'; @@ -23,6 +23,8 @@ import { ModalContext } from '../context/ModalContext'; import Modal from '../components/Modal'; import { styled } from 'styled-components'; import ModalMyTopicList from '../components/ModalMyTopicList'; +import { getMapApi } from '../apis/getMapApi'; +import useCompressImage from '../hooks/useCompressImage'; type NewPinFormValueType = Pick< NewPinFormProps, @@ -34,6 +36,7 @@ const NewPin = () => { const { navbarHighlights: _ } = useSetNavbarHighlight('addMapOrPin'); const [topic, setTopic] = useState(null); const [selectedTopic, setSelectedTopic] = useState(null); + const [showedImages, setShowedImages] = useState([]); const { clickedMarker } = useContext(MarkerContext); const { clickedCoordinate, setClickedCoordinate } = useContext(CoordinatesContext); @@ -47,6 +50,9 @@ const NewPin = () => { const { showToast } = useToast(); const { width } = useSetLayoutWidth(SIDEBAR); const { openModal, closeModal } = useContext(ModalContext); + const { compressImageList } = useCompressImage(); + + const [formImages, setFormImages] = useState([]); const goToBack = () => { routePage(-1); @@ -56,6 +62,8 @@ const NewPin = () => { let postTopicId = topic?.id; let postName = formValues.name; + const formData = new FormData(); + if (!topic) { //토픽이 없으면 selectedTopic을 통해 토픽을 생성한다. postTopicId = selectedTopic?.topicId; @@ -65,7 +73,11 @@ const NewPin = () => { postTopicId = selectedTopic.topicId; } - await postApi('/pins', { + formImages.forEach((file) => { + formData.append('images', file); + }); + + const objectData = { topicId: postTopicId, name: postName, address: clickedCoordinate.address, @@ -73,8 +85,14 @@ const NewPin = () => { latitude: clickedCoordinate.latitude, longitude: clickedCoordinate.longitude, legalDongCode: '', - images: [], - }); + }; + + const data = JSON.stringify(objectData); + const jsonBlob = new Blob([data], { type: 'application/json' }); + + formData.append('request', jsonBlob); + + await postApi('/pins', formData); }; const onSubmit = async (e: FormEvent) => { @@ -101,8 +119,8 @@ const NewPin = () => { } setClickedCoordinate({ - latitude: '', - longitude: '', + latitude: 0, + longitude: 0, address: '', }); @@ -116,7 +134,7 @@ const NewPin = () => { if (!topic) { //토픽이 없으면 selectedTopic을 통해 토픽을 생성한다. postTopicId = selectedTopic?.topicId; - postName = selectedTopic?.topicTitle; + postName = selectedTopic?.topicName; } if (postTopicId) routePage(`/topics/${postTopicId}`, [postTopicId]); @@ -144,8 +162,7 @@ const NewPin = () => { const addr = data.roadAddress; // 주소 변수 //data를 통해 받아온 값을 Tmap api를 통해 위도와 경도를 구한다. - const { ConvertAdd } = await getApi( - 'tMap', + const { ConvertAdd } = await getMapApi( `https://apis.openapi.sk.com/tmap/geo/convertAddress?version=1&format=json&callback=result&searchTypCd=NtoO&appKey=P2MX6F1aaf428AbAyahIl9L8GsIlES04aXS9hgxo&coordType=WGS84GEO&reqAdd=${addr}`, ); const lat = ConvertAdd.oldLat; @@ -162,19 +179,50 @@ const NewPin = () => { }); }; + const onPinImageChange = async ( + event: React.ChangeEvent, + ) => { + const imageLists = event.target.files; + let imageUrlLists = [...showedImages]; + + if (!imageLists) { + showToast( + 'error', + '추가하신 이미지를 찾을 수 없습니다. 다시 선택해 주세요.', + ); + return; + } + + const compressedImageList = await compressImageList(imageLists); + + for (let i = 0; i < imageLists.length; i++) { + const currentImageUrl = URL.createObjectURL(compressedImageList[i]); + imageUrlLists.push(currentImageUrl); + } + + if (imageUrlLists.length > 8) { + showToast( + 'info', + '이미지 개수는 최대 8개까지만 선택 가능합니다. 다시 선택해 주세요.', + ); + imageUrlLists = imageUrlLists.slice(0, 8); + return; + } + + setFormImages([...formImages, ...compressedImageList]); + setShowedImages(imageUrlLists); + }; + useEffect(() => { const getTopicId = async () => { if (topicId && topicId.split(',').length === 1) { - const data = await getApi('default', `/topics/${topicId}`); + const data = await getApi(`/topics/${topicId}`); setTopic(data); } if (topicId && topicId.split(',').length > 1) { - const topics = await getApi( - 'default', - `/topics/ids?ids=${topicId}`, - ); + const topics = await getApi(`/topics/ids?ids=${topicId}`); setTopic(topics); } @@ -187,7 +235,7 @@ const NewPin = () => { <>
- @@ -197,32 +245,55 @@ const NewPin = () => { -
- - - 지도 선택 - - - - * - - - - -
+ + 지도 선택 + + + + + + + + + + 장소 사진 + + + 장소에 대한 사진을 추가해주세요. + + + + 파일 찾기 + + + + + {showedImages.map((image, id) => ( +
+ + +
+ ))} +
@@ -242,27 +313,25 @@ const NewPin = () => { -
- - - 장소 위치 - - - - * - - + + + 장소 위치 + - -
+ + * + +
+ + @@ -295,13 +364,13 @@ const NewPin = () => { 추가하기 - + @@ -321,14 +390,47 @@ const NewPin = () => { ); }; +const Wrapper = styled(Flex)` + margin: 0 auto; + + @media (max-width: 1076px) { + width: calc(50vw - 40px); + } + + @media (max-width: 744px) { + width: ${({ width }) => width}; + margin: 0 auto; + } +`; + const ModalContentsWrapper = styled.div` width: 100%; height: 100%; background-color: white; + display: flex; + flex-direction: column; + align-items: center; + overflow: scroll; +`; - text-align: center; +const ImageInputLabel = styled.label` + height: 40px; + padding: 10px 10px; - overflow: scroll; + color: ${({ theme }) => theme.color.black}; + background-color: ${({ theme }) => theme.color.lightGray}; + + font-size: ${({ theme }) => theme.fontSize.extraSmall}; + cursor: pointer; +`; + +const ShowImage = styled.img` + width: 80px; + height: 80px; +`; + +const ImageInputButton = styled.input` + display: none; `; export default NewPin; diff --git a/frontend/src/pages/NewTopic.tsx b/frontend/src/pages/NewTopic.tsx index 6793b862c..bd927fcb4 100644 --- a/frontend/src/pages/NewTopic.tsx +++ b/frontend/src/pages/NewTopic.tsx @@ -1,9 +1,8 @@ -import { useContext, useEffect, useState } from 'react'; +import { useContext, useState } from 'react'; import Text from '../components/common/Text'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; import Button from '../components/common/Button'; -import { postApi } from '../apis/postApi'; import useNavigator from '../hooks/useNavigator'; import { NewTopicFormProps } from '../types/FormValues'; import useFormValues from '../hooks/useFormValues'; @@ -12,54 +11,38 @@ import useToast from '../hooks/useToast'; import InputContainer from '../components/InputContainer'; import { hasErrorMessage, hasNullValue } from '../validations'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { DEFAULT_TOPIC_IMAGE, LAYOUT_PADDING, SIDEBAR } from '../constants'; +import { LAYOUT_PADDING, SIDEBAR } from '../constants'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import Modal from '../components/Modal'; -import { styled } from 'styled-components'; -import { ModalContext } from '../context/ModalContext'; -import { getApi } from '../apis/getApi'; -import { Member } from '../types/Login'; -import Checkbox from '../components/common/CheckBox'; import { TagContext } from '../context/TagContext'; +import usePost from '../apiHooks/usePost'; +import AuthorityRadioContainer from '../components/AuthorityRadioContainer'; +import styled from 'styled-components'; +import useCompressImage from '../hooks/useCompressImage'; type NewTopicFormValuesType = Omit; const NewTopic = () => { - const [isPrivate, setIsPrivate] = useState(false); // 혼자 볼 지도 : 같이 볼 지도 - const [isAll, setIsAll] = useState(true); // 모두 : 지정 인원 - const { openModal, closeModal } = useContext(ModalContext); + const { routePage } = useNavigator(); + const { state: pulledPinIds } = useLocation(); + const { showToast } = useToast(); + const { width } = useSetLayoutWidth(SIDEBAR); + const { navbarHighlights: _ } = useSetNavbarHighlight('addMapOrPin'); + const { setTags } = useContext(TagContext); + const { fetchPost } = usePost(); const { formValues, errorMessages, onChangeInput } = useFormValues({ name: '', description: '', image: '', }); - const { routePage } = useNavigator(); - const { state: taggedIds } = useLocation(); - const { showToast } = useToast(); - const { width } = useSetLayoutWidth(SIDEBAR); - const { navbarHighlights: _ } = useSetNavbarHighlight('addMapOrPin'); - const { setTags } = useContext(TagContext); + const { compressImage } = useCompressImage(); - const [members, setMembers] = useState([]); - - useEffect(() => { - const getMemberData = async () => { - const memberData = await getApi('default', `/members`); - setMembers(memberData); - }; - - getMemberData(); - }, []); - - //해당 토픽에 권한을 부여할 아이디들을 담는 state - //addAuthority에 인자로 넘겨줌 - const [checkedMemberIds, setCheckedMemberIds] = useState([]); + const [isPrivate, setIsPrivate] = useState(false); // 혼자 볼 지도 : 같이 볼 지도 + const [isAllPermissioned, setIsAllPermissioned] = useState(true); // 모두 : 지정 인원 + const [authorizedMemberIds, setAuthorizedMemberIds] = useState([]); - const handleChecked = (isChecked: boolean, id: number) => - setCheckedMemberIds((prev: Member['id'][]) => - isChecked ? [...prev, id] : prev.filter((n: number) => n !== id), - ); + const [showImage, setShowImage] = useState(''); + const [formImage, setFormImage] = useState(null); const goToBack = () => { routePage(-1); @@ -73,106 +56,85 @@ const NewTopic = () => { return; } - if (isPrivate) { - const topicId = await postToServer(); + const topicId = await createTopic(); - const result = await addAuthority(topicId); - if (topicId) routePage(`/topics/${topicId}`); - return; + if (topicId) { + await addAuthorityToTopicWithGroupPermission(topicId); + routePage(`/topics/${topicId}`); } + }; - if (!isPrivate && !isAll) { - const topicId = await postToServer(); + const createTopic = async () => { + const response = await postToServer(); + const location = response?.headers.get('Location'); - const result = await addAuthority(topicId); - if (topicId) routePage(`/topics/${topicId}`); - return; + if (location) { + const topicIdFromLocation = location.split('/')[2]; + return Number(topicIdFromLocation); } + }; - if (!isAll && checkedMemberIds.length === 0) { - showToast('error', '멤버를 선택해주세요.'); - return; + const postToServer = async () => { + const formData = new FormData(); + + if (formImage) { + formData.append('image', formImage); } - //생성하기 버튼 눌렀을 때 postToServer로 TopicId 받고, 받은 topicId로 권한 추가 - try { - const topicId = await postToServer(); + const objectData = { + name: formValues.name, + description: formValues.description, + pins: pulledPinIds ? pulledPinIds.split(',') : [], + publicity: isPrivate ? 'PRIVATE' : 'PUBLIC', + permissionType: + isAllPermissioned && !isPrivate ? 'ALL_MEMBERS' : 'GROUP_ONLY', + }; - if (topicId) routePage(`/topics/${topicId}`); + const data = JSON.stringify(objectData); + const jsonBlob = new Blob([data], { type: 'application/json' }); - showToast('info', '지도를 생성하였습니다.'); - } catch { - showToast( - 'error', - '지도 생성에 실패하였습니다. 입력하신 항목들을 다시 확인해주세요.', - ); - } - }; + formData.append('request', jsonBlob); - const postToServer = async () => { - const response = - taggedIds?.length > 1 && typeof taggedIds !== 'string' - ? await mergeTopics() - : await createTopic(); - const location = response?.headers.get('Location'); - - if (location) { - const topicIdFromLocation = location.split('/')[2]; - return topicIdFromLocation; - } + return fetchPost({ + url: '/topics/new', + payload: formData, + errorMessage: + '지도 생성에 실패하였습니다. 입력하신 항목들을 다시 확인해주세요.', + onSuccess: () => { + showToast('info', `${formValues.name} 지도를 생성하였습니다.`); + }, + }); }; - //header의 location으로 받아온 topicId에 권한 추가 기능 - const addAuthority = async (topicId: any) => { - if (isAll && !isPrivate) return; // 모두 권한 준거면 return + const addAuthorityToTopicWithGroupPermission = async (topicId: number) => { + if (isAllPermissioned) return; - const response = await postApi(`/permissions`, { - topicId: topicId, - memberIds: checkedMemberIds, + fetchPost({ + url: '/permissions', + payload: { + topicId, + memberIds: authorizedMemberIds, + }, + errorMessage: `${formValues.name} 지도의 권한 설정에 실패했습니다.`, }); - return response; }; - const mergeTopics = async () => { - try { - return await postApi('/topics/merge', { - image: formValues.image || DEFAULT_TOPIC_IMAGE, - name: formValues.name, - description: formValues.description, - topics: taggedIds, - publicity: isPrivate ? 'PRIVATE' : 'PUBLIC', - permissionType: isAll ? 'ALL_MEMBERS' : 'GROUP_ONLY', - }); - } catch { + const onTopicImageFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files && event.target.files[0]; + if (!file) { showToast( 'error', - '지도 생성에 실패하였습니다. 입력하신 항목들을 다시 확인해주세요.', + '추가하신 이미지를 찾을 수 없습니다. 다시 선택해 주세요.', ); - throw new Error('지도 생성 실패'); + return; } - }; - const createTopic = async () => { - try { - return await postApi('/topics/new', { - image: formValues.image || DEFAULT_TOPIC_IMAGE, - name: formValues.name, - description: formValues.description, - pins: typeof taggedIds === 'string' ? taggedIds.split(',') : [], - publicity: isPrivate ? 'PRIVATE' : 'PUBLIC', - permissionType: isPrivate - ? 'GROUP_ONLY' - : isAll - ? 'ALL_MEMBERS' - : 'GROUP_ONLY', - }); - } catch { - showToast( - 'error', - '지도 생성에 실패하였습니다. 입력하신 항목들을 다시 확인해주세요.', - ); - throw new Error('지도 생성 실패'); - } + const compressedFile = await compressImage(file); + + setFormImage(compressedFile); + setShowImage(URL.createObjectURL(file)); }; return ( @@ -185,21 +147,35 @@ const NewTopic = () => { 지도 생성 + - - + + + 지도 사진 + + + 지도를 대표할 수 있는 사진을 추가해주세요. + + + + {showImage && ( + <> + {' '} + {' '} + + )} + + 파일 찾기 + + + + + { errorMessage={errorMessages.name} maxLength={20} /> + + { errorMessage={errorMessages.description} maxLength={100} /> - - - 공개 여부 - - - -
- setIsPrivate(false)} - tabIndex={4} - /> - -
- -
- setIsPrivate(true)} - tabIndex={4} - /> - -
-
- - - - 핀 생성 및 수정 권한 - - -
- { - setIsAll(true); - }} - tabIndex={5} - /> - {isPrivate ? ( - - ) : ( - - )} -
- -
- { - setIsAll(false); - openModal('newTopic'); - setCheckedMemberIds([]); - }} - tabIndex={5} - /> - -
-
- <> - - - - - 멤버 선택 - - - {checkedMemberIds.length}명 선택됨 - - - - - {members.map((member) => ( - - - - ))} - - - - - - - - - - - - - + + + + + - + + + ); }; +const Wrapper = styled.div` + margin: 0 auto; + + @media (max-width: 1076px) { + width: calc(50vw - 40px); + } + + @media (max-width: 744px) { + width: 332px; + margin: 0 auto; + } +`; + export default UpdatedPinDetail; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 21aea0a50..7f5833bd5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,20 +1,22 @@ +import { Suspense, lazy } from 'react'; import { createBrowserRouter } from 'react-router-dom'; import Home from './pages/Home'; -import NewPin from './pages/NewPin'; -import NewTopic from './pages/NewTopic'; import RootPage from './pages/RootPage'; -import SelectedTopic from './pages/SelectedTopic'; -import SeeAllPopularTopics from './pages/SeeAllPopularTopics'; -import SeeAllNearTopics from './pages/SeeAllNearTopics'; -import SeeAllLatestTopics from './pages/SeeAllLatestTopics'; -import KakaoRedirectPage from './pages/KaKaoRedirectPage'; import { ReactNode } from 'react'; import AuthLayout from './components/Layout/AuthLayout'; -import NotFound from './components/NotFound'; -import SeeTogetherTopics from './pages/SeeTogetherTopics'; -import Profile from './pages/Profile'; -import LoginError from './pages/LoginError'; -import Bookmark from './pages/Bookmark'; +import NotFound from './pages/NotFound'; + +const SelectedTopic = lazy(() => import('./pages/SelectedTopic')); +const NewPin = lazy(() => import('./pages/NewPin')); +const NewTopic = lazy(() => import('./pages/NewTopic')); +const SeeAllPopularTopics = lazy(() => import('./pages/SeeAllPopularTopics')); +const SeeAllNearTopics = lazy(() => import('./pages/SeeAllNearTopics')); +const SeeAllLatestTopics = lazy(() => import('./pages/SeeAllLatestTopics')); +const KakaoRedirect = lazy(() => import('./pages/KakaoRedirect')); +const SeeTogetherTopics = lazy(() => import('./pages/SeeTogetherTopics')); +const Profile = lazy(() => import('./pages/Profile')); +const AskLogin = lazy(() => import('./pages/AskLogin')); +const Bookmark = lazy(() => import('./pages/Bookmark')); interface routeElement { path: string; @@ -25,6 +27,14 @@ interface routeElement { children: { path: string; element: ReactNode; withAuth: boolean }[]; } +interface SuspenseCompProps { + children: ReactNode; +} + +const SuspenseComp = ({ children }: SuspenseCompProps) => { + return {children}; +}; + const routes: routeElement[] = [ { path: '/', @@ -40,57 +50,101 @@ const routes: routeElement[] = [ }, { path: 'topics/:topicId', - element: , + element: ( + + + + ), withAuth: false, }, { path: 'new-topic', - element: , + element: ( + + + + ), withAuth: true, }, { path: 'new-pin', - element: , + element: ( + + + + ), withAuth: true, }, { path: 'see-all/popularity', - element: , + element: ( + + + + ), withAuth: false, }, { path: 'see-all/near', - element: , + element: ( + + + + ), withAuth: false, }, { path: 'see-all/latest', - element: , + element: ( + + + + ), withAuth: false, }, { path: 'see-together', - element: , + element: ( + + + + ), withAuth: true, }, { path: 'favorite', - element: , + element: ( + + + + ), withAuth: true, }, { path: 'my-page', - element: , + element: ( + + + + ), withAuth: true, }, { path: '/askLogin', - element: , + element: ( + + + + ), withAuth: false, }, { path: '/oauth/redirected/kakao', - element: , + element: ( + + + + ), withAuth: false, }, ], diff --git a/frontend/src/types/Api.ts b/frontend/src/types/Api.ts new file mode 100644 index 000000000..9aea7deaa --- /dev/null +++ b/frontend/src/types/Api.ts @@ -0,0 +1 @@ +export type ContentTypeType = 'application/json' | 'x-www-form-urlencoded'; diff --git a/frontend/src/types/Bookmarks.ts b/frontend/src/types/Bookmarks.ts index e0f180d02..d9f75def8 100644 --- a/frontend/src/types/Bookmarks.ts +++ b/frontend/src/types/Bookmarks.ts @@ -1,4 +1,4 @@ -export interface BookmarksType { +export interface BookmarksProps { id: number; name: string; image: string; diff --git a/frontend/src/types/FormValues.ts b/frontend/src/types/FormValues.ts index 3bec058ac..999ef09ec 100644 --- a/frontend/src/types/FormValues.ts +++ b/frontend/src/types/FormValues.ts @@ -1,9 +1,3 @@ -export interface DefaultFormProps { - name: string; - address: string; - description: string; -} - export interface NewTopicFormProps { name: string; description: string; @@ -13,7 +7,6 @@ export interface NewTopicFormProps { export interface ModifyPinFormProps { name: string; - images: string[]; description: string; } diff --git a/frontend/src/types/Login.ts b/frontend/src/types/Login.ts index a79f2a6ce..23d871abb 100644 --- a/frontend/src/types/Login.ts +++ b/frontend/src/types/Login.ts @@ -1,4 +1,4 @@ -export interface Member { +export interface MemberProps { id: number; nickName: string; email: string; @@ -6,7 +6,7 @@ export interface Member { updatedAt: string; } -export interface LoginResponse { +export interface LoginResponseProps { accessToken: string; - member: Member; + member: MemberProps; } diff --git a/frontend/src/types/Map.ts b/frontend/src/types/Map.ts new file mode 100644 index 000000000..ba5b370a8 --- /dev/null +++ b/frontend/src/types/Map.ts @@ -0,0 +1,65 @@ +export interface MapAddressProps { + addressInfo: AddressInfoProps; +} + +export interface AddressInfoProps { + addressType: string; + adminDong: string; + adminDongCode: string; + buildingIndex: string; + buildingName: string; + bunji: string; + city_do: string; + eup_myun: string; + fullAddress: string; + gu_gun: string; + legalDong: string; + legalDongCode: string; + mappingDistance: string; + ri: string; + roadCode: string; + roadName: string; +} + +export interface MapProps { + isMobile: boolean; + mouseClickFlag: boolean; + name: string; + _data: MapDataProps; + _object_: MapObjectProps; + _status: MapStatusProps; +} + +export interface MapDataProps { + mapType: number; + maxBounds: {}; + target: string; + container: {}; + vsmMap: {}; + vsmOptions: {}; + minZoomLimit: number; + maxZoomLimit: number; + options: MapOptionsProps; +} + +export interface MapObjectProps { + eventListeners: {}; + getHandlers: string; + fireEvent: string; +} + +export interface MapStatusProps { + zoom: number; + center: {}; + width: number; + height: number; +} + +export interface MapOptionsProps { + draggable: boolean; + measureControl: boolean; + naviControl: boolean; + pinchZoom: boolean; + scaleBar: boolean; + scrollwheel: boolean; +} diff --git a/frontend/src/types/MyInfo.ts b/frontend/src/types/MyInfo.ts deleted file mode 100644 index 8e3b86198..000000000 --- a/frontend/src/types/MyInfo.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface MyInfoType { - name: string; - email: string; -} - -export interface MyInfoTopicType { - id: number; - name: string; - image: string; - pinCount: number; - bookmarkCount: number; - isBookmarked: boolean; - updatedAt: string; -} - -export interface MyInfoPinType { - id: number; - name: string; - address: string; - description: string; - latitude: number; - longitude: number; -} diff --git a/frontend/src/types/Pin.ts b/frontend/src/types/Pin.ts index 1765fb413..e226333a5 100644 --- a/frontend/src/types/Pin.ts +++ b/frontend/src/types/Pin.ts @@ -1,10 +1,17 @@ -export interface PinType { +export interface PinProps { id: number; name: string; + creator: string; address: string; description: string; latitude: number; longitude: number; + canUpdate: boolean; updatedAt: string; - images: string[]; + images: ImageProps[]; } + +export interface ImageProps { + id: number; + imageUrl: string; +} \ No newline at end of file diff --git a/frontend/src/types/Profile.ts b/frontend/src/types/Profile.ts new file mode 100644 index 000000000..16f579c8c --- /dev/null +++ b/frontend/src/types/Profile.ts @@ -0,0 +1,4 @@ +export interface ProfileProps { + name: string; + email: string; +} diff --git a/frontend/src/types/Topic.ts b/frontend/src/types/Topic.ts index 8a9dd25b2..989ed44f6 100644 --- a/frontend/src/types/Topic.ts +++ b/frontend/src/types/Topic.ts @@ -1,6 +1,6 @@ -import { PinType } from './Pin'; +import { PinProps } from './Pin'; -export interface TopicType { +export interface TopicCardProps { id: number; name: string; image: string; @@ -8,30 +8,48 @@ export interface TopicType { pinCount: number; bookmarkCount: number; updatedAt: string; - isInAtlas: false; - isBookmarked: false; + isInAtlas: boolean; + isBookmarked: boolean; } -export interface TopicDetailType { +export interface TopicDetailProps { id: number; image: string; name: string; creator: string; description: string; + canUpdate: boolean; pinCount: number; bookmarkCount: number; updatedAt: string; - isInAtlas: false; - isBookmarked: false; - pins: PinType[]; + isInAtlas: boolean; + isBookmarked: boolean; + pins: PinProps[]; } -export interface ModalMyTopicType { +export interface ModalTopicCardProps { id: number; name: string; + creator: string; image: string; pinCount: number; bookmarkCount: number; isBookmarked: boolean; updatedAt: string; } + +export interface TopicAuthorInfo { + publicity: 'PUBLIC' | 'PRIVATE'; + permissionedMembers: TopicAuthorMemberWithAuthorId[]; +} + +export interface TopicAuthorMemberWithAuthorId { + id: number; + memberResponse: TopicAuthorMember; +} + +export interface TopicAuthorMember { + id: number; + nickName: string; + email: string; +} diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index ee8901d09..990e15785 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -4,8 +4,8 @@ declare module '*.svg' { export default SVG; } -declare global { - interface Window { - Tmapv3: Tmapv3; - } -} +// declare global { +// interface Window { +// Tmapv3: Tmapv3; +// } +// } diff --git a/frontend/src/types/tmap.d.ts b/frontend/src/types/tmap.d.ts new file mode 100644 index 000000000..d591aefe0 --- /dev/null +++ b/frontend/src/types/tmap.d.ts @@ -0,0 +1,65 @@ +interface Window { + Tmapv3: { + Map: new (element: HTMLElement, options?: { center?: LatLng }) => TMap; + LatLng: new (lat: number, lng: number) => LatLng; + LatLngBounds: new () => LatLngBounds; + Marker: new (options?: MarkerOptions) => Marker; + InfoWindow: new (options?: InfoWindowOptions) => InfoWindow; + Point: new (x: number, y: number) => Point; + }; + daum: any; +} + +interface evt { + data: { + lngLat: { + _lat: number; + _lng: number; + }; + }; +} + +interface TMap { + setZoomLimit(minZoom: number, maxZoom: number): void; + destroy(): void; + panTo(latLng: LatLng): void; + fitBounds(bounds: LatLngBounds): void; + setCenter(latLng: LatLng): void; + setZoom(zoomLevel: number): void; + on(eventType: string, callback: (evt: evt) => void): void; + removeListener(eventType: string, callback: (evt: evt) => void): void; +} + +interface LatLng {} + +interface LatLngBounds { + extend(latLng: LatLng): void; +} + +interface Marker { + position?: LatLng; + icon?: string; + map?: Map; + id?: string; + getPosition(): LatLng; + on(eventType: string, callback: (evt: Event) => void): void; + setMap(mapOrNull?: Map | null): void; +} + +interface Point { + x: number; + y: number; +} + +interface InfoWindow { + position?: LatLng; + content?: string; + offset?: Point; + type?: number; + map?: Map; + setMap(mapOrNull?: Map | null): void; + setPosition(positionOrLatLng?: Position | LatLng): void; + setContent(contentOrString?: Content | string): void; + open(map?: Map, marker?: Marker, latlng?: LatLng): void; + close(): void; +} diff --git a/frontend/src/validations/index.ts b/frontend/src/validations/index.ts index 0d0c59c22..8faab7d35 100644 --- a/frontend/src/validations/index.ts +++ b/frontend/src/validations/index.ts @@ -1,7 +1,7 @@ const REG_EXP_CURSES = /(간나새끼|개새끼|개새|개쓰레기|개소리|개씨발|개씹|지랄|좆|남창|느금마|니미럴|니애미|니애비|똘추|따까리|미친새끼|미친놈|미친년|병신|븅딱|빠구리|빨통|뻐큐|쌍놈|썅놈|쌍년|썅년|쌍노무새끼|썅노무새끼|시발|씨바|씨발|시팔|씨팔|씨부랄|시부랄|씹년|씹새끼|씹새|씹창|애새끼|애미뒤진|애미 뒤진|애비뒤진|애비 뒤진|엠창|육변기|좆|지랄|제기랄|창녀|창남|창놈|호로)/; const REG_EXP_POLITICALLY = - /(괴뢰|빨갱이|왜놈|일베|조센징|쪽바리|짱깨|월북|매국노|메갈|섹스|쎅쓰|쎅스|섹쓰|자지|보지)/; + /(괴뢰|빨갱이|왜놈|일베|조센징|쪽바리|짱깨|월북|매국노|메갈|섹스|쎅쓰|쎅스|섹쓰)/; export const validateCurse = (userInput: string) => { return REG_EXP_CURSES.test(userInput); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 5b8f80244..3c7d8fe6c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,5 +14,10 @@ "types": ["cypress"] }, "exclude": ["node_modules"], - "include": ["./src/**/*.tsx", "./src/**/*.ts", "cypress/**/*"] + "include": [ + "./src/**/*.tsx", + "./src/**/*.ts", + "cypress/**/*", + "src/types/tmap.d.ts" + ] }