diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml new file mode 100644 index 00000000..75ae0531 --- /dev/null +++ b/.github/workflows/CD.yml @@ -0,0 +1,51 @@ +name: CD + +on: + push: + branches: [ "develop" ] + +env: + HOST: ${{ secrets.HOST }} + USERNAME: ${{ secrets.USERNAME }} + KEY: ${{ secrets.SSH_KEY }} + +jobs: + deploy-ci: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Docker build 가능하도록 환경 설정 + uses: docker/setup-buildx-action@v2.9.1 + + - name: Docker Hub에 로그인 + uses: docker/login-action@v2.2.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESSTOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + file: ./deploy/Dockerfile + push: true + tags: lequu/lequu-client:latest + + deploy-cd: + needs: deploy-ci + runs-on: ubuntu-22.04 + + steps: + - name: 도커 컨테이너 실행 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.RELEASE_SERVER_IP }} + username: ${{ secrets.RELEASE_SERVER_USER }} + key: ${{ secrets.RELEASE_SERVER_KEY }} + script: | + cd ~ + ./deploy.sh + docker image prune -f diff --git a/.gitignore b/.gitignore index 3b0b4037..b4ac0dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ dist-ssr *.sln *.sw? -.env \ No newline at end of file +.env +# Sentry Config File +.env.sentry-build-plugin diff --git a/.stylelintrc.json b/.stylelintrc.json index 2aa0c545..e7750f1a 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -15,22 +15,37 @@ } ], "order/order": ["custom-properties", "declarations"], - "order/properties-order": [ { "groupName": "Layout", + "emptyLineBefore": "always", "noEmptyLineBetween": true, "properties": [ "display", - "visibility", - "overflow", - "float", - "clear", + "gap", + "justify-content", + "align-items", + "flex-direction", + "flex-wrap", + "flex-flow", + "flex-grow", + "flex-shrink", + "flex-basis", + "grid-template-columns", + "grid-area", + "grid-template-rows", + "grid-column", + "grid-template-areas", + "grid-gap", "position", "top", "right", "bottom", "left", + "float", + "clear", + "visibility", + "overflow", "z-index" ] }, @@ -41,30 +56,36 @@ "properties": [ "width", "height", - "margin", - "margin-top", - "margin-right", - "margin-bottom", - "margin-left", "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", - "border" + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left" ] }, { "groupName": "Background", "emptyLineBefore": "always", "noEmptyLineBetween": true, - "properties": ["background-color"] - }, - { - "groupName": "Font", - "emptyLineBefore": "always", - "noEmptyLineBetween": true, "properties": [ + "border", + "border-radius", + "border-top", + "border-right", + "border-bottom", + "border-left", + "border-color", + "border-width", + "border-style", + "background", + "background-color", + "background-position", + "background-size", "color", "font-style", "font-weight", @@ -73,15 +94,20 @@ "letter-spacing", "text-align", "text-indent", - "vertical-align", - "white-space" + "vertical-align" ] }, { - "groupName": "Animation", + "groupName": "Text", + "emptyLineBefore": "always", + "noEmptyLineBetween": true, + "properties": ["text-decoration", "text-align", "vertical-align"] + }, + { + "groupName": "ETC", "emptyLineBefore": "always", "noEmptyLineBetween": true, - "properties": ["animation"] + "properties": ["white-space"] } ] } diff --git a/README.md b/README.md index 85102415..1f25433d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

💌 Lecue 💌

-로고 대문 이미지 +로고 대문 이미지

@@ -15,12 +15,39 @@
+## 🍟핵심 기능 +[핵심 기능] +- 레큐북 : 레큐노트를 부착할 수 있는 롤링페이퍼 기능 +
+ 레큐북 이미지 +- 레큐노트 : 텍스트와 이미지를 업로드할 수 있는 포스트잇 기능 +
+ 레큐노트 이미지 +- 스티커 : 스티커 이미지로 레큐북을 꾸밀 수 있는 기능 +
+ 스티커 부착 이미지 + +[팬덤 특화 기능] +- 커스텀 기능 : 레큐북, 레큐노트, 스티커 유저 커스텀 기능 +- 인기 롤링페이퍼 기능 : 레큐노트가 많이 부착된 레큐북 홈화면에 노출 + → 추후 스프린트로는 최애 등록하고 등록한 최애 관련 레큐북을 노출 +- 텍스트 추출 요청을 통한 굿즈 제작 : 기능 개발보다 팬덤이 레큐에 요청하면 텍스트 파일로 제공하는 형태 + +[기본 기능] +- 내 기록 보기 - 유저가 남긴 레큐노트 / 제작한 레큐북 모아보기 +
+ 마이페이지 이미지 + 마이페이지 이미지 +- 문의 요청 기능 + +
+ ## ✨ OUR TEAM | 프로필사진 | 프로필사진 | 프로필사진 | 프로필사진 | | :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | |
[짱리드]아름
|
은빈
|
정우
|
도윤
| -| [@Arooming](https://github.com/Arooming) | [@eunbeann](https://github.com/eunbeann/300x300) | [@jungwoo3490](https://github.com/jungwoo3490) | [@binllionaire](https://github.com/binllionaire) | +| [@Arooming](https://github.com/Arooming) | [@eunbeann](https://github.com/eunbeann) | [@jungwoo3490](https://github.com/jungwoo3490) | [@doyn511](https://github.com/doyn511) |
@@ -78,7 +105,6 @@ | refactor | 코드 리팩토링에 대한 커밋 | | docs | 문서를 수정한 경우, 파일 삭제, 파일명 수정 등 | | chore | 빌드 테스트 업데이트, 패키지 매니저를 설정하는 경우, 주석 추가, 자잘한 문서 수정 | -| code review | 코드 리뷰 반영 |
diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 00000000..e5568939 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18.17.0-slim + +WORKDIR /app + +COPY package.json . +COPY yarn.lock . + +RUN yarn + +COPY . . + +CMD ["yarn", "dev"] diff --git a/index.html b/index.html index 1754121c..3f6840a4 100644 --- a/index.html +++ b/index.html @@ -2,17 +2,58 @@ - + + + + + + + Lecue + + + + +
+
+ + + + diff --git a/package.json b/package.json index cbafd0bf..30dc761e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" @@ -12,10 +12,20 @@ "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "@sentry/browser": "^7.93.0", + "@sentry/react": "^7.93.0", + "@sentry/vite-plugin": "^2.10.2", + "axios": "^1.6.5", "eslint-plugin-react": "^7.33.2", + "grapheme-splitter": "^1.0.4", + "lottie-react": "^2.4.0", + "postcss": "^8.4.33", + "postcss-styled-syntax": "^0.6.3", "prettier": "^3.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-draggable": "^4.4.6", + "react-error-boundary": "^4.0.12", "react-query": "^3.39.3", "react-router-dom": "^6.21.1", "vite-plugin-svgr": "^4.2.0" @@ -31,6 +41,9 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-simple-import-sort": "^10.0.0", "husky": "^8.0.3", + "stylelint": "^16.1.0", + "stylelint-config-standard": "^36.0.0", + "stylelint-order": "^6.0.4", "typescript": "^5.2.2", "vite": "^5.0.8" } diff --git a/src/App.tsx b/src/App.tsx index a28422c9..1b1caa3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,65 @@ import { Global, ThemeProvider } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useEffect } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import Router from './Router'; import gStyle from './styles/GlobalStyles'; import theme from './styles/theme'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + suspense: true, + useErrorBoundary: true, + retry: 0, + }, + }, +}); function App() { + const setScreenSize = () => { + // vh 관련 + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + + // window width 관련 + const windowWidth = + window.innerWidth || + document.documentElement.clientWidth || + document.body.clientWidth; + const maxWidth = Math.min(375, windowWidth); + document.documentElement.style.setProperty( + '--app-max-width', + `${maxWidth}px`, + ); + }; + + useEffect(() => { + setScreenSize(); + window.addEventListener('resize', setScreenSize); + + return () => { + window.removeEventListener('resize', setScreenSize); + }; + }, []); + return ( - - - - - - + + + + + + + + ); } +const Wrapper = styled.div` + border: none; + background-color: '#F5F5F5'; + min-height: calc(var(--vh, 1vh) * 100); +`; + export default App; diff --git a/src/CreateBook/components/BookInfoTextarea/BookInfoTextarea.style.ts b/src/CreateBook/components/BookInfoTextarea/BookInfoTextarea.style.ts new file mode 100644 index 00000000..159b846b --- /dev/null +++ b/src/CreateBook/components/BookInfoTextarea/BookInfoTextarea.style.ts @@ -0,0 +1,49 @@ +import styled from '@emotion/styled'; + +export const BookInfoWrapper = styled.section` + display: flex; + align-items: center; + flex-direction: column; + + width: 100%; + margin-top: 1.2rem; +`; + +export const TextareaContainer = styled.div<{ isEmpty: boolean }>` + width: 100%; + height: 15rem; + padding: 1.7rem 2rem 4rem; + + ${({ theme }) => theme.fonts.Body3_R_14}; + + border: 0.1rem solid + ${({ theme, isEmpty }) => (isEmpty ? theme.colors.LG : theme.colors.BG)}; + border-radius: 0.8rem; + background-color: ${({ theme }) => theme.colors.white}; +`; + +export const Textarea = styled.textarea` + width: 100%; + height: 100%; + + border: none; + color: ${({ theme }) => theme.colors.BG}; + + ${({ theme }) => theme.fonts.Body2_M_14}; + + resize: none; + + &:focus { + outline: none; + } +`; + +export const WordCount = styled.p` + display: flex; + justify-content: flex-end; + + width: 100%; + + color: ${({ theme }) => theme.colors.WG}; + ${({ theme }) => theme.fonts.E_Body2_R_14}; +`; diff --git a/src/CreateBook/components/BookInfoTextarea/index.tsx b/src/CreateBook/components/BookInfoTextarea/index.tsx new file mode 100644 index 00000000..45d27af4 --- /dev/null +++ b/src/CreateBook/components/BookInfoTextarea/index.tsx @@ -0,0 +1,31 @@ +import * as S from './BookInfoTextarea.style'; + +interface BookInfoTextareaProps { + description: string; + changeDescription: (description: string) => void; +} + +function BookInfoTextarea({ + description, + changeDescription, +}: BookInfoTextareaProps) { + const handleChangeInput = (e: React.ChangeEvent) => { + if (e.target.value.length <= 65) { + changeDescription(e.target.value); + } + }; + return ( + + + + ({description.length}/65) + + + ); +} + +export default BookInfoTextarea; diff --git a/src/CreateBook/components/BookInput/BookInput.style.ts b/src/CreateBook/components/BookInput/BookInput.style.ts new file mode 100644 index 00000000..4effa998 --- /dev/null +++ b/src/CreateBook/components/BookInput/BookInput.style.ts @@ -0,0 +1,38 @@ +import styled from '@emotion/styled'; + +export const TitleWrapper = styled.section` + display: flex; + align-items: center; + flex-direction: column; + + width: 100%; + margin-top: 1.2rem; +`; + +export const InputContainer = styled.div<{ isEmpty: boolean }>` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + padding: 1.55rem 2rem; + + ${({ theme }) => theme.fonts.Body3_R_14}; + + border: 0.1rem solid + ${({ theme, isEmpty }) => (isEmpty ? theme.colors.LG : theme.colors.BG)}; + border-radius: 0.8rem; + background-color: ${({ theme }) => theme.colors.white}; +`; + +export const Input = styled.input` + width: 100%; + + color: ${({ theme }) => theme.colors.BG}; + ${({ theme }) => theme.fonts.Body2_M_14}; +`; + +export const WordCount = styled.p` + color: ${({ theme }) => theme.colors.WG}; + ${({ theme }) => theme.fonts.E_Body2_R_14}; +`; diff --git a/src/CreateBook/components/BookInput/index.tsx b/src/CreateBook/components/BookInput/index.tsx new file mode 100644 index 00000000..90f36667 --- /dev/null +++ b/src/CreateBook/components/BookInput/index.tsx @@ -0,0 +1,29 @@ +import * as S from './BookInput.style'; + +interface BookInputProps { + title: string; + changeTitle: (title: string) => void; +} + +function BookInput({ title, changeTitle }: BookInputProps) { + const handleChangeInput = (e: React.ChangeEvent) => { + if (e.target.value.length <= 15) { + changeTitle(e.target.value); + } + }; + return ( + + + + ({title.length}/15) + + + ); +} + +export default BookInput; diff --git a/src/CreateBook/components/CompleteButton/CompleteButton.style.ts b/src/CreateBook/components/CompleteButton/CompleteButton.style.ts new file mode 100644 index 00000000..7f21332d --- /dev/null +++ b/src/CreateBook/components/CompleteButton/CompleteButton.style.ts @@ -0,0 +1,6 @@ +import styled from '@emotion/styled'; + +export const CompleteButtonWrapper = styled.div` + width: 100%; + margin-bottom: 2rem; +`; diff --git a/src/CreateBook/components/CompleteButton/index.tsx b/src/CreateBook/components/CompleteButton/index.tsx new file mode 100644 index 00000000..4fa0cb95 --- /dev/null +++ b/src/CreateBook/components/CompleteButton/index.tsx @@ -0,0 +1,24 @@ +import Button from '../../../components/common/Button'; +import * as S from './CompleteButton.style'; + +interface CompleteButtonProps { + isActive: boolean; + onClick: () => void; + backgroundColor: string; +} + +function CompleteButton({ + isActive, + onClick, + backgroundColor, +}: CompleteButtonProps) { + return ( + + + + ); +} + +export default CompleteButton; diff --git a/src/CreateBook/components/SelectColor/SelectColor.style.ts b/src/CreateBook/components/SelectColor/SelectColor.style.ts new file mode 100644 index 00000000..a0c34153 --- /dev/null +++ b/src/CreateBook/components/SelectColor/SelectColor.style.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.article` + display: flex; + gap: 1.6rem; + flex-direction: column; +`; +export const Category = styled.p<{ variant: boolean }>` + display: flex; + + color: ${({ theme, variant }) => (variant ? theme.colors.white : theme.colors.BG)}; + ${({ theme }) => theme.fonts.Title1_SB_16}; +`; diff --git a/src/CreateBook/components/SelectColor/index.tsx b/src/CreateBook/components/SelectColor/index.tsx new file mode 100644 index 00000000..d4b6faab --- /dev/null +++ b/src/CreateBook/components/SelectColor/index.tsx @@ -0,0 +1,28 @@ +import ShowColorChart from '../ShowColorChart'; +import * as S from './SelectColor.style'; + +interface SelectColorProps { + backgroundColor: string; + clickBackgroundColor: (backgroundColor: string) => void; +} + +function SelectColor({ + backgroundColor, + clickBackgroundColor, +}: SelectColorProps) { + return ( + + + 레큐북 배경색 + + + clickBackgroundColor(backgroundColor) + } + /> + + ); +} + +export default SelectColor; diff --git a/src/CreateBook/components/ShowColorChart/ShowColorChart.style.ts b/src/CreateBook/components/ShowColorChart/ShowColorChart.style.ts new file mode 100644 index 00000000..c12a0c61 --- /dev/null +++ b/src/CreateBook/components/ShowColorChart/ShowColorChart.style.ts @@ -0,0 +1,49 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + gap: 1.4rem; + justify-content: flex-start; + align-items: center; + + padding: 0.5rem 0.1rem 0.7rem 0.3rem; + + overflow-x: scroll; +`; + +export const ColorWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + + width: 3.8rem; + height: 3.8rem; +`; + +export const Color = styled.button<{ $colorCode: string; variant: boolean }>` + border-radius: 3rem; + ${({ $colorCode, theme }) => + $colorCode === '#F5F5F5' && + css` + outline: 0.1rem solid ${theme.colors.WG}; + `}; + background-color: ${({ $colorCode }) => $colorCode}; + + ${({ variant, theme }) => + variant + ? css` + width: 3.8rem; + height: 3.8rem; + + border: 0.4rem solid ${theme.colors.white}; + outline: 0.1rem solid ${theme.colors.WG}; + ` + : css` + width: 3rem; + height: 3rem; + + border: none; + `}; +`; diff --git a/src/CreateBook/components/ShowColorChart/index.tsx b/src/CreateBook/components/ShowColorChart/index.tsx new file mode 100644 index 00000000..e98fb7a2 --- /dev/null +++ b/src/CreateBook/components/ShowColorChart/index.tsx @@ -0,0 +1,31 @@ +import * as S from './ShowColorChart.style'; + +interface ShowColorChartProps { + backgroundColor: string; + handleFn: (backgroundColor: string) => void; +} + +function ShowColorChart({ backgroundColor, handleFn }: ShowColorChartProps) { + return ( + + + handleFn('#F5F5F5')} + variant={backgroundColor === '#F5F5F5'} + > + + + handleFn('#191919')} + variant={backgroundColor === '#191919'} + > + + + ); +} + +export default ShowColorChart; diff --git a/src/Home/components/.gitkeep b/src/CreateBook/constants/.gitkeep similarity index 100% rename from src/Home/components/.gitkeep rename to src/CreateBook/constants/.gitkeep diff --git a/src/components/.gitkeep b/src/CreateBook/hooks/.gitkeep similarity index 100% rename from src/components/.gitkeep rename to src/CreateBook/hooks/.gitkeep diff --git a/src/CreateBook/hooks/usePostBook.ts b/src/CreateBook/hooks/usePostBook.ts new file mode 100644 index 00000000..9519282a --- /dev/null +++ b/src/CreateBook/hooks/usePostBook.ts @@ -0,0 +1,41 @@ +import { useMutation } from 'react-query'; +import { useNavigate } from 'react-router-dom'; + +import { postBook } from '../utils/api'; + +interface usePostBookProps { + favoriteName: string; + favoriteImage: string; + title: string; + description: string; + backgroundColor: string; +} + +const usePostBook = () => { + const navigate = useNavigate(); + const mutation = useMutation({ + mutationFn: ({ + favoriteName, + favoriteImage, + title, + description, + backgroundColor, + }: usePostBookProps) => { + return postBook({ + favoriteName, + favoriteImage, + title, + description, + backgroundColor, + }); + }, + onError: () => navigate('/error'), + onSuccess: (data) => { + const { bookUuid } = data; + navigate(`/lecue-book/${bookUuid}`); + }, + }); + return mutation; +}; + +export default usePostBook; diff --git a/src/CreateBook/page/CreateBook.style.ts b/src/CreateBook/page/CreateBook.style.ts new file mode 100644 index 00000000..41e2ac71 --- /dev/null +++ b/src/CreateBook/page/CreateBook.style.ts @@ -0,0 +1,43 @@ +import styled from '@emotion/styled'; + +export const CreateBookWrapper = styled.section<{ $backgroundColor: string }>` + display: flex; + flex-direction: column; + + width: 100vw; + height: 100dvh; + + background-color: ${({ $backgroundColor }) => $backgroundColor}; +`; + +export const CreateBookBodyWrapper = styled.div` + display: flex; + justify-content: space-between; + flex-direction: column; + + width: 100%; + height: calc(100dvh - 5.4rem); + padding: 0 1.6rem; + margin-top: 5.4rem; +`; + +export const InputWrapper = styled.div` + width: 100%; +`; + +export const SectionTitle = styled.p<{ variant: boolean }>` + color: ${({ theme, variant }) => + variant ? theme.colors.white : theme.colors.BG}; + + ${({ theme }) => theme.fonts.Head2_SB_18}; +`; + +export const BookInputWrapper = styled.div` + width: 100%; + margin-top: 3rem; +`; + +export const BookInfoTextareaWrapper = styled.div` + width: 100%; + margin: 4.4rem 0 2.63rem; +`; diff --git a/src/CreateBook/page/index.tsx b/src/CreateBook/page/index.tsx new file mode 100644 index 00000000..2401ed9f --- /dev/null +++ b/src/CreateBook/page/index.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import Header from '../../components/common/Header'; +import LoadingPage from '../../components/common/LoadingPage'; +import CommonModal from '../../components/common/Modal/CommonModal'; +import BookInfoTextarea from '../components/BookInfoTextarea'; +import BookInput from '../components/BookInput'; +import CompleteButton from '../components/CompleteButton'; +import SelectColor from '../components/SelectColor'; +import usePostBook from '../hooks/usePostBook'; +import * as S from './CreateBook.style'; + +function CreateBook() { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [backgroundColor, setBackgroundColor] = useState('#F5F5F5'); + const [modalOn, setModalOn] = useState(false); + const [escapeModal, setEscapeModal] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + const { presignedFileName, name } = location.state || {}; + + const handleClickCompleteButton = async () => { + setModalOn(true); + }; + + const postMutation = usePostBook(); + + const handleClickCompleteModal = async () => { + postMutation.mutate({ + favoriteName: name, + favoriteImage: presignedFileName, + title: title, + description: description, + backgroundColor: backgroundColor, + }); + }; + + return postMutation.isLoading ? ( + + ) : ( + + {modalOn && ( + + )} + {escapeModal && ( + navigate('/', { state: { step: 1 } })} + category="book_escape" + setModalOn={setEscapeModal} + /> + )} +
setEscapeModal(true)} /> + + + + + 레큐북 제목 + + setTitle(title)} /> + + + + 레큐북 소개 + + setDescription(description)} + /> + + + setBackgroundColor(backgroundColor) + } + backgroundColor={backgroundColor} + /> + + + + + ); +} + +export default CreateBook; diff --git a/src/CreateBook/type/createBookType.ts b/src/CreateBook/type/createBookType.ts new file mode 100644 index 00000000..c9247dcb --- /dev/null +++ b/src/CreateBook/type/createBookType.ts @@ -0,0 +1,6 @@ +import { SelectColorProps } from '../../LecueNote/type/lecueNoteType'; + +export type createBookType = Omit< + SelectColorProps, + 'clickedBgColor' | 'clickedCategory' +>; diff --git a/src/CreateBook/utils/api.ts b/src/CreateBook/utils/api.ts new file mode 100644 index 00000000..038c51f5 --- /dev/null +++ b/src/CreateBook/utils/api.ts @@ -0,0 +1,43 @@ +import { AxiosResponse } from 'axios'; + +import { api } from '../../libs/api'; + +interface ApiResponse { + code: number; + message: string; + data: { + bookUuid: string; + }; +} + +interface PostBookData { + favoriteName: string; + favoriteImage: string; + title: string; + description: string; + backgroundColor: string; +} + +const getAuthorizationToken = (): string | null => { + return localStorage.getItem('token'); +}; + +const postBook = async (data: PostBookData): Promise<{ bookUuid: string }> => { + const token = getAuthorizationToken(); + + const response: AxiosResponse = await api.post( + '/api/books', + data, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const bookUuid = response.data.data.bookUuid; + return { bookUuid }; +}; + +export { postBook }; diff --git a/src/Detail/api/getBookDetail.ts b/src/Detail/api/getBookDetail.ts new file mode 100644 index 00000000..c8447bad --- /dev/null +++ b/src/Detail/api/getBookDetail.ts @@ -0,0 +1,11 @@ +import { api } from '../../libs/api'; + +export async function getBookDetail(bookUuid: string) { + const data = await api.get(`/api/books/detail/${bookUuid}`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + return data.data.data; +} diff --git a/src/Detail/components/AlretBanner/AlertBanner.style.ts b/src/Detail/components/AlretBanner/AlertBanner.style.ts new file mode 100644 index 00000000..b39cff89 --- /dev/null +++ b/src/Detail/components/AlretBanner/AlertBanner.style.ts @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; + +export const ButtonWrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + position: fixed; + bottom: 2rem; + + width: 92%; +`; + +export const AlertBanner = styled.div` + display: flex; + gap: 0.4rem; + justify-content: center; + + width: 90%; + padding: 1.1rem 2.35rem; + margin-bottom: 1rem; + + border-radius: 0.6rem; + background: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.key}; + + text-align: center; + ${({ theme }) => theme.fonts.Caption2_SB_12}; +`; diff --git a/src/Detail/components/AlretBanner/index.tsx b/src/Detail/components/AlretBanner/index.tsx new file mode 100644 index 00000000..3d578a9a --- /dev/null +++ b/src/Detail/components/AlretBanner/index.tsx @@ -0,0 +1,23 @@ +import { IcCaution } from '../../../assets'; +import Button from '../../../components/common/Button'; +import * as S from './AlertBanner.style'; + +interface AlertBannerProps { + onClick: () => void; +} + +function AlertBanner({ onClick }: AlertBannerProps) { + return ( + + + + 스티커는 한 번 붙이면 수정/삭제할 수 없습니다 + + + + ); +} + +export default AlertBanner; diff --git a/src/Detail/components/BigLecueNote/BigLecueNote.style.ts b/src/Detail/components/BigLecueNote/BigLecueNote.style.ts new file mode 100644 index 00000000..c9892b7a --- /dev/null +++ b/src/Detail/components/BigLecueNote/BigLecueNote.style.ts @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; + +export const BigLecueNoteWrapper = styled.div<{ + noteBackground: string; + noteTextColor: string; +}>` + width: 100%; + height: 34.2rem; + padding: 2rem 1.1rem 1.7rem 1.9rem; + + border-radius: 0.6rem; + ${({ noteBackground }) => { + if (noteBackground.substring(0, 1) === '#') { + return `background-color: ${noteBackground}`; + } else { + return `background: url(${noteBackground})`; + } + }}; + background-size: cover; + color: ${({ noteTextColor }) => { + return noteTextColor; + }}; +`; + +export const BigLecueNoteNickname = styled.p` + ${({ theme }) => theme.fonts.Head1_B_20}; +`; + +export const BigLecueNoteContentWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + overflow: scroll; + + width: 100%; + height: 22.4rem; + padding-right: 0.8rem; + margin-top: 1.5rem; +`; + +export const BigLecueNoteContent = styled.div` + width: 100%; + max-height: 100%; + + ${({ theme }) => theme.fonts.Body1_M_16}; + + white-space: pre-wrap; +`; + +export const BigLecueNoteDate = styled.p` + width: 100%; + padding-right: 0.8rem; + margin-top: 2.1rem; + + ${({ theme }) => theme.fonts.E_Body2_R_14}; + + text-align: end; +`; diff --git a/src/Detail/components/BigLecueNote/index.tsx b/src/Detail/components/BigLecueNote/index.tsx new file mode 100644 index 00000000..fc207d7d --- /dev/null +++ b/src/Detail/components/BigLecueNote/index.tsx @@ -0,0 +1,32 @@ +import * as S from './BigLecueNote.style'; + +interface BigLecueNoteProps { + content: string; + noteDate: string; + noteNickname: string; + noteTextColor: string; + noteBackground: string; +} + +function BigLecueNote({ + content, + noteDate, + noteNickname, + noteTextColor, + noteBackground, +}: BigLecueNoteProps) { + return ( + + {noteNickname} + + {content} + + {noteDate} + + ); +} + +export default BigLecueNote; diff --git a/src/Detail/components/BookInfoBox/BookInfoBox.style.ts b/src/Detail/components/BookInfoBox/BookInfoBox.style.ts new file mode 100644 index 00000000..2312e313 --- /dev/null +++ b/src/Detail/components/BookInfoBox/BookInfoBox.style.ts @@ -0,0 +1,98 @@ +import styled from '@emotion/styled'; + +export const BookInfoBoxWrapper = styled.div<{ backgroundColor: string }>` + display: flex; + + width: 100%; + height: 18.3em; + + background-color: ${({ theme, backgroundColor }) => { + backgroundColor; + switch (backgroundColor) { + case '#F5F5F5': + return theme.colors.BG; + case '#191919': + return theme.colors.background; + } + }}; +`; + +export const ProfileImageWrapper = styled.div` + display: flex; + align-items: center; + + margin-left: 1.6rem; +`; + +export const ProfileImg = styled.img` + width: 12.6rem; + height: 12.6rem; + + border-radius: 8.2rem; + + object-fit: cover; +`; + +export const BookInfoWrapper = styled.div` + padding: 2.2rem 1.7rem; +`; + +export const BookInfoHeader = styled.div` + display: flex; + align-items: center; + column-gap: 0.9rem; +`; + +export const BookInfoHeaderItemWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 0.3rem; +`; + +export const BookInfoHeaderItem = styled.p<{ backgroundColor: string }>` + height: 1.8rem; + padding-top: 0.4rem; + + color: ${({ theme, backgroundColor }) => { + backgroundColor; + switch (backgroundColor) { + case '#F5F5F5': + return theme.colors.white30; + case '#191919': + return theme.colors.MG; + } + }}; + + ${({ theme }) => theme.fonts.E_Caption_R_12}; +`; + +export const BookInfoTitle = styled.p<{ backgroundColor: string }>` + margin-top: 0.7rem; + + color: ${({ theme, backgroundColor }) => { + backgroundColor; + switch (backgroundColor) { + case '#F5F5F5': + return theme.colors.background; + case '#191919': + return theme.colors.BG; + } + }}; + ${({ theme }) => theme.fonts.Head2_SB_18}; +`; + +export const BookInfoContent = styled.p<{ backgroundColor: string }>` + height: 8.5rem; + margin-top: 1rem; + + color: ${({ theme, backgroundColor }) => { + backgroundColor; + switch (backgroundColor) { + case '#F5F5F5': + return theme.colors.white80; + case '#191919': + return theme.colors.BG; + } + }}; + ${({ theme }) => theme.fonts.Body3_R_14}; +`; diff --git a/src/Detail/components/BookInfoBox/index.tsx b/src/Detail/components/BookInfoBox/index.tsx new file mode 100644 index 00000000..2ce5db11 --- /dev/null +++ b/src/Detail/components/BookInfoBox/index.tsx @@ -0,0 +1,52 @@ +import { IcCrown, IcDate } from '../../../assets'; +import * as S from './BookInfoBox.style'; + +interface BookInfoBoxProps { + favoriteImage: string; + bookDate: string; + bookNickname: string; + title: string; + description: string; + bookBackgroundColor: string; +} + +function BookInfoBox({ + favoriteImage, + bookDate, + bookNickname, + title, + description, + bookBackgroundColor, +}: BookInfoBoxProps) { + return ( + + + + + + + + + + {bookDate} + + + + + + {bookNickname} + + + + + {title} + + + {description} + + + + ); +} + +export default BookInfoBox; diff --git a/src/Detail/components/EmptyView/EmptyView.style.ts b/src/Detail/components/EmptyView/EmptyView.style.ts new file mode 100644 index 00000000..4f89773f --- /dev/null +++ b/src/Detail/components/EmptyView/EmptyView.style.ts @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +export const EmptyViewWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + + width: 100%; + height: calc(100dvh - 39rem); +`; + +export const EmptyViewTextWrapper = styled.div` + margin-top: 2.1rem; + + text-align: center; +`; + +export const EmptyViewText = styled.p` + color: ${({ theme }) => theme.colors.MG}; + ${({ theme }) => theme.fonts.Body3_R_14}; +`; diff --git a/src/Detail/components/EmptyView/index.tsx b/src/Detail/components/EmptyView/index.tsx new file mode 100644 index 00000000..717d178f --- /dev/null +++ b/src/Detail/components/EmptyView/index.tsx @@ -0,0 +1,16 @@ +import { ImgEmpty } from '../../../assets'; +import * as S from './EmptyView.style'; + +function EmptyView() { + return ( + + + + 아직 레큐노트가 없습니다. + 가장 먼저 작성해보세요! + + + ); +} + +export default EmptyView; diff --git a/src/Detail/components/LecueNoteLIstHeader/LecueNoteListHeader.style.ts b/src/Detail/components/LecueNoteLIstHeader/LecueNoteListHeader.style.ts new file mode 100644 index 00000000..3c308758 --- /dev/null +++ b/src/Detail/components/LecueNoteLIstHeader/LecueNoteListHeader.style.ts @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; + +export const LecueNoteListHeaderWrapper = styled.div<{ + backgroundColor: string; +}>` + display: flex; + position: sticky; + top: 9.8rem; + z-index: 2; + + padding: 1.2rem 0; + + background-color: transparent; + column-gap: 1rem; +`; + +export const LecueNoteCountBox = styled.div<{ backgroundColor: string }>` + display: flex; + justify-content: center; + align-items: center; + + padding: 1.05rem 1.6rem; + + border-radius: 7rem; + background-color: ${({ theme, backgroundColor }) => { + backgroundColor; + switch (backgroundColor) { + case '#F5F5F5': + return theme.colors.BG; + case '#191919': + return theme.colors.background; + } + }}; + color: ${({ theme, backgroundColor }) => { + switch (backgroundColor) { + case '#F5F5F5': + return theme.colors.background; + case '#191919': + return theme.colors.BG; + } + }}; + ${({ theme }) => theme.fonts.E_Body2_R_14}; +`; + +export const LecueNoteRenderModeButton = styled.button` + position: relative; + z-index: 2; + + width: 3.8rem; + height: 3.8rem; +`; diff --git a/src/Detail/components/LecueNoteLIstHeader/index.tsx b/src/Detail/components/LecueNoteLIstHeader/index.tsx new file mode 100644 index 00000000..26d40112 --- /dev/null +++ b/src/Detail/components/LecueNoteLIstHeader/index.tsx @@ -0,0 +1,35 @@ +import { BtnFloatingList, BtnFloatingPostit } from '../../../assets'; +import * as S from './LecueNoteListHeader.style'; + +interface LecueNoteListHeaderProps { + noteNum: number; + backgroundColor: string; + isZigZagView: boolean; + buttonOnClick: () => void; + isEditable: boolean; +} + +function LecueNoteListHeader({ + noteNum, + backgroundColor, + isZigZagView, + buttonOnClick, + isEditable, +}: LecueNoteListHeaderProps) { + return ( + + {`${noteNum}개`} + + {isZigZagView ? : } + + + ); +} + +export default LecueNoteListHeader; diff --git a/src/Detail/components/LecueNoteListContainer/LecueNoteListContainer.style.ts b/src/Detail/components/LecueNoteListContainer/LecueNoteListContainer.style.ts new file mode 100644 index 00000000..018403dc --- /dev/null +++ b/src/Detail/components/LecueNoteListContainer/LecueNoteListContainer.style.ts @@ -0,0 +1,44 @@ +import styled from '@emotion/styled'; + +export const LecueNoteListContainerWrapper = styled.div<{ + backgroundColor: string; + isEditable: boolean; +}>` + width: 100vw; + min-height: calc(100dvh - 28.1rem); + + padding: 0 1.6rem; + padding-bottom: ${({ isEditable }) => isEditable && '12rem'}; + + background-color: ${({ backgroundColor }) => { + return backgroundColor; + }}; + + flex: 1; +`; + +export const LecueNoteListViewWrapper = styled.div` + display: flex; + justify-content: center; + position: relative; + + width: 100%; +`; + +export const StickerButton = styled.button` + position: fixed; + right: 2.057rem; + bottom: 9.8rem; + + width: 6.8rem; + height: 6.8rem; +`; + +export const WriteButton = styled.button` + position: fixed; + right: 2.057rem; + bottom: 2rem; + + width: 6.8rem; + height: 6.8rem; +`; diff --git a/src/Detail/components/LecueNoteListContainer/index.tsx b/src/Detail/components/LecueNoteListContainer/index.tsx new file mode 100644 index 00000000..ff499986 --- /dev/null +++ b/src/Detail/components/LecueNoteListContainer/index.tsx @@ -0,0 +1,217 @@ +import { useEffect, useRef, useState } from 'react'; +import { DraggableData, DraggableEvent } from 'react-draggable'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { + BtnFloatingSticker, + BtnFloatingStickerOrange, + BtnFloatingWrite, + BtnFloatingWriteOrange, +} from '../../../assets'; +import CommonModal from '../../../components/common/Modal/CommonModal'; +import usePostStickerState from '../../../StickerAttach/hooks/usePostStickerState'; +import { NoteType, postedStickerType } from '../../type/lecueBookType'; +import AlertBanner from '../AlretBanner'; +import EmptyView from '../EmptyView'; +import LecueNoteListHeader from '../LecueNoteLIstHeader'; +import LinearView from '../LinearView'; +import ZigZagView from '../ZigZagView'; +import * as S from './LecueNoteListContainer.style'; + +interface LecueNoteListContainerProps { + noteNum: number; + backgroundColor: string; + noteList: NoteType[]; + postedStickerList: postedStickerType[]; + isEditable: boolean; + setEditableStateFalse: () => void; + bookUuid: string; + bookId: number; +} + +function LecueNoteListContainer(props: LecueNoteListContainerProps) { + const { + noteNum, + backgroundColor, + noteList, + postedStickerList, + isEditable, + setEditableStateFalse, + bookUuid, + bookId, + } = props; + //hooks + const location = useLocation(); + const navigate = useNavigate(); + const scrollRef = useRef(null); + + //storage + const storedValue = sessionStorage.getItem('scrollPosition'); + const savedScrollPosition = + storedValue !== null ? parseInt(storedValue, 10) : 0; + + //state + const [fullHeight, setFullHeight] = useState(null); + const [heightFromBottom, setHeightFromBottom] = useState(null); + const [modalOn, setModalOn] = useState(false); + const [isZigZagView, setIsZigZagView] = useState(true); + const [stickerState, setStickerState] = useState({ + postedStickerId: 0, + stickerImage: '', + positionX: 0, + positionY: savedScrollPosition, + }); + const { state } = location; + + // 스티커 위치 값 저장 + const handleDrag = (_e: DraggableEvent, ui: DraggableData) => { + const { positionX, positionY } = stickerState; + setStickerState((prev) => ({ + ...prev, + positionX: positionX + ui.deltaX, + positionY: positionY + ui.deltaY, + })); + }; + + const handleClickStickerButton = () => { + if ( + localStorage.getItem('token') && + localStorage.getItem('token') !== null + ) { + sessionStorage.setItem('scrollPosition', window.scrollY.toString()); + + navigate('/sticker-pack', { state: { bookId: bookId }, replace: true }); + } else { + setModalOn(true); + } + }; + + const handleClickModalBtn = () => { + navigate(`/login`); + }; + + const handleClickWriteButton = () => { + if ( + localStorage.getItem('token') && + localStorage.getItem('token') !== null + ) { + navigate(`/create-note`, { + state: { bookId: bookId }, + }); + } else { + setModalOn(true); + } + }; + + useEffect(() => { + if (scrollRef.current) { + if (scrollRef.current.offsetHeight) { + setFullHeight(scrollRef.current.offsetHeight); + } + + if (fullHeight !== null) { + setHeightFromBottom(fullHeight - stickerState.positionY); + } + } + }, [fullHeight, stickerState.positionY, scrollRef]); + + useEffect(() => { + // state : 라우터 타고 온 스티커 값 + if (state) { + window.scrollTo(0, savedScrollPosition); + const { stickerId, stickerImage } = state.sticker; + setStickerState((prev) => ({ + ...prev, + postedStickerId: stickerId, + stickerImage: stickerImage, + })); + } else { + // editable 상태 변경 + setEditableStateFalse(); + } + }, [state, isEditable]); + + const postMutation = usePostStickerState(bookUuid); + + const handleClickDone = () => { + // 다 붙였을 때 post 실행 + const { postedStickerId, positionX } = stickerState; + + if (heightFromBottom !== null) { + postMutation.mutate({ + postedStickerId: postedStickerId, + bookId: bookId, + positionX: positionX, + positionY: heightFromBottom, + }); + } + + setEditableStateFalse(); + }; + + return ( + + setIsZigZagView(!isZigZagView)} + /> + + {noteList.length === 0 ? ( + + ) : isZigZagView ? ( + + ) : ( + + )} + + + {!isEditable && ( + <> + {noteList.length !== 0 && ( + + {backgroundColor === '#F5F5F5' ? ( + + ) : ( + + )} + + )} + + {backgroundColor === '#F5F5F5' ? ( + + ) : ( + + )} + + + )} + + {isEditable && } + + {modalOn && ( + + )} + + ); +} + +export default LecueNoteListContainer; diff --git a/src/Detail/components/LecueNoteModal/LecueNoteModal.style.ts b/src/Detail/components/LecueNoteModal/LecueNoteModal.style.ts new file mode 100644 index 00000000..b4232f96 --- /dev/null +++ b/src/Detail/components/LecueNoteModal/LecueNoteModal.style.ts @@ -0,0 +1,86 @@ +import styled from '@emotion/styled'; + +export const BlurryContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + position: fixed; + top: 0; + z-index: 10; + + width: 100vw; + height: 100dvh; + + background-color: ${({ theme }) => theme.colors.Modal}; +`; + +export const LecueNoteModalWrapper = styled.div<{ + noteBackground?: string; + noteTextColor: string; +}>` + position: relative; + + width: 31.1rem; + height: 35.8rem; + padding: 2rem 1.5rem; + + border-radius: 0.4rem; + ${({ noteBackground }) => { + if (noteBackground?.substring(0, 1) === '#') { + return `background-color: ${noteBackground}`; + } else { + return `background: url(${noteBackground})`; + } + }}; + background-size: cover; + color: ${({ noteTextColor }) => { + return noteTextColor; + }}; +`; + +export const CloseButton = styled.button` + position: absolute; + top: 0.6rem; + right: 0.6rem; + + width: 3.2rem; + height: 3.2rem; +`; + +export const LecueNoteModalNickname = styled.p` + ${({ theme }) => theme.fonts.Head2_SB_18}; +`; + +export const LecueNoteModalContentWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + overflow: scroll; + + width: 100%; + height: 23.4rem; + padding: 0 0.6rem; + margin-top: 3rem; +`; + +export const LecueNoteModalContent = styled.p` + width: 100%; + max-height: 100%; + + ${({ theme }) => theme.fonts.Title2_M_16}; + + white-space: pre-wrap; +`; + +export const LecueNoteModalDate = styled.p` + width: 100%; + max-height: 100%; + + padding-right: 0.6rem; + margin-top: 1.3rem; + + ${({ theme }) => theme.fonts.E_Body2_R_14}; + color: ${({ theme }) => theme.colors.DG50}; + + text-align: end; +`; diff --git a/src/Detail/components/LecueNoteModal/index.tsx b/src/Detail/components/LecueNoteModal/index.tsx new file mode 100644 index 00000000..10ab70db --- /dev/null +++ b/src/Detail/components/LecueNoteModal/index.tsx @@ -0,0 +1,46 @@ +import { createPortal } from 'react-dom'; + +import { IcX } from '../../../assets'; +import { NoteType } from '../../type/lecueBookType'; +import * as S from './LecueNoteModal.style'; +const modalContainer = document.getElementById( + 'lecuenote-modal', +) as HTMLElement; + +interface LecueNoteModalProps { + selectedNote: NoteType; + closeModal: () => void; +} + +function LecueNoteModal({ selectedNote, closeModal }: LecueNoteModalProps) { + const handleCloseButtonClick = ( + event: React.MouseEvent, + ) => { + event.stopPropagation(); + closeModal(); + }; + return createPortal( + + + + + + + {selectedNote.noteNickname} + + + + {selectedNote.content} + + + {selectedNote.noteDate} + + , + modalContainer, + ); +} + +export default LecueNoteModal; diff --git a/src/Detail/components/LinearView/LinearView.style.ts b/src/Detail/components/LinearView/LinearView.style.ts new file mode 100644 index 00000000..236977bb --- /dev/null +++ b/src/Detail/components/LinearView/LinearView.style.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +export const LinearViewWrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + + width: 100%; + padding: 1.8rem 0 2.5rem; + row-gap: 1.8rem; +`; diff --git a/src/Detail/components/LinearView/index.tsx b/src/Detail/components/LinearView/index.tsx new file mode 100644 index 00000000..e0cd5fbb --- /dev/null +++ b/src/Detail/components/LinearView/index.tsx @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; + +import { NoteType } from '../../type/lecueBookType'; +import BigLecueNote from '../BigLecueNote'; +import * as S from './LinearView.style'; + +interface LinearViewProps { + noteList: NoteType[]; +} + +function LinearView({ noteList }: LinearViewProps) { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + + return ( + + {noteList.map((note) => ( + + ))} + + ); +} + +export default LinearView; diff --git a/src/Detail/components/SlideBanner/SlideBanner.style.ts b/src/Detail/components/SlideBanner/SlideBanner.style.ts new file mode 100644 index 00000000..724f6553 --- /dev/null +++ b/src/Detail/components/SlideBanner/SlideBanner.style.ts @@ -0,0 +1,51 @@ +import { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const infiniteSlide = keyframes` + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(-50%); + } +`; + +export const SliderBannerWrapper = styled.div` + position: fixed; + overflow: hidden; + z-index: 1; + + width: 100%; + height: 4.4rem; + + border-bottom: 0.1rem solid ${({ theme }) => theme.colors.BG}; + background-color: ${({ theme }) => theme.colors.key}; +`; + +export const AnimationBox = styled.div<{ + width: number; + animationDuration: number; +}>` + display: flex; + + width: ${(props) => props.width}rem; + max-width: none; + + height: 100%; + + animation: ${infiniteSlide}; + animation-duration: ${(props) => props.animationDuration}s; + animation-timing-function: linear; + animation-iteration-count: infinite; +`; + +export const SlideBannerItemList = styled.div<{ width: number }>` + display: flex; + align-items: center; + + width: ${(props) => props.width}rem; + max-width: none; + + height: 100%; +`; diff --git a/src/Detail/components/SlideBanner/index.tsx b/src/Detail/components/SlideBanner/index.tsx new file mode 100644 index 00000000..289e3bee --- /dev/null +++ b/src/Detail/components/SlideBanner/index.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react'; + +import SlideBannerItem from '../SlideBannerItem'; +import * as S from './SlideBanner.style'; + +interface SlideBannerProps { + name: string; +} + +function SlideBanner({ name }: SlideBannerProps) { + const itemBoxRef = useRef(null); + const [itemListWidth, setItemListWidth] = useState(0); + const [animationListWidth, setAnimationListWidth] = useState(0); + const [animationDuration, setAnimationDuration] = useState(10); + + const renderSlideBannerItems = () => { + return Array.from({ length: 10 }, (_, index) => ( + + )); + }; + + useEffect(() => { + if (itemBoxRef.current) { + const itemBoxWidth = itemBoxRef.current.offsetWidth; + + const itemListWidth = itemBoxWidth + 6; + + setItemListWidth(itemListWidth); + setAnimationListWidth(itemListWidth * 2); + + const nameLength = name.length; + const newAnimationDuration = Math.max(1, nameLength * 7); + setAnimationDuration(newAnimationDuration); + } + }, [name]); + + return ( + + + + {renderSlideBannerItems()} + + + {renderSlideBannerItems()} + + + + ); +} + +export default SlideBanner; diff --git a/src/Detail/components/SlideBannerItem/SlideBannerItem.style.ts b/src/Detail/components/SlideBannerItem/SlideBannerItem.style.ts new file mode 100644 index 00000000..971b0261 --- /dev/null +++ b/src/Detail/components/SlideBannerItem/SlideBannerItem.style.ts @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; + +export const SliderBannerItemWrapper = styled.div` + display: flex; + flex-shrink: 0; + + width: auto; + margin-right: 0.6rem; +`; + +export const Name = styled.p` + margin: 0.3rem 0.6rem 0; + + ${({ theme }) => theme.fonts.Orange}; +`; diff --git a/src/Detail/components/SlideBannerItem/index.tsx b/src/Detail/components/SlideBannerItem/index.tsx new file mode 100644 index 00000000..db6dae47 --- /dev/null +++ b/src/Detail/components/SlideBannerItem/index.tsx @@ -0,0 +1,23 @@ +import React, { forwardRef } from 'react'; + +import { ImgLe, ImgStarOrangeLine } from '../../../assets'; +import * as S from './SlideBannerItem.style'; + +interface SlideBannerItemProps { + name: string; +} + +const SlideBannerItem = forwardRef(function SlideBannerItem( + { name }: SlideBannerItemProps, + ref: React.Ref, +) { + return ( + }> + + {`( ${name} )`} + + + ); +}); + +export default SlideBannerItem; diff --git a/src/Detail/components/SmallLecueNote/SmallLecueNote.style.ts b/src/Detail/components/SmallLecueNote/SmallLecueNote.style.ts new file mode 100644 index 00000000..71b86855 --- /dev/null +++ b/src/Detail/components/SmallLecueNote/SmallLecueNote.style.ts @@ -0,0 +1,96 @@ +import styled from '@emotion/styled'; + +export const SmallLecueNoteWrapper = styled.div<{ + renderType: number; + noteTextColor: string; + noteBackground: string; +}>` + width: 15.2rem; + height: 16.6rem; + padding: 1.4rem 1rem 0.9rem; + margin: ${({ renderType }) => { + switch (renderType) { + case 1: + return '3.5rem 0 0 0.803rem'; + case 2: + return '0.6rem 0 0 0.86rem'; + case 3: + return '3.2rem 0 0 0.86rem'; + case 4: + return '0.81rem 0 0 0.803rem'; + case 5: + return '3rem 0 0 0.991rem'; + case 6: + return '1rem 0 0 0.926rem'; + } + }}; + + border-radius: 0.4rem; + ${({ noteBackground }) => { + if (noteBackground.substring(0, 1) === '#') { + return `background-color: ${noteBackground}`; + } else { + return `background: url(${noteBackground})`; + } + }}; + background-size: 15.2rem 16.6rem; + color: ${({ noteTextColor }) => { + return noteTextColor; + }}; + + transform: ${({ renderType }) => { + switch (renderType) { + case 1: + return 'rotate(4deg)'; + case 2: + return 'rotate(-4deg)'; + case 3: + return 'rotate(-4deg)'; + case 4: + return 'rotate(4deg)'; + case 5: + return 'rotate(6deg)'; + case 6: + return 'rotate(-6deg)'; + } + }}; +`; + +export const SmallLecueNoteNickName = styled.p` + ${({ theme }) => theme.fonts.Title1_SB_16}; +`; + +export const SmallLecueNoteContentWrapper = styled.div` + display: flex; + align-items: center; + + height: 10rem; + margin-top: 0.6rem; + + ${({ theme }) => theme.fonts.Body2_M_14}; +`; + +export const SmallLecueNoteContent = styled.p` + display: -webkit-box; + -webkit-box-orient: vertical; + + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 5; + + max-height: 100%; + + white-space: pre-wrap; + + ${({ theme }) => theme.fonts.Body2_M_14}; +`; + +export const SmallLecueNoteDate = styled.p` + width: 100%; + margin-top: 0.8rem; + + color: ${({ theme }) => theme.colors.DG50}; + ${({ theme }) => theme.fonts.E_Caption_R_12}; + + text-align: right; +`; diff --git a/src/Detail/components/SmallLecueNote/index.tsx b/src/Detail/components/SmallLecueNote/index.tsx new file mode 100644 index 00000000..87db9ae5 --- /dev/null +++ b/src/Detail/components/SmallLecueNote/index.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; + +import { NoteType } from '../../type/lecueBookType'; +import LecueNoteModal from '../LecueNoteModal'; +import * as S from './SmallLecueNote.style'; + +interface SmallLecueNoteProps { + renderType: number; + content: string; + noteDate: string; + noteNickname: string; + noteTextColor: string; + noteBackground: string; + noteId: number; + noteList: NoteType[]; +} + +function SmallLecueNote({ + renderType, + content, + noteDate, + noteNickname, + noteTextColor, + noteBackground, + noteId, + noteList, +}: SmallLecueNoteProps) { + const [modalShow, setModalShow] = useState(false); + + const getClickedNote = () => + noteList.filter((note) => note.noteId === noteId); + + const handleClickSmallLecueNote = () => { + const clickedNote = getClickedNote(); + if (clickedNote) { + setModalShow(true); + } + }; + + return ( + + {noteNickname} + + {content} + + {noteDate} + {modalShow && ( + setModalShow(false)} + /> + )} + + ); +} + +export default SmallLecueNote; diff --git a/src/Detail/components/ZigZagView/ZigZagView.style.ts b/src/Detail/components/ZigZagView/ZigZagView.style.ts new file mode 100644 index 00000000..1c424d3e --- /dev/null +++ b/src/Detail/components/ZigZagView/ZigZagView.style.ts @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; + +export const ZigZagViewWrapper = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + position: relative; + + width: 34.2rem; + padding: 1rem 0 2rem; +`; + +export const LecueNoteContainer = styled.div` + width: 100%; + height: 20.6rem; +`; + +export const Sticker = styled.div<{ + stickerImage: string; + isEditable?: boolean; +}>` + position: absolute; + + width: 10rem; + height: 10rem; + + ${({ isEditable, theme }) => + isEditable && `border: solid 0.1rem ${theme.colors.key}`}; + border-radius: 0.4rem; + background-position: center; + background-image: ${({ stickerImage }) => `url(${stickerImage})`}; + + background-size: 10rem 10rem; + + background-repeat: no-repeat; + + object-fit: contain; +`; diff --git a/src/Detail/components/ZigZagView/index.tsx b/src/Detail/components/ZigZagView/index.tsx new file mode 100644 index 00000000..4e9f9439 --- /dev/null +++ b/src/Detail/components/ZigZagView/index.tsx @@ -0,0 +1,84 @@ +import { forwardRef, Fragment, useRef } from 'react'; +import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; + +import { NoteType, postedStickerType } from '../../type/lecueBookType'; +import SmallLecueNote from '../SmallLecueNote'; +import * as S from './ZigZagView.style'; + +interface ZigZagViewProps { + noteList: NoteType[]; + handleDrag: (e: DraggableEvent, ui: DraggableData) => void; + stickerState: postedStickerType; + isEditable: boolean; + postedStickerList: postedStickerType[]; + savedScrollPosition: number; + fullHeight: number | null; +} + +const ZigZagView = forwardRef(function ZigZagView( + { + noteList, + handleDrag, + stickerState, + isEditable, + postedStickerList, + savedScrollPosition, + fullHeight, + }: ZigZagViewProps, + ref: React.Ref, +) { + const nodeRef = useRef(null); + + return ( + + {noteList.length > 0 && ( + + {noteList.map((note) => ( + + + + ))} + + )} + {postedStickerList.length > 0 && ( + + {postedStickerList.map( + (data) => + fullHeight !== null && ( + false} + nodeRef={nodeRef} + key={data.postedStickerId} + defaultPosition={{ + x: data.positionX, + y: fullHeight - data.positionY, + }} + > + + + ), + )} + + )} + + {isEditable && ( + + + + )} + + ); +}); +export default ZigZagView; diff --git a/src/Detail/constants/.gitkeep b/src/Detail/constants/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/Detail/hooks/useGetBookDetail.ts b/src/Detail/hooks/useGetBookDetail.ts new file mode 100644 index 00000000..08bf612b --- /dev/null +++ b/src/Detail/hooks/useGetBookDetail.ts @@ -0,0 +1,21 @@ +import { useQuery } from 'react-query'; +import { useNavigate } from 'react-router-dom'; + +import { getBookDetail } from '../api/getBookDetail'; + +export default function useGetBookDetail(bookUuid: string) { + const navigate = useNavigate(); + const { data: bookDetail, isLoading } = useQuery( + ['useGetBookDetail', bookUuid], + () => getBookDetail(bookUuid), + { + onError: () => { + navigate('/error'); + }, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + }, + ); + + return { bookDetail, isLoading }; +} diff --git a/src/Detail/page/DetailPage/DetailPage.style.ts b/src/Detail/page/DetailPage/DetailPage.style.ts new file mode 100644 index 00000000..628c4a79 --- /dev/null +++ b/src/Detail/page/DetailPage/DetailPage.style.ts @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; + +export const DetailPageWrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + + width: 100vw; + height: 100dvh; +`; + +export const DetailPageBodyWrapper = styled.div` + margin-top: 5.4rem; +`; + +export const LecueBookContainer = styled.div` + margin-top: 4.4rem; +`; diff --git a/src/Detail/page/DetailPage/index.tsx b/src/Detail/page/DetailPage/index.tsx new file mode 100644 index 00000000..0a95f266 --- /dev/null +++ b/src/Detail/page/DetailPage/index.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import Header from '../../../components/common/Header'; +import LoadingPage from '../../../components/common/LoadingPage'; +import usePostStickerState from '../../../StickerAttach/hooks/usePostStickerState'; +import BookInfoBox from '../../components/BookInfoBox'; +import LecueNoteListContainer from '../../components/LecueNoteListContainer'; +import SlideBanner from '../../components/SlideBanner'; +import useGetBookDetail from '../../hooks/useGetBookDetail'; +import * as S from './DetailPage.style'; + +function DetailPage() { + const [isEditable, setIsEditable] = useState(true); + + const { bookUuid } = useParams() as { bookUuid: string }; + const { bookDetail, isLoading } = useGetBookDetail(bookUuid); + const postMutation = usePostStickerState(bookUuid); + + const setEditableStateFalse = () => { + setIsEditable(false); + }; + + return isLoading || postMutation.isLoading ? ( + + ) : ( + +
+ + + + + + + + + ); +} + +export default DetailPage; diff --git a/src/Detail/type/lecueBookType.ts b/src/Detail/type/lecueBookType.ts new file mode 100644 index 00000000..a945977a --- /dev/null +++ b/src/Detail/type/lecueBookType.ts @@ -0,0 +1,16 @@ +export interface postedStickerType { + postedStickerId: number; + stickerImage: string; + positionX: number; + positionY: number; +} + +export interface NoteType { + noteId: number; + renderType: number; + content: string; + noteDate: string; + noteNickname: string; + noteTextColor: string; + noteBackground: string; +} diff --git a/src/HealthTest.tsx b/src/HealthTest.tsx new file mode 100644 index 00000000..1d2bb4d3 --- /dev/null +++ b/src/HealthTest.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +import { api } from './libs/api'; + +interface TestType { + status: string; +} + +function HealthTest() { + const [data, setData] = useState(); + const getHealthCheck = async () => { + const data = await api.get('/actuator/health'); + setData(data.data); + }; + + useEffect(() => { + getHealthCheck(); + }, []); + + return
status : {data?.status}
; +} + +export default HealthTest; diff --git a/src/Home/api/getLecueBook.ts b/src/Home/api/getLecueBook.ts new file mode 100644 index 00000000..336d2a2b --- /dev/null +++ b/src/Home/api/getLecueBook.ts @@ -0,0 +1,8 @@ +import { api } from '../../libs/api'; + +const getLecueBook = async () => { + const { data } = await api.get('/api/common/home'); + return data; +}; + +export default getLecueBook; diff --git a/src/Home/components/LecueBookList/LecueBookList.style.ts b/src/Home/components/LecueBookList/LecueBookList.style.ts new file mode 100644 index 00000000..411e6889 --- /dev/null +++ b/src/Home/components/LecueBookList/LecueBookList.style.ts @@ -0,0 +1,65 @@ +import styled from '@emotion/styled'; + +export const LecueBookListWrapper = styled.div` + display: flex; + flex-direction: column; + + width: 100%; + + background-color: ${({ theme }) => theme.colors.key}; +`; + +export const Title = styled.header` + width: 100%; + padding: 1.5rem 0; + + border-color: ${({ theme }) => theme.colors.BG}; + border-width: 0.1rem 0; + border-style: solid; + background-color: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.BG}; + ${({ theme }) => theme.fonts.Title1_SB_16}; + + text-align: center; +`; + +export const LecueBookList = styled.section` + display: grid; + gap: 2.2rem; + grid-template-columns: repeat(3, 1fr); + + width: 100%; + padding: 3rem 1.6rem 2.2rem; +`; + +export const LecueBook = styled.li` + display: flex; + gap: 1rem; + justify-content: center; + align-items: center; + flex-direction: column; + + width: 100%; + height: 14rem; + + cursor: pointer; +`; + +export const BookImage = styled.img` + width: 9.8rem; + height: 9.8rem; + + border-radius: 50%; + + object-fit: cover; +`; + +export const BookTitle = styled.p` + width: 100%; + + ${({ theme }) => theme.fonts.E_Body1_SB_14}; + + text-align: center; + word-wrap: normal; + word-break: break-all; +`; diff --git a/src/Home/components/LecueBookList/index.tsx b/src/Home/components/LecueBookList/index.tsx new file mode 100644 index 00000000..0bad82f7 --- /dev/null +++ b/src/Home/components/LecueBookList/index.tsx @@ -0,0 +1,42 @@ +// import { useNavigate } from 'react-router-dom'; + +import { useNavigate } from 'react-router-dom'; + +import useGetLecueBook from '../../hooks/useGetLecueBook'; +import * as S from './LecueBookList.style'; + +interface BookProps { + bookId: number; + bookUuid: string; + favoriteImage: string; + favoriteName: string; +} + +function LecueBookList() { + const { data } = useGetLecueBook(); + const navigate = useNavigate(); + + const handleClickLecueBook = (uuid: string) => { + navigate(`/lecue-book/${uuid}`); + }; + + return ( + + 인기 레큐북 구경하기 + + {data && + data.data.map((book: BookProps) => ( + handleClickLecueBook(book.bookUuid)} + > + + {book.favoriteName} + + ))} + + + ); +} + +export default LecueBookList; diff --git a/src/Home/components/NavigateLecueBook/NavigateLecueBook.style.ts b/src/Home/components/NavigateLecueBook/NavigateLecueBook.style.ts new file mode 100644 index 00000000..3af55d88 --- /dev/null +++ b/src/Home/components/NavigateLecueBook/NavigateLecueBook.style.ts @@ -0,0 +1,39 @@ +import styled from '@emotion/styled'; + +export const MainWrapper = styled.div` + width: 100%; + + background-color: ${({ theme }) => theme.colors.background}; +`; + +export const IconWrapper = styled.section` + display: flex; + gap: 15.7rem; + justify-content: space-between; + align-items: baseline; + + width: 100%; + padding: 6rem 1.6rem 5rem; +`; + +export const ButtonWrapper = styled.section` + display: flex; + gap: 1rem; + flex-direction: column; + + padding: 0 9.5rem 4.9rem 0; +`; + +export const Button = styled.button<{ variant?: boolean }>` + width: 28rem; + height: 6.4rem; + + border: 0.1rem solid ${({ theme }) => theme.colors.BG}; + border-radius: 0 0.2rem 0.2rem 0; + border-left: none; + background-color: ${({ theme, variant }) => + variant ? theme.colors.white : theme.colors.BG}; + color: ${({ theme, variant }) => + variant ? theme.colors.BG : theme.colors.white}; + ${({ theme }) => theme.fonts.Title1_SB_16} +`; diff --git a/src/Home/components/NavigateLecueBook/index.tsx b/src/Home/components/NavigateLecueBook/index.tsx new file mode 100644 index 00000000..29b9e660 --- /dev/null +++ b/src/Home/components/NavigateLecueBook/index.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { IcNotice, ImgLogoLecue } from '../../../assets'; +import CommonModal from '../../../components/common/Modal/CommonModal'; +import * as S from './NavigateLecueBook.style'; + +function NavigateLecueBook() { + const NAVIGATE_CATEGORY = ['레큐북 만들기', '내 기록 보기']; + const navigate = useNavigate(); + const [modalOn, setModalOn] = useState(false); + + const handleClickNavBtn = (idx: number) => { + if (localStorage.getItem('token')) { + idx === 0 ? navigate('/target') : navigate('/mypage'); + } else { + setModalOn(true); + } + }; + + return ( + + + + + + + + + + {NAVIGATE_CATEGORY.map((category, idx) => { + return ( + handleClickNavBtn(idx)} + > + {category} + + ); + })} + + + {modalOn && ( + navigate('/login')} + /> + )} + + ); +} + +export default NavigateLecueBook; diff --git a/src/Home/hooks/useGetLecueBook.ts b/src/Home/hooks/useGetLecueBook.ts new file mode 100644 index 00000000..0fa4627e --- /dev/null +++ b/src/Home/hooks/useGetLecueBook.ts @@ -0,0 +1,19 @@ +import { useQuery } from 'react-query'; +import { useNavigate } from 'react-router-dom'; + +import getLecueBook from '../api/getLecueBook'; + +const useGetLecueBook = () => { + const navigate = useNavigate(); + + const { isLoading, data } = useQuery({ + queryKey: ['get-lecue-book'], + queryFn: () => getLecueBook(), + onError: () => navigate('/error'), + refetchOnWindowFocus: false, + }); + + return { isLoading, data }; +}; + +export default useGetLecueBook; diff --git a/src/Home/page/Home.style.ts b/src/Home/page/Home.style.ts new file mode 100644 index 00000000..d585b104 --- /dev/null +++ b/src/Home/page/Home.style.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + + width: 100vw; + height: 100dvh; + overflow-x: hidden; +`; diff --git a/src/Home/page/HomePage.tsx b/src/Home/page/HomePage.tsx deleted file mode 100644 index 991219a8..00000000 --- a/src/Home/page/HomePage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -function HomePage() { - return ( -
-

HomePage

-

HomeHome

-
- ); -} - -export default HomePage; diff --git a/src/Home/page/index.tsx b/src/Home/page/index.tsx new file mode 100644 index 00000000..1c4c3db5 --- /dev/null +++ b/src/Home/page/index.tsx @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; + +import LoadingPage from '../../components/common/LoadingPage'; +import { StepProps } from '../../Splash/page/SplashPage'; +import LecueBookList from '../components/LecueBookList'; +import NavigateLecueBook from '../components/NavigateLecueBook'; +import useGetLecueBook from '../hooks/useGetLecueBook'; +import * as S from './Home.style'; + +function Home({ handleStep }: StepProps) { + const { isLoading } = useGetLecueBook(); + + useEffect(() => { + handleStep(1); + }, []); + + return isLoading ? ( + + ) : ( + + + + + ); +} + +export default Home; diff --git a/src/LecueNote/api/getPresignedUrl.ts b/src/LecueNote/api/getPresignedUrl.ts new file mode 100644 index 00000000..e232a2e6 --- /dev/null +++ b/src/LecueNote/api/getPresignedUrl.ts @@ -0,0 +1,8 @@ +import { api } from '../../libs/api'; + +const getPresignedUrl = async () => { + const { data } = await api.get('/api/images/note'); + return data; +}; + +export default getPresignedUrl; diff --git a/src/LecueNote/api/postLecueNote.ts b/src/LecueNote/api/postLecueNote.ts new file mode 100644 index 00000000..62d81730 --- /dev/null +++ b/src/LecueNote/api/postLecueNote.ts @@ -0,0 +1,32 @@ +import { api } from '../../libs/api'; +import { postLecueNoteProps } from '../type/lecueNoteType'; + +const postLecueNote = ({ + contents, + color, + fileName, + bgColor, + isIconClicked, + bookId, +}: postLecueNoteProps) => { + const token = localStorage.getItem('token'); + + const response = api.post( + '/api/notes', + { + bookId: bookId, + content: contents, + textColor: color, + background: isIconClicked ? fileName : bgColor, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + return response; +}; + +export default postLecueNote; diff --git a/src/LecueNote/api/putPresignedUrl.ts b/src/LecueNote/api/putPresignedUrl.ts new file mode 100644 index 00000000..f09f1c8e --- /dev/null +++ b/src/LecueNote/api/putPresignedUrl.ts @@ -0,0 +1,18 @@ +import { api } from '../../libs/api'; +import { putPresignedUrlProps } from '../type/lecueNoteType'; + +const putPresignedUrl = ({ + presignedUrl, + binaryFile, + fileType, +}: putPresignedUrlProps) => { + const response = api.put(presignedUrl, binaryFile, { + headers: { + 'Content-Type': fileType, + }, + }); + + return response; +}; + +export default putPresignedUrl; diff --git a/src/LecueNote/components/CreateNote/CreateNote.style.ts b/src/LecueNote/components/CreateNote/CreateNote.style.ts new file mode 100644 index 00000000..28ce432b --- /dev/null +++ b/src/LecueNote/components/CreateNote/CreateNote.style.ts @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.section` + display: flex; + gap: 3.2rem; + flex-direction: column; + + width: 100%; + margin: 7.8rem 0 3.3rem; +`; diff --git a/src/LecueNote/components/CreateNote/index.tsx b/src/LecueNote/components/CreateNote/index.tsx new file mode 100644 index 00000000..9aaeb42f --- /dev/null +++ b/src/LecueNote/components/CreateNote/index.tsx @@ -0,0 +1,51 @@ +import { CreateNoteProps } from '../../type/lecueNoteType'; +import SelectColor from '../SelectColor'; +import WriteNote from '../WriteNote'; +import * as S from './CreateNote.style'; + +function CreateNote({ + clickedCategory, + clickedBgColor, + clickedTextColor, + isIconClicked, + contents, + setFileName, + handleChangeFn, + handleClickCategory, + handleClickedColorBtn, + handleClickedIcon, + imgFile, + uploadImage, + binaryImage, + setPresignedUrl, + selectedFile +}: CreateNoteProps) { + return ( + + + + + ); +} + +export default CreateNote; diff --git a/src/LecueNote/components/Footer/Footer.style.ts b/src/LecueNote/components/Footer/Footer.style.ts new file mode 100644 index 00000000..8c2bec6b --- /dev/null +++ b/src/LecueNote/components/Footer/Footer.style.ts @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.footer` + display: flex; + justify-content: center; + align-items: end; + + width: 100%; + margin-bottom: 2rem; +`; diff --git a/src/LecueNote/components/Footer/index.tsx b/src/LecueNote/components/Footer/index.tsx new file mode 100644 index 00000000..987aade2 --- /dev/null +++ b/src/LecueNote/components/Footer/index.tsx @@ -0,0 +1,19 @@ +import Button from '../../../components/common/Button'; +import { FooterProps } from '../../type/lecueNoteType'; +import * as S from './Footer.style'; + +function Footer({ contents, setModalOn }: FooterProps) { + return ( + + + + ); +} + +export default Footer; diff --git a/src/LecueNote/components/SelectColor/SelectColor.style.ts b/src/LecueNote/components/SelectColor/SelectColor.style.ts new file mode 100644 index 00000000..4c0c4e56 --- /dev/null +++ b/src/LecueNote/components/SelectColor/SelectColor.style.ts @@ -0,0 +1,27 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const Wrapper = styled.article` + display: flex; + gap: 1.8rem; + flex-direction: column; +`; + +export const CategoryWrapper = styled.div` + display: flex; + gap: 1.4rem; +`; + +export const Category = styled.button<{ variant: boolean }>` + ${({ theme, variant }) => + variant + ? css` + ${theme.fonts.Title1_SB_16} + color: ${theme.colors.BG} + ` + : css` + ${theme.fonts.Title2_M_16} + color: ${theme.colors.MG} + `} + background-color: none; +`; diff --git a/src/LecueNote/components/SelectColor/index.tsx b/src/LecueNote/components/SelectColor/index.tsx new file mode 100644 index 00000000..a196c74c --- /dev/null +++ b/src/LecueNote/components/SelectColor/index.tsx @@ -0,0 +1,72 @@ +import { + BG_COLOR_CHART, + CATEGORY, + TEXT_COLOR_CHART, +} from '../../constants/colorChart'; +import { SelectColorProps } from '../../type/lecueNoteType'; +import ShowColorChart from '../ShowColorChart'; +import * as S from './SelectColor.style'; + +function SelectColor({ + isIconClicked, + clickedCategory, + clickedTextColor, + clickedBgColor, + setPresignedUrl, + binaryImage, + setFileName, + handleCategoryFn, + handleColorFn, + handleIconFn, + uploadImage, + selectedFile +}: SelectColorProps) { + return ( + + + {CATEGORY.map((it) => { + return ( + + {it} + + ); + })} + + + {clickedCategory === '텍스트색' ? ( + + ) : ( + + )} + + ); +} + +export default SelectColor; diff --git a/src/LecueNote/components/ShowColorChart/ShowColorChart.style.ts b/src/LecueNote/components/ShowColorChart/ShowColorChart.style.ts new file mode 100644 index 00000000..c018244b --- /dev/null +++ b/src/LecueNote/components/ShowColorChart/ShowColorChart.style.ts @@ -0,0 +1,78 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + gap: 1.4rem; + justify-content: flex-start; + align-items: center; + + padding: 0.2rem 0.1rem 1rem 0.3rem; + + overflow-x: scroll; +`; + +export const Input = styled.input` + display: none; +`; + +export const IconWrapper = styled.button<{ $isIconClicked: boolean }>` + ${({ theme, $isIconClicked }) => + $isIconClicked && + css` + outline: 0.1rem solid ${theme.colors.WG}; + `}; + flex-shrink: 0; + + width: 3.8rem; + height: 3.8rem; + + border-radius: 3rem; +`; + +export const ColorWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + + width: 3.8rem; + height: 3.8rem; +`; + +export const Color = styled.button<{ + $isIconClicked: boolean; + $colorCode: string; + variant: boolean; +}>` + border-radius: 3rem; + ${({ $colorCode, theme }) => + $colorCode === '#FFF' && + css` + outline: 0.1rem solid ${theme.colors.WG}; + `}; + background-color: ${({ $colorCode }) => $colorCode}; + + ${({ variant, theme, $isIconClicked }) => + $isIconClicked + ? css` + width: 3rem; + height: 3rem; + + border: none; + ` + : variant + ? css` + width: 3.8rem; + height: 3.8rem; + + border: 0.4rem solid ${theme.colors.white}; + outline: 0.1rem solid ${theme.colors.WG}; + ` + : css` + width: 3rem; + height: 3rem; + + border: none; + `}; +`; diff --git a/src/LecueNote/components/ShowColorChart/index.tsx b/src/LecueNote/components/ShowColorChart/index.tsx new file mode 100644 index 00000000..507db0e8 --- /dev/null +++ b/src/LecueNote/components/ShowColorChart/index.tsx @@ -0,0 +1,88 @@ +import { useRef } from 'react'; + +import { IcCameraSmall } from '../../../assets'; +import { BG_COLOR_CHART } from '../../constants/colorChart'; +import useGetPresignedUrl from '../../hooks/useGetPresignedUrl'; +import { ShowColorChartProps } from '../../type/lecueNoteType'; +import * as S from './ShowColorChart.style'; + +function ShowColorChart({ + isIconClicked, + colorChart, + state, + selectedFile, + setPresignedUrl, + binaryImage, + setFileName, + uploadImage, + handleFn, + handleIconFn, +}: ShowColorChartProps) { + const imgRef = useRef(null); + // 여기 + useGetPresignedUrl(setPresignedUrl, setFileName); + + const handleImageUpload = () => { + const fileInput = imgRef.current; + + if (fileInput && fileInput.files && fileInput.files.length > 0) { + const file = fileInput.files[0]; + + // reader1: 파일을 base64로 읽어서 업로드 + const reader1 = new FileReader(); + reader1.readAsDataURL(file); + reader1.onloadend = () => { + if (reader1.result !== null) { + uploadImage(reader1.result as string); + } + }; + + // reader2: 파일을 ArrayBuffer로 읽어서 PUT 요청 수행 + const reader2 = new FileReader(); + reader2.readAsArrayBuffer(file); + binaryImage(reader2); + selectedFile(file); + } + }; + + return ( + + {colorChart === BG_COLOR_CHART && ( + <> + + + { + handleIconFn(); + imgRef.current?.click(); + }} + $isIconClicked={isIconClicked} + > + + + + )} + {colorChart.map((colorCode) => ( + + + + ))} + + ); +} + +export default ShowColorChart; diff --git a/src/LecueNote/components/WriteNote/WriteNote.style.ts b/src/LecueNote/components/WriteNote/WriteNote.style.ts new file mode 100644 index 00000000..8c6e0882 --- /dev/null +++ b/src/LecueNote/components/WriteNote/WriteNote.style.ts @@ -0,0 +1,87 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + gap: 0.6rem; + flex-direction: column; +`; + +export const LecueNote = styled.article<{ + $bgColor: string; + $isIconClicked: boolean; + $imgFile: string; +}>` + display: flex; + flex-direction: column; + + width: 100%; + height: calc(100dvh - 33.2rem); + + border-radius: 0.6rem; + + ${({ $isIconClicked, $bgColor, $imgFile }) => + $isIconClicked + ? css` + width: 100%; + height: calc(100dvh - 33.2rem); + + background-size: 100% calc(100dvh - 33.2rem); + + background-image: url(${$imgFile}); + ` + : css` + background-color: ${$bgColor}; + `}; +`; + +export const Nickname = styled.p<{ $textColor: string }>` + margin: 2rem 0 1rem 2rem; + + color: ${({ $textColor }) => $textColor}; + ${({ theme }) => theme.fonts.Head1_B_20} +`; + +export const Contents = styled.textarea<{ $textColor: string }>` + width: calc(100% - 3rem); + height: 100%; + margin: 0 1.5rem 2rem; + + border: none; + ${({ theme }) => theme.fonts.Body1_R_16}; + background-color: transparent; + color: ${({ $textColor }) => $textColor}; + resize: none; + + outline-color: ${({ theme }) => theme.colors.key}; + + &::placeholder { + color: ${({ theme }) => theme.colors.DG}; + ${({ theme }) => theme.fonts.Body2_M_14}; + } +`; + +export const BottomContentsWrapper = styled.div` + display: flex; + justify-content: space-between; + + width: calc(100% - 4rem); + height: 1.7rem; + margin: 0 2rem 2rem; +`; + +export const Date = styled.p` + ${({ theme }) => theme.fonts.E_Body2_R_14}; + color: ${({ theme }) => theme.colors.DG50}; +`; + +export const Counter = styled.p` + ${({ theme }) => theme.fonts.E_Body2_R_14}; + color: ${({ theme }) => theme.colors.DG}; +`; + +export const Notice = styled.p` + color: ${({ theme }) => theme.colors.key}; + + ${({ theme }) => theme.fonts.Caption1_R_12}; +`; diff --git a/src/LecueNote/components/WriteNote/index.tsx b/src/LecueNote/components/WriteNote/index.tsx new file mode 100644 index 00000000..53cdeaeb --- /dev/null +++ b/src/LecueNote/components/WriteNote/index.tsx @@ -0,0 +1,47 @@ +import GraphemeSplitter from 'grapheme-splitter'; +import { useEffect, useState } from 'react'; + +import { WriteNoteProps } from '../../type/lecueNoteType'; +import * as S from './WriteNote.style'; + +function WriteNote({ + imgFile, + isIconClicked, + clickedBgColor, + clickedTextColor, + contents, + handleChangeFn, +}: WriteNoteProps) { + const nickname = localStorage.getItem('nickname'); + + // 이모지 글자 수 세기 관련 라이브러리 + const split = new GraphemeSplitter(); + const today = new Date(); + const [dateArr, setDateArr] = useState([0, 0, 0]); + + useEffect(() => { + setDateArr([today.getFullYear(), today.getMonth() + 1, today.getDate()]); + }, [today.getDate()]); + + return ( + + + {nickname} + + + + {dateArr[0]}.{dateArr[1]}.{dateArr[2]} + + ({split.splitGraphemes(contents).length}/1000) + + + *욕설/비속어는 자동 필터링됩니다. + + ); +} + +export default WriteNote; diff --git a/src/LecueNote/constants/colorChart.ts b/src/LecueNote/constants/colorChart.ts new file mode 100644 index 00000000..4805a898 --- /dev/null +++ b/src/LecueNote/constants/colorChart.ts @@ -0,0 +1,20 @@ +export const CATEGORY = ['텍스트색', '배경색']; + +export const TEXT_COLOR_CHART = ['#191919', '#FFF']; + +export const BG_COLOR_CHART = [ + '#F48080', + '#7FB8FD', + '#F8E99A', + '#85CEAF', + '#B3CBE8', + '#E5E2CE', + '#FFF', + '#FE394C', + '#9852F9', + '#FFD600', + '#98ED4D', + '#FF71B3', + '#CCC', + '#191919', +]; diff --git a/src/LecueNote/hooks/useGetPresignedUrl.ts b/src/LecueNote/hooks/useGetPresignedUrl.ts new file mode 100644 index 00000000..573b8471 --- /dev/null +++ b/src/LecueNote/hooks/useGetPresignedUrl.ts @@ -0,0 +1,26 @@ +import { useQuery } from 'react-query'; +import { useNavigate } from 'react-router-dom'; + +import getPresignedUrl from '../api/getPresignedUrl'; + +const useGetPresignedUrl = ( + setPresignedUrl: React.Dispatch>, + setFileName: React.Dispatch>, +) => { + const navigate = useNavigate(); + + const { isLoading, data } = useQuery({ + queryKey: ['get-presigned-url'], + queryFn: () => getPresignedUrl(), + onError: () => navigate('/error'), + onSuccess: (data) => { + setPresignedUrl(data.data.url); + setFileName(data.data.fileName); + }, + refetchOnWindowFocus: false, + }); + + return { isLoading, data }; +}; + +export default useGetPresignedUrl; diff --git a/src/LecueNote/hooks/usePostLecueNote.ts b/src/LecueNote/hooks/usePostLecueNote.ts new file mode 100644 index 00000000..528c7f51 --- /dev/null +++ b/src/LecueNote/hooks/usePostLecueNote.ts @@ -0,0 +1,36 @@ +import { useMutation } from 'react-query'; +import { useNavigate } from 'react-router-dom'; + +import postLecueNote from '../api/postLecueNote'; +import { postLecueNoteProps } from '../type/lecueNoteType'; + +const usePostLecueNote = () => { + const navigate = useNavigate(); + const mutation = useMutation({ + mutationFn: ({ + contents, + color, + fileName, + bgColor, + isIconClicked, + bookId, + }: postLecueNoteProps) => { + return postLecueNote({ + contents, + color, + fileName, + bgColor, + isIconClicked, + bookId, + }); + }, + onError: () => navigate('/error'), + onSuccess: (data) => { + const bookUuid = data.data.data.bookUuid; + navigate(`/lecue-book/${bookUuid}`); + }, + }); + return mutation; +}; + +export default usePostLecueNote; diff --git a/src/LecueNote/hooks/usePutPresignedUrl.ts b/src/LecueNote/hooks/usePutPresignedUrl.ts new file mode 100644 index 00000000..116d6583 --- /dev/null +++ b/src/LecueNote/hooks/usePutPresignedUrl.ts @@ -0,0 +1,22 @@ +import { useMutation } from 'react-query'; +import { useNavigate } from 'react-router-dom'; + +import putPresignedUrl from '../api/putPresignedUrl'; +import { putPresignedUrlProps } from './../type/lecueNoteType'; + +const usePutPresignedUrl = () => { + const navigate = useNavigate(); + const mutation = useMutation({ + mutationFn: ({ + presignedUrl, + binaryFile, + fileType, + }: putPresignedUrlProps) => { + return putPresignedUrl({ presignedUrl, binaryFile, fileType }); + }, + onError: () => navigate('/error'), + }); + return mutation; +}; + +export default usePutPresignedUrl; diff --git a/src/LecueNote/page/LeceuNotePage/LecueNotePage.style.ts b/src/LecueNote/page/LeceuNotePage/LecueNotePage.style.ts new file mode 100644 index 00000000..d65164c2 --- /dev/null +++ b/src/LecueNote/page/LeceuNotePage/LecueNotePage.style.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + position: relative; + overflow: hidden; + + width: 100vw; + height: 100dvh; + padding: 0 1.7rem; +`; diff --git a/src/LecueNote/page/LeceuNotePage/index.tsx b/src/LecueNote/page/LeceuNotePage/index.tsx new file mode 100644 index 00000000..2d01a324 --- /dev/null +++ b/src/LecueNote/page/LeceuNotePage/index.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import Header from '../../../components/common/Header'; +import LoadingPage from '../../../components/common/LoadingPage'; +import CommonModal from '../../../components/common/Modal/CommonModal'; +import CreateNote from '../../components/CreateNote'; +import Footer from '../../components/Footer'; +import { + BG_COLOR_CHART, + CATEGORY, + TEXT_COLOR_CHART, +} from '../../constants/colorChart'; +import usePostLecueNote from '../../hooks/usePostLecueNote'; +import usePutPresignedUrl from '../../hooks/usePutPresignedUrl'; +import * as S from './LecueNotePage.style'; + +function LecueNotePage() { + const MAX_LENGTH = 1000; + const navigate = useNavigate(); + + const [contents, setContents] = useState(''); + const [imgFile, setImgFile] = useState(''); + const [imgFile2, setImgFile2] = useState(); + const [clickedCategory, setClickedCategory] = useState(CATEGORY[0]); + const [clickedTextColor, setClickedTextColor] = useState(TEXT_COLOR_CHART[0]); + const [clickedBgColor, setClickedBgColor] = useState(BG_COLOR_CHART[0]); + const [isIconClicked, setIsIconClicked] = useState(false); + const [fileName, setFileName] = useState(BG_COLOR_CHART[0]); + const [presignedUrl, setPresignedUrl] = useState(''); + const [file, setFile] = useState(); + const [modalOn, setModalOn] = useState(false); + const [escapeModal, setEscapeModal] = useState(false); + + const putMutation = usePutPresignedUrl(); + const postMutation = usePostLecueNote(); + const location = useLocation(); + + const { bookId } = location.state || {}; + + const handleClickCategory = ( + e: React.MouseEvent, + ) => { + setClickedCategory(e.currentTarget.innerHTML); + }; + + const handleChangeContents = (e: React.ChangeEvent) => { + setContents(e.target.value); + if (e.target.value.length > MAX_LENGTH) { + setContents((e.target.value = e.target.value.substring(0, MAX_LENGTH))); + alert('1000자 내로 작성해주세요.'); + } + }; + + const handleClickedColorBtn = ( + e: React.MouseEvent, + ) => { + if (clickedCategory === '텍스트색') { + setClickedTextColor(e.currentTarget.id); + } else { + setClickedBgColor(e.currentTarget.id); + setIsIconClicked(false); + } + }; + + const handleClickedIcon = () => { + setIsIconClicked(true); + }; + + const handleClickCompleteModal = async () => { + if (imgFile2) { + if (imgFile2.result && file) { + putMutation.mutate({ + presignedUrl: presignedUrl, + binaryFile: imgFile2.result, + fileType: file.type, + }); + } + } + postMutation.mutate({ + contents: contents, + color: clickedTextColor, + fileName: fileName, + bgColor: clickedBgColor, + isIconClicked: isIconClicked, + bookId: bookId, + }); + }; + + return putMutation.isLoading || postMutation.isLoading ? ( + + ) : ( + + {modalOn && ( + + )} + + {escapeModal && ( + navigate(-1)} + category="note_escape" + setModalOn={setEscapeModal} + /> + )} +
setEscapeModal(true)} + /> + setImgFile(file)} + setFileName={setFileName} + handleChangeFn={handleChangeContents} + handleClickCategory={handleClickCategory} + handleClickedColorBtn={handleClickedColorBtn} + handleClickedIcon={handleClickedIcon} + setPresignedUrl={setPresignedUrl} + binaryImage={(file) => setImgFile2(file)} + selectedFile={(file) => setFile(file)} + /> +