diff --git a/keyword/chapter08/keyword08.md b/keyword/chapter08/keyword08.md new file mode 100644 index 00000000..0b160ada --- /dev/null +++ b/keyword/chapter08/keyword08.md @@ -0,0 +1,495 @@ +- **`Debounce`** 구글링 후 개념 정리 및 코드 작성해보기 🍠 + - **`Debounce`** 개념 정리 🍠 + + + ### 사용 예시 + + 검색창에 글자를 입력할 때마다 API 요청을 보내면 비효율적이다. + + ``` + ㄱ 입력 → API 요청 + 기 입력 → API 요청 + 김 입력 → API 요청 + 김철 입력 → API 요청 + ``` + + Debounce를 적용하면 사용자가 입력을 멈춘 뒤 한 번만 요청한다. + + ``` + 김철수 입력 완료 + 0.5초 동안 추가 입력 없음 + → API 요청 1번 실행 + ``` + + ### 핵심 특징 + + - 연속된 이벤트 중 **마지막 이벤트만 처리** + - 불필요한 API 요청, 렌더링, 함수 실행을 줄일 수 있음 + - 검색창, 자동완성, 창 크기 조절, 버튼 중복 클릭 방지 등에 사용됨 + + - **`Debounce`** 코드 작성 🍠 + + ### **기본 코드** + + ```tsx + function debounce(callback, delay) { + let timerId; + + return function (...args) { + clearTimeout(timerId); + + timerId = setTimeout(() => { + callback(...args); + }, delay); + }; + } + ``` + + - `function debounce(callback, delay)` + ```tsx + functiondebounce(callback,delay) { + ``` + `debounce`라는 함수를 만들고 여기서 매개변수는 2개! + - `callback` → 나중에 실제로 실행하고 싶은 함수 + 예시) 검색 함수 + ```tsx + functionsearch(keyword) { + console.log(keyword); + } + ``` + - `delay` → 몇 초 기다렸다가 실행할지 정하는 시간 값 + 단위는 **ms, 밀리초** + ``` + 500 -> 0.5초 + ``` + - `let timerId;` + - `lettimerId` → 타이머 번호를 저장하는 변수 + `setTimeout`을 실행하면 자바스크립트가 타이머 ID를 하나 줌 + + ```tsx + timerId=setTimeout(...) + // 나중에 타이머를 취소하기 위해 위와 같이 저장함 + + clearTimeout(timerId); + // 이렇게 취소 가능 + ``` + + - `return function (...args)` + `debounce` 함수는 바로 `callback`을 실행하는 게 아니라, **새로운 함수 하나를 만들어서 반환함** + **예시** + + ```tsx + constdebouncedSearch=debounce(search,500); + + // 이렇게 하면 debouncedSearch 안에는 이 함수가 들어가게 돼. + + function (...args) { + clearTimeout(timerId); + + timerId=setTimeout(() => { + callback(...args); + },delay); + } + + // 즉, 사용자가 실제로 호출하는 것은 debounce가 아니라 debounce가 만들어준 함수임 + ``` + + - `...args` → 함수에 들어오는 모든 인자를 배열처럼 받아주는 문법 + **예시** + + ```tsx + debouncedSearch('apple'); + + // 그러면: + args = ['apple']; + // 만약 이렇게 부르면: + debouncedSearch('apple', 1, true); + // 그러면: + args = ['apple', 1, true]; + // 이렇게 저장돼. + // 나중에 원래 함수에 그대로 넘기려고 쓰는 거야. + callback(...args); + ``` + + - `clearTimeout(timerId);` → 기존에 예약되어 있던 타이머 취소하는 코드 + **Debounce**의 핵심 + **예시** + + ```tsx + 검색창에 이렇게 입력한다고 할 때 + + ``` + + a 입력 + ap 입력 + app 입력 + apple 입력 + + ``` + 입력할 때마다 함수가 호출됨 + + 만약, 매번 이전 타이머를 취소한다면 + 마지막에는 apple만 남음 + + ``` + + a 입력 → 500ms 뒤 실행 예약 + ap 입력 → 기존 a 예약 취소, 새로 예약 + app 입력 → 기존 ap 예약 취소, 새로 예약 + apple 입력 → 기존 app 예약 취소, 새로 예약 + + ``` + + ``` + + - `setTimeout(() => { ... }, delay)` + + ```tsx + timerId=setTimeout(() => { + callback(...args); + },delay); + + // 새로운 타이머 등록 코드 + + // 뜻 + delay 시간만큼 기다린 뒤 callback 함수를 실행해라 + + // 예시: delay가 500이면 + 500ms 뒤에 callback 실행 + ``` + + - `callback(...args);` → 실제 함수가 실행되는 부분 + **예시** + + ```tsx + constdebouncedSearch=debounce(search,500); + + debouncedSearch('apple'); + + // 500ms 뒤에 실제로는 + search('apple'); -> 이렇게 실행 됨 + ``` + + + + - 전체 흐름 예시 + + ```tsx + functionsearch(keyword) { + console.log('검색어:',keyword); + } + + constdebouncedSearch=debounce(search,500); + + debouncedSearch('a'); + debouncedSearch('ap'); + debouncedSearch('app'); + debouncedSearch('apple'); + ``` + + **실행 흐름** + + ``` + 1. 'a' 입력 → 500ms 뒤 search('a') 예약 + 2. 'ap' 입력 → 이전 예약 취소, search('ap') 예약 + 3. 'app' 입력 → 이전 예약 취소, search('app') 예약 + 4. 'apple' 입력 → 이전 예약 취소, search('apple') 예약 + 5. 500ms 동안 추가 입력 없음 + 6. search('apple') 실행 + ``` + + **결과** + + ``` + 검색어:apple + ``` + + ### **사용 예시** + + ```tsx + **const searchInput = document.querySelector('#search'); + + function handleSearch(event) { + console.log('검색어:', event.target.value); + } + + const debouncedSearch = debounce(handleSearch, 500); + + searchInput.addEventListener('input', debouncedSearch);** + ``` + + ### **코드 흐름** + + ```tsx + **1. 사용자가 입력한다. + 2. 기존 타이머가 있으면 취소한다. + 3. 새로운 타이머를 설정한다. + 4. 500ms 안에 또 입력하면 다시 취소된다. + 5. 500ms 동안 입력이 없으면 callback이 실행된다.** + ``` + + ### **React 예시** + + ```tsx + import { useEffect, useState } from 'react'; + + function SearchInput() { + const [keyword, setKeyword] = useState(''); + const [debouncedKeyword, setDebouncedKeyword] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedKeyword(keyword); + }, 500); + + return () => { + clearTimeout(timer); + }; + }, [keyword]); + + useEffect(() => { + if (debouncedKeyword) { + console.log('API 요청:', debouncedKeyword); + } + }, [debouncedKeyword]); + + return ( + setKeyword(e.target.value)} + placeholder="검색어를 입력하세요" + /> + ); + } + ``` + +- **`Throttling`** 구글링 후 개념 정리 및 코드 작성해보기 🍠 + - **`Throttling`** 개념 정리 🍠 + + + ## **Debounce와의 차이점** + + ### Debounce + + ``` + 입력이 끝날 때까지 기다렸다가 마지막 1번 실행 + + 예시 + aaaaaaa 입력 중... + 입력 멈춤 + → 실행 1번 + ``` + + ### Throttle + + ``` + 입력이 계속되어도 일정 간격마다 실행 + + 예시 + 스크롤 중... + 0.5초마다 실행 + 0.5초마다 실행 + 0.5초마다 실행 + ``` + + ## 사용 예시 + + 1. 스크롤 이벤트 + + ``` + 스크롤은 엄청 많이 발생함 + + 스크롤 1px + 스크롤 2px + 스크롤 3px + ... + -> 이를 매번 실행하면 성능 떨어짐 + 그래서 throttle을 사용하여 0.3초마다 한 번씩만 실행하게 만듦 + ``` + + 2. 게임 이동 + + ``` + 키를 누르고 있으면 이벤트 발생 + + throttle 사용하면 '0.1초 마다 이동'처럼 제어 가능! + ``` + + + ## 핵심 특징 + + - 일정 시간 간격마다 실행 + - 이벤트가 계속 발생해도 주기적으로 실행 가능 + - 스크롤, 마우스 이동, 게임 입력 등에 많이 사용 + - 과도한 이벤트 호출을 줄여 성능 최적화 가능 + + - **`Throttling`** 코드 작성 🍠 + + ### 기본 코드 + + ```tsx + functionthrottle(callback,delay) { + letisWaiting=false; + + returnfunction (...args) { + if (isWaiting)return; + + callback(...args); + + isWaiting=true; + + setTimeout(() => { + isWaiting=false; + },delay); + }; + } + ``` + + - **`function throttle(callback, delay)`** + + ```tsx + function throttle(callback, delay) { + // Throttle 함수를 만드는 부분 + ``` + + - `callback` → 매개 변수 + - 실제로 실행할 함수 + ```tsx + // 예시 + function movePlayer() { + console.log('이동'); + } + ``` + - `delay` → 몇 ms마다 실행할지 정하는 시간 + + *** + + - **`let isWaiting = false;` →** 지금 대기 중인지 확인하는 변수 + - 초기값 `false -> 아직 실행 가능 상태` 라는 뜻 + - **`return function (...args)** {` + + + - **`if (isWaiting) return;`** + + + **예시** + + ```tsx + 첫 실행 → 가능0.1초 뒤 실행 시도 → 막힘0.2초 뒤 실행 시도 → 막힘0.3초 지나면 다시 가능 + **** + ``` + + - **`callback(...args);` →** 실제 함수 실행 + + ```tsx + **예시** + + throttledScroll('현재 위치'); + 이면: + handleScroll('현재 위치'); + 실행됨. + ``` + + - **`isWaiting = true;` →** 실행 금지 상태로 바꿈 + + + - **`setTimeout(() => { ... }, delay)`** + + + **예시** + + ```tsx + delay = 1000 이면 + 1초 뒤에 다시 실행 허용 + **** + ``` + + - **전체 흐름 예시** + + ```tsx + function shoot() { console.log('발사!');} + const throttledShoot = throttle(shoot, 1000); + + // 사용자가 버튼을 엄청 빠르게 누른다고 할 때 + throttledShoot(); + throttledShoot(); + throttledShoot(); + throttledShoot(); + + **// 실행 흐름 + + // 첫 번째 클릭** + isWaiting = false + → 실행 가능 + → "발사!" + → isWaiting = true + + **// 두 번째 클릭** + isWaiting = true + → return + → 무시됨 + + **// 세 번째 클릭** + 여전히 true + → 무시 + + **// 1초 후** + isWaiting = false + 다시 실행 가능 상태 됨 + ``` + + - **Debounce vs Throttle 핵심 비교** + | 구분 | Debounce | Throttle | + | ------------ | ---------------- | ------------------ | + | 실행 방식 | 마지막 한 번만 | 일정 간격마다 | + | 연속 입력 시 | 계속 취소 | 일정 시간마다 실행 | + | 대표 사용 | 검색창 | 스크롤 | + | 핵심 목적 | 마지막 행동 감지 | 실행 횟수 제한 | diff --git a/mission/chapter08/mission_1/src/App.css b/mission/chapter08/mission_1/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/mission/chapter08/mission_1/src/App.tsx b/mission/chapter08/mission_1/src/App.tsx new file mode 100644 index 00000000..9978d8bd --- /dev/null +++ b/mission/chapter08/mission_1/src/App.tsx @@ -0,0 +1,63 @@ +import './App.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import HomeLayout from './layouts/HomeLayout'; +import NotFound from './pages/NotFoundPage'; +import LoginPage from './pages/LoginPage'; +import HomePage from './pages/HomePage'; +import SignupPage from './pages/SignupPage'; +import MyPage from './pages/MyPage'; +import LpDetailPage from './pages/LpDetailPage'; +import ProtectedRoute from './components/ProtectedRoute'; +import GoogleCallbackPage from './pages/GoogleCallBackPage'; +import { AuthProvider } from './context/AuthContext'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { + path: 'mypage', + element: ( + + + + ), + }, + { + path: 'lps/:lpId', // LP 상세 페이지 + element: ( + + + + ), + }, + ], + }, + { + path: '/login', + element: , + errorElement: , + }, + { + path: '/signup', + element: , + errorElement: , + }, + { + path: '/v1/auth/google/callback', + element: , + }, +]); + +function App() { + return ( + + + + ); +} + +export default App; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/api/auth.ts b/mission/chapter08/mission_1/src/api/auth.ts new file mode 100644 index 00000000..44d9b927 --- /dev/null +++ b/mission/chapter08/mission_1/src/api/auth.ts @@ -0,0 +1,13 @@ +import axiosInstance from './axios'; + +// 로그인 +export const signin = async (email: string, password: string) => { + const response = await axiosInstance.post('/auth/signin', { email, password }); + return response.data; +}; + +// 로그아웃 +export const signout = async () => { + const response = await axiosInstance.post('/auth/signout'); + return response.data; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/api/axios.ts b/mission/chapter08/mission_1/src/api/axios.ts new file mode 100644 index 00000000..41cdc580 --- /dev/null +++ b/mission/chapter08/mission_1/src/api/axios.ts @@ -0,0 +1,94 @@ +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_URL, +}); + +// 토큰 갱신 중복 방지용 변수 +let isRefreshing = false; // 현재 갱신 중인지 +let refreshSubscribers: ((token: string) => void)[] = []; // 대기 중인 요청들 + +//갱신 완료 후 대기 중인 요청들한테 새 토큰 전달 +const onRefreshed = (token: string) => { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +}; + +// 대기열에 요청 추가 +const addRefreshSubscriber = (callback: (token: string) => void) => { + refreshSubscribers.push(callback); +}; + +// 요청 인터셉터 +axiosInstance.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// 응답 인터셉터 +axiosInstance.interceptors.response.use( + // 성공 응답은 그냥 통과 + (response) => response, + + // 실패 응답(에러)은 여기서 처리 + async (error) => { + const originalRequest = error.config; + + // 401 = 토큰 만료 + if (error.response?.status === 401 && !originalRequest._retry) { + // 재시도 표시 → 한 번만 재시도하도록 기록 + originalRequest._retry = true; + + // 이미 갱신 중이면 대기열에 추가 + if (isRefreshing) { + return new Promise((resolve) => { + addRefreshSubscriber((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(axiosInstance(originalRequest)); + }); + }); + } + + isRefreshing = true; // 갱신 시작 + + try { + const refreshToken = localStorage.getItem('refreshToken'); + + const response = await axios.post( + `${import.meta.env.VITE_API_URL}/auth/refresh`, // env로 변경 + { + refresh: refreshToken, + }, + ); + + // 새로 받은 accessToken 저장 + const newAccessToken = response.data.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + + isRefreshing = false; + onRefreshed(newAccessToken); // 대기 중인 요청들 처리 + + // 실패했던 요청 헤더도 새 토큰으로 교체 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + // 실패했던 요청 재시도 + return axiosInstance(originalRequest); + } catch (refreshError) { + // refreshToken도 만료 → 강제 로그아웃 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + +export default axiosInstance; diff --git a/mission/chapter08/mission_1/src/api/lp.ts b/mission/chapter08/mission_1/src/api/lp.ts new file mode 100644 index 00000000..8f79033b --- /dev/null +++ b/mission/chapter08/mission_1/src/api/lp.ts @@ -0,0 +1,98 @@ +import axiosInstance from './axios'; +import type { CommentListResponse, LpDetailResponse, LpListResponse } from '../types/lp'; + +// LP 목록 조회 +export const getLps = async ( + order: 'asc' | 'desc' = 'desc', + cursor: number = 0, + search: string = '', // 추가 +): Promise => { + const response = await axiosInstance.get('/lps', { + params: { + order, + limit: 10, + cursor, + // 빈 문자열이면 파라미터 자체를 보내지 않음 + ...(search.trim() && { search: search.trim() }), + }, + }); + return response.data; +}; + +// LP 상세 조회 +export const getLpDetail = async (lpId: number): Promise => { + const response = await axiosInstance.get(`/lps/${lpId}`); + return response.data; +}; + +// LP 생성 +export const createLp = async (data: { + title: string; + content: string; + thumbnail: string; + tags: string[]; + published: boolean; +}) => { + const response = await axiosInstance.post('/lps', data); + return response.data; +}; + +// LP 수정 +export const updateLp = async (lpId: number, data: { + title?: string; + content?: string; + thumbnail?: string; + tags?: string[]; + published?: boolean; +}) => { + const response = await axiosInstance.patch(`/lps/${lpId}`, data); + return response.data; +}; + +// LP 좋아요 +export const likeLp = async (lpId: number) => { + const response = await axiosInstance.post(`/lps/${lpId}/likes`); + return response.data; +}; + +// LP 좋아요 취소 +export const unlikeLp = async (lpId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}/likes`); + return response.data; +}; + +// LP 삭제 +export const deleteLp = async (lpId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}`); + return response.data; +}; + +// 댓글 목록 조회 +export const getComments = async ( + lpId: number, + order: 'asc' | 'desc' = 'asc', + cursor: number = 0, +): Promise => { + const response = await axiosInstance.get(`/lps/${lpId}/comments`, { + params: { order, limit: 10, cursor }, + }); + return response.data; +}; + +// 댓글 작성 +export const createComment = async (lpId: number, content: string) => { + const response = await axiosInstance.post(`/lps/${lpId}/comments`, { content }); + return response.data; +}; + +// 댓글 수정 +export const updateComment = async (lpId: number, commentId: number, content: string) => { + const response = await axiosInstance.patch(`/lps/${lpId}/comments/${commentId}`, { content }); + return response.data; +}; + +// 댓글 삭제 +export const deleteComment = async (lpId: number, commentId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}/comments/${commentId}`); + return response.data; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/api/upload.ts b/mission/chapter08/mission_1/src/api/upload.ts new file mode 100644 index 00000000..555268f2 --- /dev/null +++ b/mission/chapter08/mission_1/src/api/upload.ts @@ -0,0 +1,11 @@ +import axiosInstance from './axios'; + +export const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await axiosInstance.post('/uploads', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + // 응답에서 imageUrl 반환 + return response.data.data.imageUrl; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/api/user.ts b/mission/chapter08/mission_1/src/api/user.ts new file mode 100644 index 00000000..37394720 --- /dev/null +++ b/mission/chapter08/mission_1/src/api/user.ts @@ -0,0 +1,23 @@ +import axiosInstance from './axios'; + +// 내 프로필 조회 +export const getMyProfile = async () => { + const response = await axiosInstance.get('/users/me'); + return response.data; +}; + +// 프로필 수정 +export const updateMyProfile = async (data: { + name: string; + bio: string; + avatar: string; +}) => { + const response = await axiosInstance.patch('/users', data); + return response.data; +}; + +// 회원 탈퇴 +export const deleteMyAccount = async () => { + const response = await axiosInstance.delete('/users'); + return response.data; +}; diff --git a/mission/chapter08/mission_1/src/assets/hero.png b/mission/chapter08/mission_1/src/assets/hero.png new file mode 100644 index 00000000..cc51a3d2 Binary files /dev/null and b/mission/chapter08/mission_1/src/assets/hero.png differ diff --git a/mission/chapter08/mission_1/src/assets/react.svg b/mission/chapter08/mission_1/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/mission/chapter08/mission_1/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/assets/vite.svg b/mission/chapter08/mission_1/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/mission/chapter08/mission_1/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/mission/chapter08/mission_1/src/components/Button.tsx b/mission/chapter08/mission_1/src/components/Button.tsx new file mode 100644 index 00000000..4601dc04 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/Button.tsx @@ -0,0 +1,43 @@ +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'google'; // 버튼 종류 + type?: 'button' | 'submit'; + className?: string; +} + +const Button = ({ + children, + onClick, + disabled = false, + variant = 'primary', + type = 'button', + className = '', +}: ButtonProps) => { + // variant에 따라 스타일 다르게 + const variantStyles = { + // 파란 버튼 (로그인, 다음, 회원가입 등) + primary: + 'w-full bg-blue-300 text-white py-3 rounded-md text-lg font-medium hover:bg-blue-500 transition-colors cursor-pointer disabled:bg-gray-300', + // 테두리 버튼 (로그아웃 등) + secondary: + 'px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 transition-colors cursor-pointer', + // 구글 버튼 + google: + 'w-full flex items-center justify-center gap-2 border border-gray-300 py-3 rounded-md text-lg font-medium hover:bg-gray-100 transition-colors cursor-pointer', + }; + + return ( + + ); +}; + +export default Button; diff --git a/mission/chapter08/mission_1/src/components/CommentSkeleton.tsx b/mission/chapter08/mission_1/src/components/CommentSkeleton.tsx new file mode 100644 index 00000000..60c1dec9 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/CommentSkeleton.tsx @@ -0,0 +1,14 @@ +const CommentSkeleton = () => { + return ( +
+
+
+
+
+
+
+
+ ); +}; + +export default CommentSkeleton; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/components/CreateLpModal.tsx b/mission/chapter08/mission_1/src/components/CreateLpModal.tsx new file mode 100644 index 00000000..f317ea07 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/CreateLpModal.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { createLp } from '../api/lp'; +import { uploadImage } from '../api/upload'; + +interface CreateLpModalProps { + onClose: () => void; +} + +const CreateLpModal = ({ onClose }: CreateLpModalProps) => { + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState([]); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + + const { mutate: createLpMutate, isPending } = useMutation({ + mutationFn: async () => { + let thumbnailUrl = ''; + + if (thumbnailFile) { + thumbnailUrl = await uploadImage(thumbnailFile); + } + + return createLp({ + title, + content, + thumbnail: thumbnailUrl, + tags, + published: true, + }); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + onClose(); + }, + + onError: (error) => { + console.error('LP 생성 실패:', error); + alert('LP 생성에 실패했습니다.'); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setThumbnailFile(file); + + setPreviewUrl((prev) => { + if (prev?.startsWith('blob:')) { + URL.revokeObjectURL(prev); + } + + return URL.createObjectURL(file); + }); + }; + + useEffect(() => { + return () => { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + const handleAddTag = () => { + const trimmed = tagInput.trim(); + + if (!trimmed || tags.includes(trimmed)) return; + + setTags((prev) => [...prev, trimmed]); + setTagInput(''); + }; + + const handleRemoveTag = (tag: string) => { + setTags((prev) => prev.filter((t) => t !== tag)); + }; + + const handleTagKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleSubmit = () => { + if (!title.trim()) { + alert('LP 이름을 입력해주세요.'); + return; + } + + createLpMutate(); + }; + + const handleBackdropClick = () => { + onClose(); + }; + + return ( +
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + +
fileInputRef.current?.click()} + > + {previewUrl ? ( + preview + ) : ( +
+ + + + + + + + + + + 클릭해서 사진 추가 + +
+ )} +
+ + + + setTitle(e.target.value)} + placeholder="LP Name" + className="bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + + setContent(e.target.value)} + placeholder="LP Content" + className="bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + +
+ setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + placeholder="LP Tag" + className="flex-1 bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + + +
+ + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + + + ))} +
+ )} + + +
+
+ ); +}; + +export default CreateLpModal; diff --git a/mission/chapter08/mission_1/src/components/DeleteConfirmModal.tsx b/mission/chapter08/mission_1/src/components/DeleteConfirmModal.tsx new file mode 100644 index 00000000..0fa52634 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/DeleteConfirmModal.tsx @@ -0,0 +1,33 @@ +interface DeleteConfirmModalProps { + onConfirm: () => void; + onCancel: () => void; + isPending?: boolean; +} + +const DeleteConfirmModal = ({ onConfirm, onCancel, isPending }: DeleteConfirmModalProps) => { + return ( +
+
+

정말 탈퇴하시겠습니까?

+

탈퇴 후에는 모든 데이터가 삭제되며 복구할 수 없습니다.

+
+ + +
+
+
+ ); +}; + +export default DeleteConfirmModal; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/components/Input.tsx b/mission/chapter08/mission_1/src/components/Input.tsx new file mode 100644 index 00000000..05686388 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/Input.tsx @@ -0,0 +1,32 @@ +import { forwardRef } from 'react'; + +interface InputProps { + type?: string; + placeholder?: string; + hasError?: boolean; + errorMessage?: string; +} + +// forwardRef → react-hook-form의 register가 ref를 전달할 수 있게 해줌 +const Input = forwardRef( + ({ type = 'text', placeholder, hasError = false, errorMessage, ...rest }, ref) => { + return ( +
+ + {/* 에러 메시지 있으면 자동으로 표시 */} + {hasError && errorMessage && ( +
{errorMessage}
+ )} +
+ ); + } +); + +export default Input; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/components/LpCard.tsx b/mission/chapter08/mission_1/src/components/LpCard.tsx new file mode 100644 index 00000000..58f4f7c1 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/LpCard.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom'; +import type { Lp } from '../types/lp'; + +interface LpCardProps { + lp: Lp; +} + +const LpCard = ({ lp }: LpCardProps) => { + const navigate = useNavigate(); + + return ( +
navigate(`/lps/${lp.id}`)} + className="relative group cursor-pointer rounded-lg overflow-hidden aspect-square bg-gray-200" + > + {/* 썸네일 */} + {lp.title} + + {/* 호버 시 오버레이 */} +
+

{lp.title}

+
+ + {new Date(lp.createdAt).toLocaleDateString('ko-KR')} + + + ❤️ {lp.likes?.length ?? 0} + +
+ {/* 태그 */} +
+ {lp.tags?.slice(0, 3).map((tag) => ( + + #{tag.name} + + ))} +
+
+
+ ); +}; + +export default LpCard; diff --git a/mission/chapter08/mission_1/src/components/LpCardSkeleton.tsx b/mission/chapter08/mission_1/src/components/LpCardSkeleton.tsx new file mode 100644 index 00000000..0bd3ee3d --- /dev/null +++ b/mission/chapter08/mission_1/src/components/LpCardSkeleton.tsx @@ -0,0 +1,7 @@ +const LpCardSkeleton = () => { + return ( +
+ ); +}; + +export default LpCardSkeleton; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/components/Navbar.tsx b/mission/chapter08/mission_1/src/components/Navbar.tsx new file mode 100644 index 00000000..89dd632d --- /dev/null +++ b/mission/chapter08/mission_1/src/components/Navbar.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; + +interface NavbarProps { + onMenuClick: () => void; +} + +const Navbar = ({ onMenuClick }: NavbarProps) => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + // ── 로그아웃 mutation ── + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { + await logout(); + }, + onSuccess: () => { + navigate('/'); + }, + }); + + return ( + + ); +}; + +export default Navbar; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/components/ProtectedRoute.tsx b/mission/chapter08/mission_1/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..77d974dd --- /dev/null +++ b/mission/chapter08/mission_1/src/components/ProtectedRoute.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth(); + const location = useLocation(); // 현재 경로 저장 + const [showModal, setShowModal] = useState(true); + + if (!user) { + // 모달 닫고 로그인으로 + if (!showModal) { + return ; + // state={{ from: location }} → 원래 가려던 경로 저장! + } + + return ( + // 경고 모달 +
+
+

로그인이 필요합니다

+

이 페이지는 로그인 후 이용할 수 있습니다.

+ +
+
+ ); + } + + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/components/Sidebar.tsx b/mission/chapter08/mission_1/src/components/Sidebar.tsx new file mode 100644 index 00000000..f15375c1 --- /dev/null +++ b/mission/chapter08/mission_1/src/components/Sidebar.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { deleteMyAccount } from '../api/user'; +import DeleteConfirmModal from './DeleteConfirmModal'; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; + isStatic?: boolean; +} + +const Sidebar = ({ isOpen, onClose, isStatic = false }: SidebarProps) => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + const [search, setSearch] = useState(''); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // ── 로그아웃 mutation ── + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { + await logout(); + }, + onSuccess: () => { + if (!isStatic) onClose(); + navigate('/'); + }, + }); + + // ── 탈퇴 mutation ── + const { mutate: deleteAccountMutate, isPending: isDeleting } = useMutation({ + mutationFn: deleteMyAccount, + onSuccess: () => { + logout(); + navigate('/login'); + }, + onError: () => { + alert('회원 탈퇴에 실패했습니다.'); + }, + }); + + const handleNavigate = (path: string) => { + navigate(path); + if (!isStatic) onClose(); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (search.trim()) { + navigate(`/?search=${search}`); + if (!isStatic) onClose(); + } + }; + + const content = ( +
+ {/* 검색창 */} +
+ setSearch(e.target.value)} + placeholder="검색..." + className="border border-gray-600 bg-transparent rounded-md px-3 py-2 text-sm outline-none focus:border-pink-400 text-white" + /> + +
+ +
+ + {/* 메뉴 */} + + + {/* 하단 로그인/로그아웃 */} +
+ {user ? ( +
+ {user.nickname}님 + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* 탈퇴 확인 모달 */} + {showDeleteModal && ( + deleteAccountMutate()} + onCancel={() => setShowDeleteModal(false)} + isPending={isDeleting} + /> + )} +
+ ); + + if (isStatic) { + return
{content}
; + } + + return ( + <> + {isOpen && ( +
+ )} + + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/context/AuthContext.tsx b/mission/chapter08/mission_1/src/context/AuthContext.tsx new file mode 100644 index 00000000..d85baa05 --- /dev/null +++ b/mission/chapter08/mission_1/src/context/AuthContext.tsx @@ -0,0 +1,102 @@ +import { createContext, useContext, useState } from 'react'; +import axiosInstance from '../api/axios'; + +interface User { + id: number; + email: string; + nickname: string; +} + +interface AuthContextType { + user: User | null; + login: (user: User, accessToken: string, refreshToken: string) => void; + logout: () => void; + updateNickname: (nickname: string) => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [user, setUser] = useState(() => { + try { + const stored = localStorage.getItem('user'); + + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.error('유저 정보 파싱 실패:', error); + + localStorage.removeItem('user'); + + return null; + } + }); + + const login = ( + userData: User, + accessToken: string, + refreshToken: string + ) => { + setUser(userData); + + localStorage.setItem('user', JSON.stringify(userData)); + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + }; + + const logout = async () => { + try { + await axiosInstance.post('/auth/signout'); + } catch (error) { + console.error('로그아웃 실패:', error); + } finally { + setUser(null); + + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }; + + // 닉네임만 즉시 업데이트 + const updateNickname = (nickname: string) => { + setUser((prev) => { + if (!prev) return prev; + + const updated = { + ...prev, + nickname, + }; + + localStorage.setItem('user', JSON.stringify(updated)); + + return updated; + }); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('AuthProvider 밖에서 사용 불가!'); + } + + return context; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/hooks/useDebounce.ts b/mission/chapter08/mission_1/src/hooks/useDebounce.ts new file mode 100644 index 00000000..f03d70c9 --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/hooks/useInfiniteComments.ts b/mission/chapter08/mission_1/src/hooks/useInfiniteComments.ts new file mode 100644 index 00000000..fc05cb01 --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useInfiniteComments.ts @@ -0,0 +1,18 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getComments } from '../api/lp'; +import type { CommentListResponse } from '../types/lp'; + +const useInfiniteComments = (lpId: number, order: 'asc' | 'desc') => { + return useInfiniteQuery({ + queryKey: ['lpComments', lpId, order], + queryFn: ({ pageParam }) => getComments(lpId, order, pageParam as number), + getNextPageParam: (lastPage) => { + return lastPage.data.hasNext ? lastPage.data.nextCursor : undefined; + }, + initialPageParam: 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export default useInfiniteComments; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/hooks/useInfiniteLps.ts b/mission/chapter08/mission_1/src/hooks/useInfiniteLps.ts new file mode 100644 index 00000000..5a12c1cb --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useInfiniteLps.ts @@ -0,0 +1,25 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getLps } from '../api/lp'; + +const useInfiniteLps = (order: 'asc' | 'desc', search: string = '') => { + const trimmedSearch = search.trim(); + + return useInfiniteQuery({ + queryKey: ['search', order, trimmedSearch], + + queryFn: ({ pageParam = 0 }) => getLps(order, pageParam, trimmedSearch), + + getNextPageParam: (lastPage) => { + return lastPage.data.hasNext ? lastPage.data.nextCursor : undefined; + }, + + initialPageParam: 0, + + enabled: true, + + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export default useInfiniteLps; diff --git a/mission/chapter08/mission_1/src/hooks/useLocalStorage.ts b/mission/chapter08/mission_1/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..c6a3ad1b --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +function useLocalStorage(key: string, initialValue: T) { + // 로컬스토리지에서 값을 가져오는 함수 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + // 저장된 값이 있으면 파싱해서 반환, 없으면 초기값 반환 + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // 값을 저장하는 함수 + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export default useLocalStorage; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/hooks/useLpDetail.ts b/mission/chapter08/mission_1/src/hooks/useLpDetail.ts new file mode 100644 index 00000000..300c788f --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useLpDetail.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLpDetail } from '../api/lp'; + +const useLpDetail = (lpId: number) => { + return useQuery({ + queryKey: ['lp', lpId], // lpId 포함 + queryFn: () => getLpDetail(lpId), + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + }); +}; + +export default useLpDetail; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/hooks/useLps.ts b/mission/chapter08/mission_1/src/hooks/useLps.ts new file mode 100644 index 00000000..731431b6 --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useLps.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLps } from '../api/lp'; + +const useLps = (order: 'asc' | 'desc') => { + return useQuery({ + queryKey: ['lps', order], // order 바뀌면 자동으로 리패치 + queryFn: () => getLps(order), + staleTime: 1000 * 60 * 5, // 5분 동안 캐시 유지 + gcTime: 1000 * 60 * 10, // 10분 후 캐시 삭제 + }); +}; + +export default useLps; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/hooks/useMyPage.ts b/mission/chapter08/mission_1/src/hooks/useMyPage.ts new file mode 100644 index 00000000..d48d78c1 --- /dev/null +++ b/mission/chapter08/mission_1/src/hooks/useMyPage.ts @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { updateMyProfile, deleteMyAccount } from '../api/user'; + +export const useMyPage = () => { + const navigate = useNavigate(); + const { user, logout, updateNickname } = useAuth(); + const queryClient = useQueryClient(); + + const { mutate: updateProfileMutate, isPending: isUpdating } = useMutation({ + mutationFn: (profileData: { name: string; bio: string; avatar: string }) => + updateMyProfile(profileData), + onMutate: async (profileData) => { + const previousNickname = user?.nickname; + updateNickname(profileData.name); + return { previousNickname }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['myProfile'] }); + alert('프로필이 수정되었습니다.'); + }, + onError: (_error, _variables, context) => { + if (context?.previousNickname) updateNickname(context.previousNickname); + alert('프로필 수정에 실패했습니다.'); + }, + }); + + const { mutate: deleteAccountMutate, isPending: isDeleting } = useMutation({ + mutationFn: deleteMyAccount, + onSuccess: () => { logout(); navigate('/login'); }, + onError: () => { alert('회원 탈퇴에 실패했습니다.'); }, + }); + + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { await logout(); }, + onSuccess: () => { navigate('/'); }, + }); + + return { + user, + isUpdating, + isDeleting, + updateProfileMutate, + deleteAccountMutate, + logoutMutate, + }; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/index.css b/mission/chapter08/mission_1/src/index.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/mission/chapter08/mission_1/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter08/mission_1/src/layouts/HomeLayout.tsx b/mission/chapter08/mission_1/src/layouts/HomeLayout.tsx new file mode 100644 index 00000000..0eb4c6bf --- /dev/null +++ b/mission/chapter08/mission_1/src/layouts/HomeLayout.tsx @@ -0,0 +1,35 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import Sidebar from '../components/Sidebar'; +import { useState } from 'react'; + +const HomeLayout = () => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + return ( +
+ setIsSidebarOpen(true)} /> +
+ {/* 사이드바 - md 이상에서는 항상 보임 */} + + + {/* 메인 콘텐츠 */} +
+ +
+
+ + {/* 모바일 사이드바 - md 미만에서만 동작 */} +
+ setIsSidebarOpen(false)} + /> +
+
+ ); +}; + +export default HomeLayout; diff --git a/mission/chapter08/mission_1/src/main.tsx b/mission/chapter08/mission_1/src/main.tsx new file mode 100644 index 00000000..896bc7ce --- /dev/null +++ b/mission/chapter08/mission_1/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import './index.css'; +import App from './App.tsx'; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById('root')!).render( + + + + + + , +); diff --git a/mission/chapter08/mission_1/src/pages/GoogleCallBackPage.tsx b/mission/chapter08/mission_1/src/pages/GoogleCallBackPage.tsx new file mode 100644 index 00000000..866cfa46 --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/GoogleCallBackPage.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const GoogleCallbackPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + const name = params.get('name') || ''; + const userId = params.get('userId') || ''; + + if (accessToken && refreshToken) { + login({ email: userId, nickname: name }, accessToken, refreshToken); + navigate('/'); + } else { + navigate('/login'); + } + }, []); + + return ( +
+

로그인 처리 중...

+
+ ); +}; + +export default GoogleCallbackPage; diff --git a/mission/chapter08/mission_1/src/pages/HomePage.tsx b/mission/chapter08/mission_1/src/pages/HomePage.tsx new file mode 100644 index 00000000..c9d2c9d5 --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/HomePage.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from 'react'; +import useInfiniteLps from '../hooks/useInfiniteLps'; +import useDebounce from '../hooks/useDebounce'; +import LpCard from '../components/LpCard'; +import LpCardSkeleton from '../components/LpCardSkeleton'; +import CreateLpModal from '../components/CreateLpModal'; + +const HomePage = () => { + const [order, setOrder] = useState<'asc' | 'desc'>('desc'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [search, setSearch] = useState(''); + const bottomRef = useRef(null); + + // 300ms 디바운스 적용 - 타이핑 중엔 API 요청 안 나감 + const debouncedSearch = useDebounce(search, 300); + + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteLps(order, debouncedSearch); + + const lps = data?.pages.flatMap((page) => page.data.data) ?? []; + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + if (bottomRef.current) observer.observe(bottomRef.current); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+ {/* 검색창 */} +
+ setSearch(e.target.value)} + placeholder="LP 검색..." + className="w-full border border-gray-600 bg-transparent rounded-md px-4 py-2 text-sm outline-none focus:border-pink-400 text-black" + /> + {/* 디바운스 상태 표시 (디버깅용 - 나중에 지워도 됨) */} + {search !== debouncedSearch && ( +

입력 감지 중...

+ )} +
+ + {/* 정렬 버튼 */} +
+ + +
+ + {/* 검색 결과 안내 */} + {debouncedSearch && !isLoading && ( +

+ "{debouncedSearch}" 검색 결과{' '} + {lps.length === 0 ? '없음' : `${lps.length}건`} +

+ )} + + {/* 초기 로딩 스켈레톤 */} + {isLoading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} + + {/* 에러 */} + {isError && ( +
+

데이터를 불러오는데 실패했습니다.

+ +
+ )} + + {/* LP 목록 */} + {!isLoading && !isError && ( + <> + {lps.length === 0 && debouncedSearch ? ( +
+

검색 결과가 없습니다.

+
+ ) : ( +
+ {lps.map((lp) => ( + + ))} +
+ )} + + {isFetchingNextPage && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + +
+ + )} + + {/* 우측 하단 플로팅 버튼 */} + + + {isModalOpen && setIsModalOpen(false)} />} +
+ ); +}; + +export default HomePage; diff --git a/mission/chapter08/mission_1/src/pages/LoginPage.tsx b/mission/chapter08/mission_1/src/pages/LoginPage.tsx new file mode 100644 index 00000000..554d1fae --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/LoginPage.tsx @@ -0,0 +1,114 @@ +import { useNavigate, useLocation } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { signinSchema, type SigninFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; +import { signin } from '../api/auth'; +import Button from '../components/Button'; +import Input from '../components/Input'; + +const LoginPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { login } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onChange', + }); + + const from = (location.state as { from?: Location })?.from?.pathname || '/'; + + const { mutate: signinMutate, isPending } = useMutation({ + mutationFn: ({ email, password }: SigninFormValues) => + signin(email, password), + + onSuccess: (data, variables) => { + console.log('로그인 응답:', data.data); + + const { name, accessToken, refreshToken, id, userId } = data.data; + + login( + { + id: Number(id ?? userId), + email: variables.email, + nickname: name, + }, + accessToken, + refreshToken, + ); + + navigate(from, { replace: true }); + }, + + onError: () => { + alert('이메일 또는 비밀번호가 올바르지 않습니다.'); + }, + }); + + const onSubmit = (data: SigninFormValues) => { + signinMutate(data); + }; + + const onGoogleLogin = () => { + window.location.href = import.meta.env.VITE_GOOGLE_LOGIN_URL; + }; + + return ( +
+
+ +

로그인

+
+ +
+
+ + + + + + + +
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/pages/LpDetailPage.tsx b/mission/chapter08/mission_1/src/pages/LpDetailPage.tsx new file mode 100644 index 00000000..e71dfb18 --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/LpDetailPage.tsx @@ -0,0 +1,457 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import useLpDetail from '../hooks/useLpDetail'; +import useInfiniteComments from '../hooks/useInfiniteComments'; +import { useAuth } from '../context/AuthContext'; +import { + likeLp, + unlikeLp, + deleteLp, + createComment, + updateComment, + deleteComment, +} from '../api/lp'; +import CommentSkeleton from '../components/CommentSkeleton'; +import type { Comment } from '../types/lp'; + +const LpDetailPage = () => { + const { lpId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const bottomRef = useRef(null); + + const [commentOrder, setCommentOrder] = useState<'asc' | 'desc'>('asc'); + const [commentText, setCommentText] = useState(''); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + const [openMenuId, setOpenMenuId] = useState(null); + + const { data, isLoading, isError, refetch } = useLpDetail(Number(lpId)); + + const { + data: commentsData, + isLoading: isCommentsLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteComments(Number(lpId), commentOrder); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + if (bottomRef.current) observer.observe(bottomRef.current); + + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const lp = data?.data; + + const comments: Comment[] = + (commentsData as any)?.pages?.flatMap((page: any) => page.data.data) ?? []; + + const { mutate: likeMutate } = useMutation({ + mutationFn: () => { + const alreadyLiked = lp?.likes?.some( + (like: any) => Number(like.userId) === Number(user?.id), + ); + + return alreadyLiked ? unlikeLp(Number(lpId)) : likeLp(Number(lpId)); + }, + + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: ['lp', Number(lpId)], + }); + + const previousData = queryClient.getQueryData(['lp', Number(lpId)]); + + queryClient.setQueryData(['lp', Number(lpId)], (old: any) => { + if (!old || !user) return old; + + const currentLp = old.data; + + const alreadyLiked = currentLp.likes?.some( + (like: any) => Number(like.userId) === Number(user.id), + ); + + return { + ...old, + data: { + ...currentLp, + likes: alreadyLiked + ? currentLp.likes.filter( + (like: any) => Number(like.userId) !== Number(user.id), + ) + : [ + ...(currentLp.likes ?? []), + { + id: Date.now(), + userId: user.id, + lpId: Number(lpId), + }, + ], + }, + }; + }); + + return { previousData }; + }, + + onError: (_error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(['lp', Number(lpId)], context.previousData); + } + + alert('좋아요 처리에 실패했습니다.'); + }, + + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ['lp', Number(lpId)], + }); + }, + }); + + const { mutate: deleteLpMutate, isPending: isDeletingLp } = useMutation({ + mutationFn: () => deleteLp(Number(lpId)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + navigate('/'); + }, + onError: () => alert('LP 삭제에 실패했습니다.'), + }); + + const { mutate: createCommentMutate, isPending: isCreatingComment } = + useMutation({ + mutationFn: (content: string) => createComment(Number(lpId), content), + onSuccess: () => { + setCommentText(''); + queryClient.invalidateQueries({ + queryKey: ['lpComments', Number(lpId)], + }); + }, + onError: () => alert('댓글 작성에 실패했습니다.'), + }); + + const { mutate: updateCommentMutate, isPending: isUpdatingComment } = + useMutation({ + mutationFn: ({ + commentId, + content, + }: { + commentId: number; + content: string; + }) => updateComment(Number(lpId), commentId, content), + onSuccess: () => { + setEditingCommentId(null); + setEditingContent(''); + queryClient.invalidateQueries({ + queryKey: ['lpComments', Number(lpId)], + }); + }, + onError: () => alert('댓글 수정에 실패했습니다.'), + }); + + const { mutate: deleteCommentMutate } = useMutation({ + mutationFn: (commentId: number) => deleteComment(Number(lpId), commentId), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['lpComments', Number(lpId)] }), + onError: () => alert('댓글 삭제에 실패했습니다.'), + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError || !lp) { + return ( +
+

데이터를 불러오는데 실패했습니다.

+ +
+ ); + } + + const lpAuthorId = (lp as any).authorId ?? (lp as any).author?.id; + const isLpOwner = + user && lpAuthorId && Number(user.id) === Number(lpAuthorId); + + const isCommentOwner = (comment: any) => { + const commentAuthorId = comment.authorId ?? comment.author?.id; + return ( + user && commentAuthorId && Number(user.id) === Number(commentAuthorId) + ); + }; + + const isLiked = lp.likes?.some( + (like: any) => Number(like.userId) === Number(user?.id), + ); + + return ( +
+ + + {lp.title} + +

{lp.title}

+ +
+ {new Date(lp.createdAt).toLocaleDateString('ko-KR')} + ❤️ {lp.likes?.length ?? 0} +
+ +
+ {lp.tags?.map((tag) => ( + + #{tag.name} + + ))} +
+ +

{lp.content}

+ +
+ + + {isLpOwner && ( + <> + + + + + )} +
+ +
+
+

댓글

+ +
+ + + +
+
+ +
{ + e.preventDefault(); + if (!commentText.trim()) return; + createCommentMutate(commentText); + }} + className="flex gap-2 mb-6" + > + setCommentText(e.target.value)} + placeholder="댓글을 입력해주세요" + className="flex-1 bg-[#1e1e1e] border border-gray-700 rounded-md px-4 py-2 text-sm outline-none focus:border-pink-500 text-white" + /> + + +
+ + {isCommentsLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!isCommentsLoading && ( +
+ {comments.map((comment: any) => ( +
+
+
+
+ {comment.author?.name?.[0] ?? '?'} +
+ + + {comment.author?.name} + + + + {new Date(comment.createdAt).toLocaleDateString('ko-KR')} + +
+ + {isCommentOwner(comment) && ( +
+ + + {openMenuId === comment.id && ( +
+ + + +
+ )} +
+ )} +
+ + {editingCommentId === comment.id ? ( +
+ setEditingContent(e.target.value)} + className="flex-1 bg-[#1e1e1e] border border-gray-600 rounded-md px-3 py-1 text-sm text-white outline-none focus:border-pink-500" + /> + + +
+ ) : ( +

+ {comment.content} +

+ )} +
+ ))} + + {isFetchingNextPage && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + +
+
+ )} +
+
+ ); +}; + +export default LpDetailPage; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/pages/MyPage.tsx b/mission/chapter08/mission_1/src/pages/MyPage.tsx new file mode 100644 index 00000000..837cea95 --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/MyPage.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import DeleteConfirmModal from '../components/DeleteConfirmModal'; +import { useMyPage } from '../hooks/useMyPage'; + +const MyPage = () => { + const navigate = useNavigate(); + const { + user, + isUpdating, + isDeleting, + updateProfileMutate, + deleteAccountMutate, + logoutMutate, +} = useMyPage(); + + const [isEditing, setIsEditing] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [name, setName] = useState(user?.nickname ?? ''); + const [bio, setBio] = useState(''); + const [avatar, setAvatar] = useState(''); + + + + const handleProfileSubmit = () => { + if (!name.trim()) { + alert('닉네임을 입력해주세요.'); + return; + } + + updateProfileMutate({ + name, + bio, + avatar, + }); + + setIsEditing(false); +}; + + return ( +
+
+ +

마이페이지

+
+ +
+ {isEditing ? ( +
+
+ {avatar ? ( + avatar + ) : ( + 👤 + )} +
+ +
+ setName(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500 pr-10" + placeholder="닉네임" + /> + + +
+ + setBio(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500" + placeholder="bio" + /> + + setAvatar(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500" + placeholder="avatar URL" + /> + +

{user?.email}

+ + +
+ ) : ( + <> +
+
+ {avatar ? ( + avatar + ) : ( + '👤' + )} +
+ + +
+ +
+

+ {user?.nickname} +

+

{user?.email}

+
+ + + + + + )} +
+ + {showDeleteModal && ( + deleteAccountMutate()} + onCancel={() => setShowDeleteModal(false)} + isPending={isDeleting} + /> + )} +
+ ); +}; + +export default MyPage; diff --git a/mission/chapter08/mission_1/src/pages/NotFoundPage.tsx b/mission/chapter08/mission_1/src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..6f316cb9 --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/NotFoundPage.tsx @@ -0,0 +1,5 @@ +const NotFound = () => { + return
찾을 수 없는 페이지입니다
; +}; + +export default NotFound; diff --git a/mission/chapter08/mission_1/src/pages/SignupPage.tsx b/mission/chapter08/mission_1/src/pages/SignupPage.tsx new file mode 100644 index 00000000..d2f11148 --- /dev/null +++ b/mission/chapter08/mission_1/src/pages/SignupPage.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupSchema, type SignupFormValues } from '../utils/validate'; +import axiosInstance from '../api/axios'; + +const SignupPage = () => { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordCheck, setShowPasswordCheck] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormValues) => { + try { + // 서버에 회원가입 요청 + await axiosInstance.post('/auth/signup', { + name: data.name, + email: data.email, + password: data.password, + }); + + // 회원가입 성공 → 로그인 페이지로 이동 + alert('회원가입 성공! 로그인 해주세요 😊'); + navigate('/login'); + } catch (error) { + console.error('회원가입 실패:', error); + alert('회원가입에 실패했습니다.'); + } + }; + + const email = watch('email'); + const password = watch('password'); + const passwordCheck = watch('passwordCheck'); + const name = watch('name'); + + const isStep1Valid = !errors.email && email?.length > 0; + const isStep2Valid = + !errors.password && + !errors.passwordCheck && + password?.length > 0 && + passwordCheck?.length > 0 && + password === passwordCheck; + const isStep3Valid = !errors.name && name?.length > 0; + + return ( +
+
+ +

회원가입

+
+ +
+
+ {step === 1 && ( + <> + + {errors.email && ( +
{errors.email.message}
+ )} + + + )} + + {step === 2 && ( + <> +
{email}
+
+ + +
+ {errors.password && ( +
{errors.password.message}
+ )} +
+ + +
+ {passwordCheck?.length > 0 && password !== passwordCheck && ( +
비밀번호가 일치하지 않습니다
+ )} + + + )} + + {step === 3 && ( + <> + + {errors.name && ( +
{errors.name.message}
+ )} + + + )} +
+
+
+ ); +}; + +export default SignupPage; \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/types/lp.ts b/mission/chapter08/mission_1/src/types/lp.ts new file mode 100644 index 00000000..30719af3 --- /dev/null +++ b/mission/chapter08/mission_1/src/types/lp.ts @@ -0,0 +1,65 @@ +export interface Tag { + id: number; + name: string; +} + +export interface Like { + id: number; + userId: number; + lpId: number; +} + +export interface Lp { + id: number; + title: string; + content: string; + thumbnail: string; + published: boolean; + createdAt: string; + updatedAt: string; + tags: Tag[]; + likes: Like[]; +} + +// 댓글 타입 +export interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + lpId: number; + authorId: number; + author: { + id: number; + name: string; + }; +} +export interface LpListResponse { + status: boolean; + statusCode: number; + message: string; + data: { + data: Lp[]; + nextCursor: number | null; + hasNext: boolean; + }; +} + +export interface LpDetailResponse { + status: boolean; + statusCode: number; + message: string; + data: Lp; +} + +// 댓글 목록 응답 타입 +export interface CommentListResponse { + status: boolean; + statusCode: number; + message: string; + data: { + data: Comment[]; + nextCursor: number | null; + hasNext: boolean; + }; +} \ No newline at end of file diff --git a/mission/chapter08/mission_1/src/utils/validate.ts b/mission/chapter08/mission_1/src/utils/validate.ts new file mode 100644 index 00000000..6fddb70b --- /dev/null +++ b/mission/chapter08/mission_1/src/utils/validate.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const signinSchema = z.object({ + email: z.string().email('올바른 이메일 형식이 아닙니다'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .max(20, '비밀번호는 20자 이하여야 합니다'), +}); + +export const signupSchema = z + .object({ + name: z.string().min(1, '이름을 입력해주세요'), + email: z.string().email('올바른 이메일 형식이 아닙니다'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다') + .max(20, '비밀번호는 20자 이하여야 합니다'), + passwordCheck: z.string(), + }) + .refine((data) => data.password === data.passwordCheck, { + message: '비밀번호가 일치하지 않습니다', + path: ['passwordCheck'], + }); + +export type SigninFormValues = z.infer; +export type SignupFormValues = z.infer; diff --git a/mission/chapter08/mission_2/src/App.css b/mission/chapter08/mission_2/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/mission/chapter08/mission_2/src/App.tsx b/mission/chapter08/mission_2/src/App.tsx new file mode 100644 index 00000000..9978d8bd --- /dev/null +++ b/mission/chapter08/mission_2/src/App.tsx @@ -0,0 +1,63 @@ +import './App.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import HomeLayout from './layouts/HomeLayout'; +import NotFound from './pages/NotFoundPage'; +import LoginPage from './pages/LoginPage'; +import HomePage from './pages/HomePage'; +import SignupPage from './pages/SignupPage'; +import MyPage from './pages/MyPage'; +import LpDetailPage from './pages/LpDetailPage'; +import ProtectedRoute from './components/ProtectedRoute'; +import GoogleCallbackPage from './pages/GoogleCallBackPage'; +import { AuthProvider } from './context/AuthContext'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { + path: 'mypage', + element: ( + + + + ), + }, + { + path: 'lps/:lpId', // LP 상세 페이지 + element: ( + + + + ), + }, + ], + }, + { + path: '/login', + element: , + errorElement: , + }, + { + path: '/signup', + element: , + errorElement: , + }, + { + path: '/v1/auth/google/callback', + element: , + }, +]); + +function App() { + return ( + + + + ); +} + +export default App; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/api/auth.ts b/mission/chapter08/mission_2/src/api/auth.ts new file mode 100644 index 00000000..44d9b927 --- /dev/null +++ b/mission/chapter08/mission_2/src/api/auth.ts @@ -0,0 +1,13 @@ +import axiosInstance from './axios'; + +// 로그인 +export const signin = async (email: string, password: string) => { + const response = await axiosInstance.post('/auth/signin', { email, password }); + return response.data; +}; + +// 로그아웃 +export const signout = async () => { + const response = await axiosInstance.post('/auth/signout'); + return response.data; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/api/axios.ts b/mission/chapter08/mission_2/src/api/axios.ts new file mode 100644 index 00000000..41cdc580 --- /dev/null +++ b/mission/chapter08/mission_2/src/api/axios.ts @@ -0,0 +1,94 @@ +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_URL, +}); + +// 토큰 갱신 중복 방지용 변수 +let isRefreshing = false; // 현재 갱신 중인지 +let refreshSubscribers: ((token: string) => void)[] = []; // 대기 중인 요청들 + +//갱신 완료 후 대기 중인 요청들한테 새 토큰 전달 +const onRefreshed = (token: string) => { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +}; + +// 대기열에 요청 추가 +const addRefreshSubscriber = (callback: (token: string) => void) => { + refreshSubscribers.push(callback); +}; + +// 요청 인터셉터 +axiosInstance.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// 응답 인터셉터 +axiosInstance.interceptors.response.use( + // 성공 응답은 그냥 통과 + (response) => response, + + // 실패 응답(에러)은 여기서 처리 + async (error) => { + const originalRequest = error.config; + + // 401 = 토큰 만료 + if (error.response?.status === 401 && !originalRequest._retry) { + // 재시도 표시 → 한 번만 재시도하도록 기록 + originalRequest._retry = true; + + // 이미 갱신 중이면 대기열에 추가 + if (isRefreshing) { + return new Promise((resolve) => { + addRefreshSubscriber((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(axiosInstance(originalRequest)); + }); + }); + } + + isRefreshing = true; // 갱신 시작 + + try { + const refreshToken = localStorage.getItem('refreshToken'); + + const response = await axios.post( + `${import.meta.env.VITE_API_URL}/auth/refresh`, // env로 변경 + { + refresh: refreshToken, + }, + ); + + // 새로 받은 accessToken 저장 + const newAccessToken = response.data.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + + isRefreshing = false; + onRefreshed(newAccessToken); // 대기 중인 요청들 처리 + + // 실패했던 요청 헤더도 새 토큰으로 교체 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + // 실패했던 요청 재시도 + return axiosInstance(originalRequest); + } catch (refreshError) { + // refreshToken도 만료 → 강제 로그아웃 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + +export default axiosInstance; diff --git a/mission/chapter08/mission_2/src/api/lp.ts b/mission/chapter08/mission_2/src/api/lp.ts new file mode 100644 index 00000000..8f79033b --- /dev/null +++ b/mission/chapter08/mission_2/src/api/lp.ts @@ -0,0 +1,98 @@ +import axiosInstance from './axios'; +import type { CommentListResponse, LpDetailResponse, LpListResponse } from '../types/lp'; + +// LP 목록 조회 +export const getLps = async ( + order: 'asc' | 'desc' = 'desc', + cursor: number = 0, + search: string = '', // 추가 +): Promise => { + const response = await axiosInstance.get('/lps', { + params: { + order, + limit: 10, + cursor, + // 빈 문자열이면 파라미터 자체를 보내지 않음 + ...(search.trim() && { search: search.trim() }), + }, + }); + return response.data; +}; + +// LP 상세 조회 +export const getLpDetail = async (lpId: number): Promise => { + const response = await axiosInstance.get(`/lps/${lpId}`); + return response.data; +}; + +// LP 생성 +export const createLp = async (data: { + title: string; + content: string; + thumbnail: string; + tags: string[]; + published: boolean; +}) => { + const response = await axiosInstance.post('/lps', data); + return response.data; +}; + +// LP 수정 +export const updateLp = async (lpId: number, data: { + title?: string; + content?: string; + thumbnail?: string; + tags?: string[]; + published?: boolean; +}) => { + const response = await axiosInstance.patch(`/lps/${lpId}`, data); + return response.data; +}; + +// LP 좋아요 +export const likeLp = async (lpId: number) => { + const response = await axiosInstance.post(`/lps/${lpId}/likes`); + return response.data; +}; + +// LP 좋아요 취소 +export const unlikeLp = async (lpId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}/likes`); + return response.data; +}; + +// LP 삭제 +export const deleteLp = async (lpId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}`); + return response.data; +}; + +// 댓글 목록 조회 +export const getComments = async ( + lpId: number, + order: 'asc' | 'desc' = 'asc', + cursor: number = 0, +): Promise => { + const response = await axiosInstance.get(`/lps/${lpId}/comments`, { + params: { order, limit: 10, cursor }, + }); + return response.data; +}; + +// 댓글 작성 +export const createComment = async (lpId: number, content: string) => { + const response = await axiosInstance.post(`/lps/${lpId}/comments`, { content }); + return response.data; +}; + +// 댓글 수정 +export const updateComment = async (lpId: number, commentId: number, content: string) => { + const response = await axiosInstance.patch(`/lps/${lpId}/comments/${commentId}`, { content }); + return response.data; +}; + +// 댓글 삭제 +export const deleteComment = async (lpId: number, commentId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}/comments/${commentId}`); + return response.data; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/api/upload.ts b/mission/chapter08/mission_2/src/api/upload.ts new file mode 100644 index 00000000..555268f2 --- /dev/null +++ b/mission/chapter08/mission_2/src/api/upload.ts @@ -0,0 +1,11 @@ +import axiosInstance from './axios'; + +export const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await axiosInstance.post('/uploads', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + // 응답에서 imageUrl 반환 + return response.data.data.imageUrl; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/api/user.ts b/mission/chapter08/mission_2/src/api/user.ts new file mode 100644 index 00000000..37394720 --- /dev/null +++ b/mission/chapter08/mission_2/src/api/user.ts @@ -0,0 +1,23 @@ +import axiosInstance from './axios'; + +// 내 프로필 조회 +export const getMyProfile = async () => { + const response = await axiosInstance.get('/users/me'); + return response.data; +}; + +// 프로필 수정 +export const updateMyProfile = async (data: { + name: string; + bio: string; + avatar: string; +}) => { + const response = await axiosInstance.patch('/users', data); + return response.data; +}; + +// 회원 탈퇴 +export const deleteMyAccount = async () => { + const response = await axiosInstance.delete('/users'); + return response.data; +}; diff --git a/mission/chapter08/mission_2/src/assets/hero.png b/mission/chapter08/mission_2/src/assets/hero.png new file mode 100644 index 00000000..cc51a3d2 Binary files /dev/null and b/mission/chapter08/mission_2/src/assets/hero.png differ diff --git a/mission/chapter08/mission_2/src/assets/react.svg b/mission/chapter08/mission_2/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/mission/chapter08/mission_2/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/assets/vite.svg b/mission/chapter08/mission_2/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/mission/chapter08/mission_2/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/mission/chapter08/mission_2/src/components/Button.tsx b/mission/chapter08/mission_2/src/components/Button.tsx new file mode 100644 index 00000000..4601dc04 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/Button.tsx @@ -0,0 +1,43 @@ +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'google'; // 버튼 종류 + type?: 'button' | 'submit'; + className?: string; +} + +const Button = ({ + children, + onClick, + disabled = false, + variant = 'primary', + type = 'button', + className = '', +}: ButtonProps) => { + // variant에 따라 스타일 다르게 + const variantStyles = { + // 파란 버튼 (로그인, 다음, 회원가입 등) + primary: + 'w-full bg-blue-300 text-white py-3 rounded-md text-lg font-medium hover:bg-blue-500 transition-colors cursor-pointer disabled:bg-gray-300', + // 테두리 버튼 (로그아웃 등) + secondary: + 'px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 transition-colors cursor-pointer', + // 구글 버튼 + google: + 'w-full flex items-center justify-center gap-2 border border-gray-300 py-3 rounded-md text-lg font-medium hover:bg-gray-100 transition-colors cursor-pointer', + }; + + return ( + + ); +}; + +export default Button; diff --git a/mission/chapter08/mission_2/src/components/CommentSkeleton.tsx b/mission/chapter08/mission_2/src/components/CommentSkeleton.tsx new file mode 100644 index 00000000..60c1dec9 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/CommentSkeleton.tsx @@ -0,0 +1,14 @@ +const CommentSkeleton = () => { + return ( +
+
+
+
+
+
+
+
+ ); +}; + +export default CommentSkeleton; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/components/CreateLpModal.tsx b/mission/chapter08/mission_2/src/components/CreateLpModal.tsx new file mode 100644 index 00000000..f317ea07 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/CreateLpModal.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { createLp } from '../api/lp'; +import { uploadImage } from '../api/upload'; + +interface CreateLpModalProps { + onClose: () => void; +} + +const CreateLpModal = ({ onClose }: CreateLpModalProps) => { + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState([]); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + + const { mutate: createLpMutate, isPending } = useMutation({ + mutationFn: async () => { + let thumbnailUrl = ''; + + if (thumbnailFile) { + thumbnailUrl = await uploadImage(thumbnailFile); + } + + return createLp({ + title, + content, + thumbnail: thumbnailUrl, + tags, + published: true, + }); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + onClose(); + }, + + onError: (error) => { + console.error('LP 생성 실패:', error); + alert('LP 생성에 실패했습니다.'); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setThumbnailFile(file); + + setPreviewUrl((prev) => { + if (prev?.startsWith('blob:')) { + URL.revokeObjectURL(prev); + } + + return URL.createObjectURL(file); + }); + }; + + useEffect(() => { + return () => { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + const handleAddTag = () => { + const trimmed = tagInput.trim(); + + if (!trimmed || tags.includes(trimmed)) return; + + setTags((prev) => [...prev, trimmed]); + setTagInput(''); + }; + + const handleRemoveTag = (tag: string) => { + setTags((prev) => prev.filter((t) => t !== tag)); + }; + + const handleTagKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleSubmit = () => { + if (!title.trim()) { + alert('LP 이름을 입력해주세요.'); + return; + } + + createLpMutate(); + }; + + const handleBackdropClick = () => { + onClose(); + }; + + return ( +
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + +
fileInputRef.current?.click()} + > + {previewUrl ? ( + preview + ) : ( +
+ + + + + + + + + + + 클릭해서 사진 추가 + +
+ )} +
+ + + + setTitle(e.target.value)} + placeholder="LP Name" + className="bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + + setContent(e.target.value)} + placeholder="LP Content" + className="bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + +
+ setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + placeholder="LP Tag" + className="flex-1 bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + + +
+ + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + + + ))} +
+ )} + + +
+
+ ); +}; + +export default CreateLpModal; diff --git a/mission/chapter08/mission_2/src/components/DeleteConfirmModal.tsx b/mission/chapter08/mission_2/src/components/DeleteConfirmModal.tsx new file mode 100644 index 00000000..0fa52634 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/DeleteConfirmModal.tsx @@ -0,0 +1,33 @@ +interface DeleteConfirmModalProps { + onConfirm: () => void; + onCancel: () => void; + isPending?: boolean; +} + +const DeleteConfirmModal = ({ onConfirm, onCancel, isPending }: DeleteConfirmModalProps) => { + return ( +
+
+

정말 탈퇴하시겠습니까?

+

탈퇴 후에는 모든 데이터가 삭제되며 복구할 수 없습니다.

+
+ + +
+
+
+ ); +}; + +export default DeleteConfirmModal; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/components/Input.tsx b/mission/chapter08/mission_2/src/components/Input.tsx new file mode 100644 index 00000000..05686388 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/Input.tsx @@ -0,0 +1,32 @@ +import { forwardRef } from 'react'; + +interface InputProps { + type?: string; + placeholder?: string; + hasError?: boolean; + errorMessage?: string; +} + +// forwardRef → react-hook-form의 register가 ref를 전달할 수 있게 해줌 +const Input = forwardRef( + ({ type = 'text', placeholder, hasError = false, errorMessage, ...rest }, ref) => { + return ( +
+ + {/* 에러 메시지 있으면 자동으로 표시 */} + {hasError && errorMessage && ( +
{errorMessage}
+ )} +
+ ); + } +); + +export default Input; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/components/LpCard.tsx b/mission/chapter08/mission_2/src/components/LpCard.tsx new file mode 100644 index 00000000..58f4f7c1 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/LpCard.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom'; +import type { Lp } from '../types/lp'; + +interface LpCardProps { + lp: Lp; +} + +const LpCard = ({ lp }: LpCardProps) => { + const navigate = useNavigate(); + + return ( +
navigate(`/lps/${lp.id}`)} + className="relative group cursor-pointer rounded-lg overflow-hidden aspect-square bg-gray-200" + > + {/* 썸네일 */} + {lp.title} + + {/* 호버 시 오버레이 */} +
+

{lp.title}

+
+ + {new Date(lp.createdAt).toLocaleDateString('ko-KR')} + + + ❤️ {lp.likes?.length ?? 0} + +
+ {/* 태그 */} +
+ {lp.tags?.slice(0, 3).map((tag) => ( + + #{tag.name} + + ))} +
+
+
+ ); +}; + +export default LpCard; diff --git a/mission/chapter08/mission_2/src/components/LpCardSkeleton.tsx b/mission/chapter08/mission_2/src/components/LpCardSkeleton.tsx new file mode 100644 index 00000000..0bd3ee3d --- /dev/null +++ b/mission/chapter08/mission_2/src/components/LpCardSkeleton.tsx @@ -0,0 +1,7 @@ +const LpCardSkeleton = () => { + return ( +
+ ); +}; + +export default LpCardSkeleton; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/components/Navbar.tsx b/mission/chapter08/mission_2/src/components/Navbar.tsx new file mode 100644 index 00000000..89dd632d --- /dev/null +++ b/mission/chapter08/mission_2/src/components/Navbar.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; + +interface NavbarProps { + onMenuClick: () => void; +} + +const Navbar = ({ onMenuClick }: NavbarProps) => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + // ── 로그아웃 mutation ── + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { + await logout(); + }, + onSuccess: () => { + navigate('/'); + }, + }); + + return ( + + ); +}; + +export default Navbar; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/components/ProtectedRoute.tsx b/mission/chapter08/mission_2/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..77d974dd --- /dev/null +++ b/mission/chapter08/mission_2/src/components/ProtectedRoute.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth(); + const location = useLocation(); // 현재 경로 저장 + const [showModal, setShowModal] = useState(true); + + if (!user) { + // 모달 닫고 로그인으로 + if (!showModal) { + return ; + // state={{ from: location }} → 원래 가려던 경로 저장! + } + + return ( + // 경고 모달 +
+
+

로그인이 필요합니다

+

이 페이지는 로그인 후 이용할 수 있습니다.

+ +
+
+ ); + } + + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/components/Sidebar.tsx b/mission/chapter08/mission_2/src/components/Sidebar.tsx new file mode 100644 index 00000000..f15375c1 --- /dev/null +++ b/mission/chapter08/mission_2/src/components/Sidebar.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { deleteMyAccount } from '../api/user'; +import DeleteConfirmModal from './DeleteConfirmModal'; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; + isStatic?: boolean; +} + +const Sidebar = ({ isOpen, onClose, isStatic = false }: SidebarProps) => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + const [search, setSearch] = useState(''); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // ── 로그아웃 mutation ── + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { + await logout(); + }, + onSuccess: () => { + if (!isStatic) onClose(); + navigate('/'); + }, + }); + + // ── 탈퇴 mutation ── + const { mutate: deleteAccountMutate, isPending: isDeleting } = useMutation({ + mutationFn: deleteMyAccount, + onSuccess: () => { + logout(); + navigate('/login'); + }, + onError: () => { + alert('회원 탈퇴에 실패했습니다.'); + }, + }); + + const handleNavigate = (path: string) => { + navigate(path); + if (!isStatic) onClose(); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (search.trim()) { + navigate(`/?search=${search}`); + if (!isStatic) onClose(); + } + }; + + const content = ( +
+ {/* 검색창 */} +
+ setSearch(e.target.value)} + placeholder="검색..." + className="border border-gray-600 bg-transparent rounded-md px-3 py-2 text-sm outline-none focus:border-pink-400 text-white" + /> + +
+ +
+ + {/* 메뉴 */} + + + {/* 하단 로그인/로그아웃 */} +
+ {user ? ( +
+ {user.nickname}님 + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* 탈퇴 확인 모달 */} + {showDeleteModal && ( + deleteAccountMutate()} + onCancel={() => setShowDeleteModal(false)} + isPending={isDeleting} + /> + )} +
+ ); + + if (isStatic) { + return
{content}
; + } + + return ( + <> + {isOpen && ( +
+ )} + + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/context/AuthContext.tsx b/mission/chapter08/mission_2/src/context/AuthContext.tsx new file mode 100644 index 00000000..d85baa05 --- /dev/null +++ b/mission/chapter08/mission_2/src/context/AuthContext.tsx @@ -0,0 +1,102 @@ +import { createContext, useContext, useState } from 'react'; +import axiosInstance from '../api/axios'; + +interface User { + id: number; + email: string; + nickname: string; +} + +interface AuthContextType { + user: User | null; + login: (user: User, accessToken: string, refreshToken: string) => void; + logout: () => void; + updateNickname: (nickname: string) => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [user, setUser] = useState(() => { + try { + const stored = localStorage.getItem('user'); + + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.error('유저 정보 파싱 실패:', error); + + localStorage.removeItem('user'); + + return null; + } + }); + + const login = ( + userData: User, + accessToken: string, + refreshToken: string + ) => { + setUser(userData); + + localStorage.setItem('user', JSON.stringify(userData)); + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + }; + + const logout = async () => { + try { + await axiosInstance.post('/auth/signout'); + } catch (error) { + console.error('로그아웃 실패:', error); + } finally { + setUser(null); + + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }; + + // 닉네임만 즉시 업데이트 + const updateNickname = (nickname: string) => { + setUser((prev) => { + if (!prev) return prev; + + const updated = { + ...prev, + nickname, + }; + + localStorage.setItem('user', JSON.stringify(updated)); + + return updated; + }); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('AuthProvider 밖에서 사용 불가!'); + } + + return context; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useDebounce.ts b/mission/chapter08/mission_2/src/hooks/useDebounce.ts new file mode 100644 index 00000000..f03d70c9 --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useInfiniteComments.ts b/mission/chapter08/mission_2/src/hooks/useInfiniteComments.ts new file mode 100644 index 00000000..fc05cb01 --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useInfiniteComments.ts @@ -0,0 +1,18 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getComments } from '../api/lp'; +import type { CommentListResponse } from '../types/lp'; + +const useInfiniteComments = (lpId: number, order: 'asc' | 'desc') => { + return useInfiniteQuery({ + queryKey: ['lpComments', lpId, order], + queryFn: ({ pageParam }) => getComments(lpId, order, pageParam as number), + getNextPageParam: (lastPage) => { + return lastPage.data.hasNext ? lastPage.data.nextCursor : undefined; + }, + initialPageParam: 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export default useInfiniteComments; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useInfiniteLps.ts b/mission/chapter08/mission_2/src/hooks/useInfiniteLps.ts new file mode 100644 index 00000000..5a12c1cb --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useInfiniteLps.ts @@ -0,0 +1,25 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getLps } from '../api/lp'; + +const useInfiniteLps = (order: 'asc' | 'desc', search: string = '') => { + const trimmedSearch = search.trim(); + + return useInfiniteQuery({ + queryKey: ['search', order, trimmedSearch], + + queryFn: ({ pageParam = 0 }) => getLps(order, pageParam, trimmedSearch), + + getNextPageParam: (lastPage) => { + return lastPage.data.hasNext ? lastPage.data.nextCursor : undefined; + }, + + initialPageParam: 0, + + enabled: true, + + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export default useInfiniteLps; diff --git a/mission/chapter08/mission_2/src/hooks/useLocalStorage.ts b/mission/chapter08/mission_2/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..c6a3ad1b --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +function useLocalStorage(key: string, initialValue: T) { + // 로컬스토리지에서 값을 가져오는 함수 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + // 저장된 값이 있으면 파싱해서 반환, 없으면 초기값 반환 + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // 값을 저장하는 함수 + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export default useLocalStorage; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useLpDetail.ts b/mission/chapter08/mission_2/src/hooks/useLpDetail.ts new file mode 100644 index 00000000..300c788f --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useLpDetail.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLpDetail } from '../api/lp'; + +const useLpDetail = (lpId: number) => { + return useQuery({ + queryKey: ['lp', lpId], // lpId 포함 + queryFn: () => getLpDetail(lpId), + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + }); +}; + +export default useLpDetail; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useLps.ts b/mission/chapter08/mission_2/src/hooks/useLps.ts new file mode 100644 index 00000000..731431b6 --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useLps.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLps } from '../api/lp'; + +const useLps = (order: 'asc' | 'desc') => { + return useQuery({ + queryKey: ['lps', order], // order 바뀌면 자동으로 리패치 + queryFn: () => getLps(order), + staleTime: 1000 * 60 * 5, // 5분 동안 캐시 유지 + gcTime: 1000 * 60 * 10, // 10분 후 캐시 삭제 + }); +}; + +export default useLps; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useMyPage.ts b/mission/chapter08/mission_2/src/hooks/useMyPage.ts new file mode 100644 index 00000000..d48d78c1 --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useMyPage.ts @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { updateMyProfile, deleteMyAccount } from '../api/user'; + +export const useMyPage = () => { + const navigate = useNavigate(); + const { user, logout, updateNickname } = useAuth(); + const queryClient = useQueryClient(); + + const { mutate: updateProfileMutate, isPending: isUpdating } = useMutation({ + mutationFn: (profileData: { name: string; bio: string; avatar: string }) => + updateMyProfile(profileData), + onMutate: async (profileData) => { + const previousNickname = user?.nickname; + updateNickname(profileData.name); + return { previousNickname }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['myProfile'] }); + alert('프로필이 수정되었습니다.'); + }, + onError: (_error, _variables, context) => { + if (context?.previousNickname) updateNickname(context.previousNickname); + alert('프로필 수정에 실패했습니다.'); + }, + }); + + const { mutate: deleteAccountMutate, isPending: isDeleting } = useMutation({ + mutationFn: deleteMyAccount, + onSuccess: () => { logout(); navigate('/login'); }, + onError: () => { alert('회원 탈퇴에 실패했습니다.'); }, + }); + + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { await logout(); }, + onSuccess: () => { navigate('/'); }, + }); + + return { + user, + isUpdating, + isDeleting, + updateProfileMutate, + deleteAccountMutate, + logoutMutate, + }; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/hooks/useThrottle.ts b/mission/chapter08/mission_2/src/hooks/useThrottle.ts new file mode 100644 index 00000000..144316ff --- /dev/null +++ b/mission/chapter08/mission_2/src/hooks/useThrottle.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react'; + +function useThrottle(value: T, interval: number = 1000): T { + const [throttledValue, setThrottledValue] = useState(value); + const lastExecuted = useRef(Date.now()); + const timerRef = useRef | null>(null); + + useEffect(() => { + const elapsed = Date.now() - lastExecuted.current; + + if (elapsed >= interval) { + setThrottledValue(value); + lastExecuted.current = Date.now(); + return; + } + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + setThrottledValue(value); + lastExecuted.current = Date.now(); + timerRef.current = null; + }, interval - elapsed); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [value, interval]); + + return throttledValue; +} + +export default useThrottle; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/index.css b/mission/chapter08/mission_2/src/index.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/mission/chapter08/mission_2/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter08/mission_2/src/layouts/HomeLayout.tsx b/mission/chapter08/mission_2/src/layouts/HomeLayout.tsx new file mode 100644 index 00000000..0eb4c6bf --- /dev/null +++ b/mission/chapter08/mission_2/src/layouts/HomeLayout.tsx @@ -0,0 +1,35 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import Sidebar from '../components/Sidebar'; +import { useState } from 'react'; + +const HomeLayout = () => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + return ( +
+ setIsSidebarOpen(true)} /> +
+ {/* 사이드바 - md 이상에서는 항상 보임 */} + + + {/* 메인 콘텐츠 */} +
+ +
+
+ + {/* 모바일 사이드바 - md 미만에서만 동작 */} +
+ setIsSidebarOpen(false)} + /> +
+
+ ); +}; + +export default HomeLayout; diff --git a/mission/chapter08/mission_2/src/main.tsx b/mission/chapter08/mission_2/src/main.tsx new file mode 100644 index 00000000..896bc7ce --- /dev/null +++ b/mission/chapter08/mission_2/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import './index.css'; +import App from './App.tsx'; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById('root')!).render( + + + + + + , +); diff --git a/mission/chapter08/mission_2/src/pages/GoogleCallBackPage.tsx b/mission/chapter08/mission_2/src/pages/GoogleCallBackPage.tsx new file mode 100644 index 00000000..866cfa46 --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/GoogleCallBackPage.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const GoogleCallbackPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + const name = params.get('name') || ''; + const userId = params.get('userId') || ''; + + if (accessToken && refreshToken) { + login({ email: userId, nickname: name }, accessToken, refreshToken); + navigate('/'); + } else { + navigate('/login'); + } + }, []); + + return ( +
+

로그인 처리 중...

+
+ ); +}; + +export default GoogleCallbackPage; diff --git a/mission/chapter08/mission_2/src/pages/HomePage.tsx b/mission/chapter08/mission_2/src/pages/HomePage.tsx new file mode 100644 index 00000000..968f2ae5 --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/HomePage.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; +import useInfiniteLps from '../hooks/useInfiniteLps'; +import useDebounce from '../hooks/useDebounce'; +import useThrottle from '../hooks/useThrottle'; + +import LpCard from '../components/LpCard'; +import LpCardSkeleton from '../components/LpCardSkeleton'; +import CreateLpModal from '../components/CreateLpModal'; + +const HomePage = () => { + const [order, setOrder] = useState<'asc' | 'desc'>('desc'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [search, setSearch] = useState(''); + + const bottomRef = useRef(null); + + const debouncedSearch = useDebounce(search, 300); + + const [scrollTrigger, setScrollTrigger] = useState(0); + const throttledScrollTrigger = useThrottle(scrollTrigger, 1000); + + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteLps(order, debouncedSearch); + + const lps = data?.pages.flatMap((page) => page.data.data) ?? []; + + useEffect(() => { + if (isLoading || isError) return; + if (!bottomRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + console.log('[Observer] 바닥 감지'); + setScrollTrigger((prev) => prev + 1); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(bottomRef.current); + + return () => observer.disconnect(); + }, [isLoading, isError]); + + useEffect(() => { + if (throttledScrollTrigger > 0 && hasNextPage && !isFetchingNextPage) { + console.log('[Throttle] 1초에 한 번만 다음 페이지 요청'); + fetchNextPage(); + } + }, [throttledScrollTrigger, hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+
+ setSearch(e.target.value)} + placeholder="LP 검색..." + className="w-full border border-gray-600 bg-transparent rounded-md px-4 py-2 text-sm outline-none focus:border-pink-400 text-white" + /> + + {search !== debouncedSearch && ( +

입력 감지 중...

+ )} +
+ +
+ + + +
+ + {debouncedSearch && !isLoading && ( +

+ "{debouncedSearch}" 검색 결과{' '} + {lps.length === 0 ? '없음' : `${lps.length}건`} +

+ )} + + {isLoading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} + + {isError && ( +
+

데이터를 불러오는데 실패했습니다.

+ + +
+ )} + + {!isLoading && !isError && ( + <> + {lps.length === 0 && debouncedSearch ? ( +
+

검색 결과가 없습니다.

+
+ ) : ( +
+ {lps.map((lp) => ( + + ))} +
+ )} + + {isFetchingNextPage && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + +
+ + )} + + + + {isModalOpen && setIsModalOpen(false)} />} +
+ ); +}; + +export default HomePage; diff --git a/mission/chapter08/mission_2/src/pages/LoginPage.tsx b/mission/chapter08/mission_2/src/pages/LoginPage.tsx new file mode 100644 index 00000000..554d1fae --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/LoginPage.tsx @@ -0,0 +1,114 @@ +import { useNavigate, useLocation } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { signinSchema, type SigninFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; +import { signin } from '../api/auth'; +import Button from '../components/Button'; +import Input from '../components/Input'; + +const LoginPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { login } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onChange', + }); + + const from = (location.state as { from?: Location })?.from?.pathname || '/'; + + const { mutate: signinMutate, isPending } = useMutation({ + mutationFn: ({ email, password }: SigninFormValues) => + signin(email, password), + + onSuccess: (data, variables) => { + console.log('로그인 응답:', data.data); + + const { name, accessToken, refreshToken, id, userId } = data.data; + + login( + { + id: Number(id ?? userId), + email: variables.email, + nickname: name, + }, + accessToken, + refreshToken, + ); + + navigate(from, { replace: true }); + }, + + onError: () => { + alert('이메일 또는 비밀번호가 올바르지 않습니다.'); + }, + }); + + const onSubmit = (data: SigninFormValues) => { + signinMutate(data); + }; + + const onGoogleLogin = () => { + window.location.href = import.meta.env.VITE_GOOGLE_LOGIN_URL; + }; + + return ( +
+
+ +

로그인

+
+ +
+
+ + + + + + + +
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/pages/LpDetailPage.tsx b/mission/chapter08/mission_2/src/pages/LpDetailPage.tsx new file mode 100644 index 00000000..e71dfb18 --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/LpDetailPage.tsx @@ -0,0 +1,457 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import useLpDetail from '../hooks/useLpDetail'; +import useInfiniteComments from '../hooks/useInfiniteComments'; +import { useAuth } from '../context/AuthContext'; +import { + likeLp, + unlikeLp, + deleteLp, + createComment, + updateComment, + deleteComment, +} from '../api/lp'; +import CommentSkeleton from '../components/CommentSkeleton'; +import type { Comment } from '../types/lp'; + +const LpDetailPage = () => { + const { lpId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const bottomRef = useRef(null); + + const [commentOrder, setCommentOrder] = useState<'asc' | 'desc'>('asc'); + const [commentText, setCommentText] = useState(''); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + const [openMenuId, setOpenMenuId] = useState(null); + + const { data, isLoading, isError, refetch } = useLpDetail(Number(lpId)); + + const { + data: commentsData, + isLoading: isCommentsLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteComments(Number(lpId), commentOrder); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + if (bottomRef.current) observer.observe(bottomRef.current); + + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const lp = data?.data; + + const comments: Comment[] = + (commentsData as any)?.pages?.flatMap((page: any) => page.data.data) ?? []; + + const { mutate: likeMutate } = useMutation({ + mutationFn: () => { + const alreadyLiked = lp?.likes?.some( + (like: any) => Number(like.userId) === Number(user?.id), + ); + + return alreadyLiked ? unlikeLp(Number(lpId)) : likeLp(Number(lpId)); + }, + + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: ['lp', Number(lpId)], + }); + + const previousData = queryClient.getQueryData(['lp', Number(lpId)]); + + queryClient.setQueryData(['lp', Number(lpId)], (old: any) => { + if (!old || !user) return old; + + const currentLp = old.data; + + const alreadyLiked = currentLp.likes?.some( + (like: any) => Number(like.userId) === Number(user.id), + ); + + return { + ...old, + data: { + ...currentLp, + likes: alreadyLiked + ? currentLp.likes.filter( + (like: any) => Number(like.userId) !== Number(user.id), + ) + : [ + ...(currentLp.likes ?? []), + { + id: Date.now(), + userId: user.id, + lpId: Number(lpId), + }, + ], + }, + }; + }); + + return { previousData }; + }, + + onError: (_error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(['lp', Number(lpId)], context.previousData); + } + + alert('좋아요 처리에 실패했습니다.'); + }, + + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ['lp', Number(lpId)], + }); + }, + }); + + const { mutate: deleteLpMutate, isPending: isDeletingLp } = useMutation({ + mutationFn: () => deleteLp(Number(lpId)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + navigate('/'); + }, + onError: () => alert('LP 삭제에 실패했습니다.'), + }); + + const { mutate: createCommentMutate, isPending: isCreatingComment } = + useMutation({ + mutationFn: (content: string) => createComment(Number(lpId), content), + onSuccess: () => { + setCommentText(''); + queryClient.invalidateQueries({ + queryKey: ['lpComments', Number(lpId)], + }); + }, + onError: () => alert('댓글 작성에 실패했습니다.'), + }); + + const { mutate: updateCommentMutate, isPending: isUpdatingComment } = + useMutation({ + mutationFn: ({ + commentId, + content, + }: { + commentId: number; + content: string; + }) => updateComment(Number(lpId), commentId, content), + onSuccess: () => { + setEditingCommentId(null); + setEditingContent(''); + queryClient.invalidateQueries({ + queryKey: ['lpComments', Number(lpId)], + }); + }, + onError: () => alert('댓글 수정에 실패했습니다.'), + }); + + const { mutate: deleteCommentMutate } = useMutation({ + mutationFn: (commentId: number) => deleteComment(Number(lpId), commentId), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['lpComments', Number(lpId)] }), + onError: () => alert('댓글 삭제에 실패했습니다.'), + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError || !lp) { + return ( +
+

데이터를 불러오는데 실패했습니다.

+ +
+ ); + } + + const lpAuthorId = (lp as any).authorId ?? (lp as any).author?.id; + const isLpOwner = + user && lpAuthorId && Number(user.id) === Number(lpAuthorId); + + const isCommentOwner = (comment: any) => { + const commentAuthorId = comment.authorId ?? comment.author?.id; + return ( + user && commentAuthorId && Number(user.id) === Number(commentAuthorId) + ); + }; + + const isLiked = lp.likes?.some( + (like: any) => Number(like.userId) === Number(user?.id), + ); + + return ( +
+ + + {lp.title} + +

{lp.title}

+ +
+ {new Date(lp.createdAt).toLocaleDateString('ko-KR')} + ❤️ {lp.likes?.length ?? 0} +
+ +
+ {lp.tags?.map((tag) => ( + + #{tag.name} + + ))} +
+ +

{lp.content}

+ +
+ + + {isLpOwner && ( + <> + + + + + )} +
+ +
+
+

댓글

+ +
+ + + +
+
+ +
{ + e.preventDefault(); + if (!commentText.trim()) return; + createCommentMutate(commentText); + }} + className="flex gap-2 mb-6" + > + setCommentText(e.target.value)} + placeholder="댓글을 입력해주세요" + className="flex-1 bg-[#1e1e1e] border border-gray-700 rounded-md px-4 py-2 text-sm outline-none focus:border-pink-500 text-white" + /> + + +
+ + {isCommentsLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!isCommentsLoading && ( +
+ {comments.map((comment: any) => ( +
+
+
+
+ {comment.author?.name?.[0] ?? '?'} +
+ + + {comment.author?.name} + + + + {new Date(comment.createdAt).toLocaleDateString('ko-KR')} + +
+ + {isCommentOwner(comment) && ( +
+ + + {openMenuId === comment.id && ( +
+ + + +
+ )} +
+ )} +
+ + {editingCommentId === comment.id ? ( +
+ setEditingContent(e.target.value)} + className="flex-1 bg-[#1e1e1e] border border-gray-600 rounded-md px-3 py-1 text-sm text-white outline-none focus:border-pink-500" + /> + + +
+ ) : ( +

+ {comment.content} +

+ )} +
+ ))} + + {isFetchingNextPage && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + +
+
+ )} +
+
+ ); +}; + +export default LpDetailPage; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/pages/MyPage.tsx b/mission/chapter08/mission_2/src/pages/MyPage.tsx new file mode 100644 index 00000000..837cea95 --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/MyPage.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import DeleteConfirmModal from '../components/DeleteConfirmModal'; +import { useMyPage } from '../hooks/useMyPage'; + +const MyPage = () => { + const navigate = useNavigate(); + const { + user, + isUpdating, + isDeleting, + updateProfileMutate, + deleteAccountMutate, + logoutMutate, +} = useMyPage(); + + const [isEditing, setIsEditing] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [name, setName] = useState(user?.nickname ?? ''); + const [bio, setBio] = useState(''); + const [avatar, setAvatar] = useState(''); + + + + const handleProfileSubmit = () => { + if (!name.trim()) { + alert('닉네임을 입력해주세요.'); + return; + } + + updateProfileMutate({ + name, + bio, + avatar, + }); + + setIsEditing(false); +}; + + return ( +
+
+ +

마이페이지

+
+ +
+ {isEditing ? ( +
+
+ {avatar ? ( + avatar + ) : ( + 👤 + )} +
+ +
+ setName(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500 pr-10" + placeholder="닉네임" + /> + + +
+ + setBio(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500" + placeholder="bio" + /> + + setAvatar(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500" + placeholder="avatar URL" + /> + +

{user?.email}

+ + +
+ ) : ( + <> +
+
+ {avatar ? ( + avatar + ) : ( + '👤' + )} +
+ + +
+ +
+

+ {user?.nickname} +

+

{user?.email}

+
+ + + + + + )} +
+ + {showDeleteModal && ( + deleteAccountMutate()} + onCancel={() => setShowDeleteModal(false)} + isPending={isDeleting} + /> + )} +
+ ); +}; + +export default MyPage; diff --git a/mission/chapter08/mission_2/src/pages/NotFoundPage.tsx b/mission/chapter08/mission_2/src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..6f316cb9 --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/NotFoundPage.tsx @@ -0,0 +1,5 @@ +const NotFound = () => { + return
찾을 수 없는 페이지입니다
; +}; + +export default NotFound; diff --git a/mission/chapter08/mission_2/src/pages/SignupPage.tsx b/mission/chapter08/mission_2/src/pages/SignupPage.tsx new file mode 100644 index 00000000..d2f11148 --- /dev/null +++ b/mission/chapter08/mission_2/src/pages/SignupPage.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupSchema, type SignupFormValues } from '../utils/validate'; +import axiosInstance from '../api/axios'; + +const SignupPage = () => { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordCheck, setShowPasswordCheck] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormValues) => { + try { + // 서버에 회원가입 요청 + await axiosInstance.post('/auth/signup', { + name: data.name, + email: data.email, + password: data.password, + }); + + // 회원가입 성공 → 로그인 페이지로 이동 + alert('회원가입 성공! 로그인 해주세요 😊'); + navigate('/login'); + } catch (error) { + console.error('회원가입 실패:', error); + alert('회원가입에 실패했습니다.'); + } + }; + + const email = watch('email'); + const password = watch('password'); + const passwordCheck = watch('passwordCheck'); + const name = watch('name'); + + const isStep1Valid = !errors.email && email?.length > 0; + const isStep2Valid = + !errors.password && + !errors.passwordCheck && + password?.length > 0 && + passwordCheck?.length > 0 && + password === passwordCheck; + const isStep3Valid = !errors.name && name?.length > 0; + + return ( +
+
+ +

회원가입

+
+ +
+
+ {step === 1 && ( + <> + + {errors.email && ( +
{errors.email.message}
+ )} + + + )} + + {step === 2 && ( + <> +
{email}
+
+ + +
+ {errors.password && ( +
{errors.password.message}
+ )} +
+ + +
+ {passwordCheck?.length > 0 && password !== passwordCheck && ( +
비밀번호가 일치하지 않습니다
+ )} + + + )} + + {step === 3 && ( + <> + + {errors.name && ( +
{errors.name.message}
+ )} + + + )} +
+
+
+ ); +}; + +export default SignupPage; \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/types/lp.ts b/mission/chapter08/mission_2/src/types/lp.ts new file mode 100644 index 00000000..30719af3 --- /dev/null +++ b/mission/chapter08/mission_2/src/types/lp.ts @@ -0,0 +1,65 @@ +export interface Tag { + id: number; + name: string; +} + +export interface Like { + id: number; + userId: number; + lpId: number; +} + +export interface Lp { + id: number; + title: string; + content: string; + thumbnail: string; + published: boolean; + createdAt: string; + updatedAt: string; + tags: Tag[]; + likes: Like[]; +} + +// 댓글 타입 +export interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + lpId: number; + authorId: number; + author: { + id: number; + name: string; + }; +} +export interface LpListResponse { + status: boolean; + statusCode: number; + message: string; + data: { + data: Lp[]; + nextCursor: number | null; + hasNext: boolean; + }; +} + +export interface LpDetailResponse { + status: boolean; + statusCode: number; + message: string; + data: Lp; +} + +// 댓글 목록 응답 타입 +export interface CommentListResponse { + status: boolean; + statusCode: number; + message: string; + data: { + data: Comment[]; + nextCursor: number | null; + hasNext: boolean; + }; +} \ No newline at end of file diff --git a/mission/chapter08/mission_2/src/utils/validate.ts b/mission/chapter08/mission_2/src/utils/validate.ts new file mode 100644 index 00000000..6fddb70b --- /dev/null +++ b/mission/chapter08/mission_2/src/utils/validate.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const signinSchema = z.object({ + email: z.string().email('올바른 이메일 형식이 아닙니다'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .max(20, '비밀번호는 20자 이하여야 합니다'), +}); + +export const signupSchema = z + .object({ + name: z.string().min(1, '이름을 입력해주세요'), + email: z.string().email('올바른 이메일 형식이 아닙니다'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다') + .max(20, '비밀번호는 20자 이하여야 합니다'), + passwordCheck: z.string(), + }) + .refine((data) => data.password === data.passwordCheck, { + message: '비밀번호가 일치하지 않습니다', + path: ['passwordCheck'], + }); + +export type SigninFormValues = z.infer; +export type SignupFormValues = z.infer; diff --git a/mission/chapter08/mission_3/src/App.css b/mission/chapter08/mission_3/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/mission/chapter08/mission_3/src/App.tsx b/mission/chapter08/mission_3/src/App.tsx new file mode 100644 index 00000000..9978d8bd --- /dev/null +++ b/mission/chapter08/mission_3/src/App.tsx @@ -0,0 +1,63 @@ +import './App.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import HomeLayout from './layouts/HomeLayout'; +import NotFound from './pages/NotFoundPage'; +import LoginPage from './pages/LoginPage'; +import HomePage from './pages/HomePage'; +import SignupPage from './pages/SignupPage'; +import MyPage from './pages/MyPage'; +import LpDetailPage from './pages/LpDetailPage'; +import ProtectedRoute from './components/ProtectedRoute'; +import GoogleCallbackPage from './pages/GoogleCallBackPage'; +import { AuthProvider } from './context/AuthContext'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { + path: 'mypage', + element: ( + + + + ), + }, + { + path: 'lps/:lpId', // LP 상세 페이지 + element: ( + + + + ), + }, + ], + }, + { + path: '/login', + element: , + errorElement: , + }, + { + path: '/signup', + element: , + errorElement: , + }, + { + path: '/v1/auth/google/callback', + element: , + }, +]); + +function App() { + return ( + + + + ); +} + +export default App; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/api/auth.ts b/mission/chapter08/mission_3/src/api/auth.ts new file mode 100644 index 00000000..44d9b927 --- /dev/null +++ b/mission/chapter08/mission_3/src/api/auth.ts @@ -0,0 +1,13 @@ +import axiosInstance from './axios'; + +// 로그인 +export const signin = async (email: string, password: string) => { + const response = await axiosInstance.post('/auth/signin', { email, password }); + return response.data; +}; + +// 로그아웃 +export const signout = async () => { + const response = await axiosInstance.post('/auth/signout'); + return response.data; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/api/axios.ts b/mission/chapter08/mission_3/src/api/axios.ts new file mode 100644 index 00000000..41cdc580 --- /dev/null +++ b/mission/chapter08/mission_3/src/api/axios.ts @@ -0,0 +1,94 @@ +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_URL, +}); + +// 토큰 갱신 중복 방지용 변수 +let isRefreshing = false; // 현재 갱신 중인지 +let refreshSubscribers: ((token: string) => void)[] = []; // 대기 중인 요청들 + +//갱신 완료 후 대기 중인 요청들한테 새 토큰 전달 +const onRefreshed = (token: string) => { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +}; + +// 대기열에 요청 추가 +const addRefreshSubscriber = (callback: (token: string) => void) => { + refreshSubscribers.push(callback); +}; + +// 요청 인터셉터 +axiosInstance.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// 응답 인터셉터 +axiosInstance.interceptors.response.use( + // 성공 응답은 그냥 통과 + (response) => response, + + // 실패 응답(에러)은 여기서 처리 + async (error) => { + const originalRequest = error.config; + + // 401 = 토큰 만료 + if (error.response?.status === 401 && !originalRequest._retry) { + // 재시도 표시 → 한 번만 재시도하도록 기록 + originalRequest._retry = true; + + // 이미 갱신 중이면 대기열에 추가 + if (isRefreshing) { + return new Promise((resolve) => { + addRefreshSubscriber((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(axiosInstance(originalRequest)); + }); + }); + } + + isRefreshing = true; // 갱신 시작 + + try { + const refreshToken = localStorage.getItem('refreshToken'); + + const response = await axios.post( + `${import.meta.env.VITE_API_URL}/auth/refresh`, // env로 변경 + { + refresh: refreshToken, + }, + ); + + // 새로 받은 accessToken 저장 + const newAccessToken = response.data.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + + isRefreshing = false; + onRefreshed(newAccessToken); // 대기 중인 요청들 처리 + + // 실패했던 요청 헤더도 새 토큰으로 교체 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + // 실패했던 요청 재시도 + return axiosInstance(originalRequest); + } catch (refreshError) { + // refreshToken도 만료 → 강제 로그아웃 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + +export default axiosInstance; diff --git a/mission/chapter08/mission_3/src/api/lp.ts b/mission/chapter08/mission_3/src/api/lp.ts new file mode 100644 index 00000000..8f79033b --- /dev/null +++ b/mission/chapter08/mission_3/src/api/lp.ts @@ -0,0 +1,98 @@ +import axiosInstance from './axios'; +import type { CommentListResponse, LpDetailResponse, LpListResponse } from '../types/lp'; + +// LP 목록 조회 +export const getLps = async ( + order: 'asc' | 'desc' = 'desc', + cursor: number = 0, + search: string = '', // 추가 +): Promise => { + const response = await axiosInstance.get('/lps', { + params: { + order, + limit: 10, + cursor, + // 빈 문자열이면 파라미터 자체를 보내지 않음 + ...(search.trim() && { search: search.trim() }), + }, + }); + return response.data; +}; + +// LP 상세 조회 +export const getLpDetail = async (lpId: number): Promise => { + const response = await axiosInstance.get(`/lps/${lpId}`); + return response.data; +}; + +// LP 생성 +export const createLp = async (data: { + title: string; + content: string; + thumbnail: string; + tags: string[]; + published: boolean; +}) => { + const response = await axiosInstance.post('/lps', data); + return response.data; +}; + +// LP 수정 +export const updateLp = async (lpId: number, data: { + title?: string; + content?: string; + thumbnail?: string; + tags?: string[]; + published?: boolean; +}) => { + const response = await axiosInstance.patch(`/lps/${lpId}`, data); + return response.data; +}; + +// LP 좋아요 +export const likeLp = async (lpId: number) => { + const response = await axiosInstance.post(`/lps/${lpId}/likes`); + return response.data; +}; + +// LP 좋아요 취소 +export const unlikeLp = async (lpId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}/likes`); + return response.data; +}; + +// LP 삭제 +export const deleteLp = async (lpId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}`); + return response.data; +}; + +// 댓글 목록 조회 +export const getComments = async ( + lpId: number, + order: 'asc' | 'desc' = 'asc', + cursor: number = 0, +): Promise => { + const response = await axiosInstance.get(`/lps/${lpId}/comments`, { + params: { order, limit: 10, cursor }, + }); + return response.data; +}; + +// 댓글 작성 +export const createComment = async (lpId: number, content: string) => { + const response = await axiosInstance.post(`/lps/${lpId}/comments`, { content }); + return response.data; +}; + +// 댓글 수정 +export const updateComment = async (lpId: number, commentId: number, content: string) => { + const response = await axiosInstance.patch(`/lps/${lpId}/comments/${commentId}`, { content }); + return response.data; +}; + +// 댓글 삭제 +export const deleteComment = async (lpId: number, commentId: number) => { + const response = await axiosInstance.delete(`/lps/${lpId}/comments/${commentId}`); + return response.data; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/api/upload.ts b/mission/chapter08/mission_3/src/api/upload.ts new file mode 100644 index 00000000..555268f2 --- /dev/null +++ b/mission/chapter08/mission_3/src/api/upload.ts @@ -0,0 +1,11 @@ +import axiosInstance from './axios'; + +export const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await axiosInstance.post('/uploads', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + // 응답에서 imageUrl 반환 + return response.data.data.imageUrl; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/api/user.ts b/mission/chapter08/mission_3/src/api/user.ts new file mode 100644 index 00000000..37394720 --- /dev/null +++ b/mission/chapter08/mission_3/src/api/user.ts @@ -0,0 +1,23 @@ +import axiosInstance from './axios'; + +// 내 프로필 조회 +export const getMyProfile = async () => { + const response = await axiosInstance.get('/users/me'); + return response.data; +}; + +// 프로필 수정 +export const updateMyProfile = async (data: { + name: string; + bio: string; + avatar: string; +}) => { + const response = await axiosInstance.patch('/users', data); + return response.data; +}; + +// 회원 탈퇴 +export const deleteMyAccount = async () => { + const response = await axiosInstance.delete('/users'); + return response.data; +}; diff --git a/mission/chapter08/mission_3/src/assets/hero.png b/mission/chapter08/mission_3/src/assets/hero.png new file mode 100644 index 00000000..cc51a3d2 Binary files /dev/null and b/mission/chapter08/mission_3/src/assets/hero.png differ diff --git a/mission/chapter08/mission_3/src/assets/react.svg b/mission/chapter08/mission_3/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/mission/chapter08/mission_3/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/assets/vite.svg b/mission/chapter08/mission_3/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/mission/chapter08/mission_3/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/mission/chapter08/mission_3/src/components/Button.tsx b/mission/chapter08/mission_3/src/components/Button.tsx new file mode 100644 index 00000000..3258f21e --- /dev/null +++ b/mission/chapter08/mission_3/src/components/Button.tsx @@ -0,0 +1,35 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + variant?: 'primary' | 'secondary' | 'google'; +} + +const Button = ({ + children, + variant = 'primary', + className = '', + disabled, + ...rest +}: ButtonProps) => { + const variantStyles = { + primary: + 'w-full bg-blue-300 text-white py-3 rounded-md text-lg font-medium hover:bg-blue-500 transition-colors cursor-pointer disabled:bg-gray-300 disabled:cursor-not-allowed', + secondary: + 'px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 transition-colors cursor-pointer disabled:bg-gray-100 disabled:cursor-not-allowed', + google: + 'w-full flex items-center justify-center gap-2 border border-gray-300 py-3 rounded-md text-lg font-medium hover:bg-gray-100 transition-colors cursor-pointer disabled:bg-gray-100 disabled:cursor-not-allowed', + }; + + return ( + + ); +}; + +export default Button; diff --git a/mission/chapter08/mission_3/src/components/CommentSkeleton.tsx b/mission/chapter08/mission_3/src/components/CommentSkeleton.tsx new file mode 100644 index 00000000..60c1dec9 --- /dev/null +++ b/mission/chapter08/mission_3/src/components/CommentSkeleton.tsx @@ -0,0 +1,14 @@ +const CommentSkeleton = () => { + return ( +
+
+
+
+
+
+
+
+ ); +}; + +export default CommentSkeleton; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/components/CreateLpModal.tsx b/mission/chapter08/mission_3/src/components/CreateLpModal.tsx new file mode 100644 index 00000000..f317ea07 --- /dev/null +++ b/mission/chapter08/mission_3/src/components/CreateLpModal.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { createLp } from '../api/lp'; +import { uploadImage } from '../api/upload'; + +interface CreateLpModalProps { + onClose: () => void; +} + +const CreateLpModal = ({ onClose }: CreateLpModalProps) => { + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState([]); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + + const { mutate: createLpMutate, isPending } = useMutation({ + mutationFn: async () => { + let thumbnailUrl = ''; + + if (thumbnailFile) { + thumbnailUrl = await uploadImage(thumbnailFile); + } + + return createLp({ + title, + content, + thumbnail: thumbnailUrl, + tags, + published: true, + }); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + onClose(); + }, + + onError: (error) => { + console.error('LP 생성 실패:', error); + alert('LP 생성에 실패했습니다.'); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setThumbnailFile(file); + + setPreviewUrl((prev) => { + if (prev?.startsWith('blob:')) { + URL.revokeObjectURL(prev); + } + + return URL.createObjectURL(file); + }); + }; + + useEffect(() => { + return () => { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + const handleAddTag = () => { + const trimmed = tagInput.trim(); + + if (!trimmed || tags.includes(trimmed)) return; + + setTags((prev) => [...prev, trimmed]); + setTagInput(''); + }; + + const handleRemoveTag = (tag: string) => { + setTags((prev) => prev.filter((t) => t !== tag)); + }; + + const handleTagKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleSubmit = () => { + if (!title.trim()) { + alert('LP 이름을 입력해주세요.'); + return; + } + + createLpMutate(); + }; + + const handleBackdropClick = () => { + onClose(); + }; + + return ( +
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + +
fileInputRef.current?.click()} + > + {previewUrl ? ( + preview + ) : ( +
+ + + + + + + + + + + 클릭해서 사진 추가 + +
+ )} +
+ + + + setTitle(e.target.value)} + placeholder="LP Name" + className="bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + + setContent(e.target.value)} + placeholder="LP Content" + className="bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + +
+ setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + placeholder="LP Tag" + className="flex-1 bg-[#3a3a3a] text-white placeholder-gray-500 rounded-lg px-4 py-3 outline-none focus:ring-2 focus:ring-pink-500 transition-all" + /> + + +
+ + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + + + ))} +
+ )} + + +
+
+ ); +}; + +export default CreateLpModal; diff --git a/mission/chapter08/mission_3/src/components/DeleteConfirmModal.tsx b/mission/chapter08/mission_3/src/components/DeleteConfirmModal.tsx new file mode 100644 index 00000000..0fa52634 --- /dev/null +++ b/mission/chapter08/mission_3/src/components/DeleteConfirmModal.tsx @@ -0,0 +1,33 @@ +interface DeleteConfirmModalProps { + onConfirm: () => void; + onCancel: () => void; + isPending?: boolean; +} + +const DeleteConfirmModal = ({ onConfirm, onCancel, isPending }: DeleteConfirmModalProps) => { + return ( +
+
+

정말 탈퇴하시겠습니까?

+

탈퇴 후에는 모든 데이터가 삭제되며 복구할 수 없습니다.

+
+ + +
+
+
+ ); +}; + +export default DeleteConfirmModal; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/components/Input.tsx b/mission/chapter08/mission_3/src/components/Input.tsx new file mode 100644 index 00000000..182c8224 --- /dev/null +++ b/mission/chapter08/mission_3/src/components/Input.tsx @@ -0,0 +1,30 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + hasError?: boolean; + errorMessage?: string; +} + +const Input = forwardRef( + ({ hasError = false, errorMessage, className = '', ...rest }, ref) => { + return ( +
+ + + {hasError && errorMessage && ( +

{errorMessage}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; + +export default Input; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/components/LpCard.tsx b/mission/chapter08/mission_3/src/components/LpCard.tsx new file mode 100644 index 00000000..58f4f7c1 --- /dev/null +++ b/mission/chapter08/mission_3/src/components/LpCard.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom'; +import type { Lp } from '../types/lp'; + +interface LpCardProps { + lp: Lp; +} + +const LpCard = ({ lp }: LpCardProps) => { + const navigate = useNavigate(); + + return ( +
navigate(`/lps/${lp.id}`)} + className="relative group cursor-pointer rounded-lg overflow-hidden aspect-square bg-gray-200" + > + {/* 썸네일 */} + {lp.title} + + {/* 호버 시 오버레이 */} +
+

{lp.title}

+
+ + {new Date(lp.createdAt).toLocaleDateString('ko-KR')} + + + ❤️ {lp.likes?.length ?? 0} + +
+ {/* 태그 */} +
+ {lp.tags?.slice(0, 3).map((tag) => ( + + #{tag.name} + + ))} +
+
+
+ ); +}; + +export default LpCard; diff --git a/mission/chapter08/mission_3/src/components/LpCardSkeleton.tsx b/mission/chapter08/mission_3/src/components/LpCardSkeleton.tsx new file mode 100644 index 00000000..0bd3ee3d --- /dev/null +++ b/mission/chapter08/mission_3/src/components/LpCardSkeleton.tsx @@ -0,0 +1,7 @@ +const LpCardSkeleton = () => { + return ( +
+ ); +}; + +export default LpCardSkeleton; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/components/Navbar.tsx b/mission/chapter08/mission_3/src/components/Navbar.tsx new file mode 100644 index 00000000..89dd632d --- /dev/null +++ b/mission/chapter08/mission_3/src/components/Navbar.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; + +interface NavbarProps { + onMenuClick: () => void; +} + +const Navbar = ({ onMenuClick }: NavbarProps) => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + // ── 로그아웃 mutation ── + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { + await logout(); + }, + onSuccess: () => { + navigate('/'); + }, + }); + + return ( + + ); +}; + +export default Navbar; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/components/ProtectedRoute.tsx b/mission/chapter08/mission_3/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..77d974dd --- /dev/null +++ b/mission/chapter08/mission_3/src/components/ProtectedRoute.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth(); + const location = useLocation(); // 현재 경로 저장 + const [showModal, setShowModal] = useState(true); + + if (!user) { + // 모달 닫고 로그인으로 + if (!showModal) { + return ; + // state={{ from: location }} → 원래 가려던 경로 저장! + } + + return ( + // 경고 모달 +
+
+

로그인이 필요합니다

+

이 페이지는 로그인 후 이용할 수 있습니다.

+ +
+
+ ); + } + + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/components/Sidebar.tsx b/mission/chapter08/mission_3/src/components/Sidebar.tsx new file mode 100644 index 00000000..76e837f7 --- /dev/null +++ b/mission/chapter08/mission_3/src/components/Sidebar.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { deleteMyAccount } from '../api/user'; +import DeleteConfirmModal from './DeleteConfirmModal'; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; + isStatic?: boolean; +} + +const Sidebar = ({ isOpen, onClose, isStatic = false }: SidebarProps) => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + const [search, setSearch] = useState(''); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { + await logout(); + }, + onSuccess: () => { + if (!isStatic) onClose(); + navigate('/'); + }, + }); + + const { mutate: deleteAccountMutate, isPending: isDeleting } = useMutation({ + mutationFn: deleteMyAccount, + onSuccess: () => { + logout(); + navigate('/login'); + }, + onError: () => { + alert('회원 탈퇴에 실패했습니다.'); + }, + }); + + const handleNavigate = (path: string) => { + navigate(path); + if (!isStatic) onClose(); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (search.trim()) { + navigate(`/?search=${search}`); + if (!isStatic) onClose(); + } + }; + + const content = ( +
+ {/* 검색창 */} +
+ setSearch(e.target.value)} + placeholder="검색..." + className="border border-gray-600 bg-transparent rounded-md px-3 py-2 text-sm outline-none focus:border-pink-400 text-white" + /> + +
+ +
+ + {/* 메뉴 */} + + + {/* 하단 로그인/로그아웃 */} +
+ {user ? ( +
+ {user.nickname}님 + + +
+ ) : ( +
+ + +
+ )} +
+ + {showDeleteModal && ( + deleteAccountMutate()} + onCancel={() => setShowDeleteModal(false)} + isPending={isDeleting} + /> + )} +
+ ); + + // 데스크탑 정적 사이드바 + if (isStatic) { + return
{content}
; + } + + // 모바일 드로어 사이드바 + return ( + <> + {/* 배경 오버레이 - opacity 트랜지션으로 fade in/out */} +
+ + {/* 사이드바 패널 - translate-x 트랜지션으로 슬라이드 in/out */} + + + ); +}; + +export default Sidebar; diff --git a/mission/chapter08/mission_3/src/context/AuthContext.tsx b/mission/chapter08/mission_3/src/context/AuthContext.tsx new file mode 100644 index 00000000..d85baa05 --- /dev/null +++ b/mission/chapter08/mission_3/src/context/AuthContext.tsx @@ -0,0 +1,102 @@ +import { createContext, useContext, useState } from 'react'; +import axiosInstance from '../api/axios'; + +interface User { + id: number; + email: string; + nickname: string; +} + +interface AuthContextType { + user: User | null; + login: (user: User, accessToken: string, refreshToken: string) => void; + logout: () => void; + updateNickname: (nickname: string) => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [user, setUser] = useState(() => { + try { + const stored = localStorage.getItem('user'); + + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.error('유저 정보 파싱 실패:', error); + + localStorage.removeItem('user'); + + return null; + } + }); + + const login = ( + userData: User, + accessToken: string, + refreshToken: string + ) => { + setUser(userData); + + localStorage.setItem('user', JSON.stringify(userData)); + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + }; + + const logout = async () => { + try { + await axiosInstance.post('/auth/signout'); + } catch (error) { + console.error('로그아웃 실패:', error); + } finally { + setUser(null); + + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }; + + // 닉네임만 즉시 업데이트 + const updateNickname = (nickname: string) => { + setUser((prev) => { + if (!prev) return prev; + + const updated = { + ...prev, + nickname, + }; + + localStorage.setItem('user', JSON.stringify(updated)); + + return updated; + }); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('AuthProvider 밖에서 사용 불가!'); + } + + return context; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useDebounce.ts b/mission/chapter08/mission_3/src/hooks/useDebounce.ts new file mode 100644 index 00000000..f03d70c9 --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useInfiniteComments.ts b/mission/chapter08/mission_3/src/hooks/useInfiniteComments.ts new file mode 100644 index 00000000..fc05cb01 --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useInfiniteComments.ts @@ -0,0 +1,18 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getComments } from '../api/lp'; +import type { CommentListResponse } from '../types/lp'; + +const useInfiniteComments = (lpId: number, order: 'asc' | 'desc') => { + return useInfiniteQuery({ + queryKey: ['lpComments', lpId, order], + queryFn: ({ pageParam }) => getComments(lpId, order, pageParam as number), + getNextPageParam: (lastPage) => { + return lastPage.data.hasNext ? lastPage.data.nextCursor : undefined; + }, + initialPageParam: 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export default useInfiniteComments; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useInfiniteLps.ts b/mission/chapter08/mission_3/src/hooks/useInfiniteLps.ts new file mode 100644 index 00000000..5a12c1cb --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useInfiniteLps.ts @@ -0,0 +1,25 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getLps } from '../api/lp'; + +const useInfiniteLps = (order: 'asc' | 'desc', search: string = '') => { + const trimmedSearch = search.trim(); + + return useInfiniteQuery({ + queryKey: ['search', order, trimmedSearch], + + queryFn: ({ pageParam = 0 }) => getLps(order, pageParam, trimmedSearch), + + getNextPageParam: (lastPage) => { + return lastPage.data.hasNext ? lastPage.data.nextCursor : undefined; + }, + + initialPageParam: 0, + + enabled: true, + + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +}; + +export default useInfiniteLps; diff --git a/mission/chapter08/mission_3/src/hooks/useLocalStorage.ts b/mission/chapter08/mission_3/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..c6a3ad1b --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +function useLocalStorage(key: string, initialValue: T) { + // 로컬스토리지에서 값을 가져오는 함수 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + // 저장된 값이 있으면 파싱해서 반환, 없으면 초기값 반환 + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // 값을 저장하는 함수 + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export default useLocalStorage; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useLpDetail.ts b/mission/chapter08/mission_3/src/hooks/useLpDetail.ts new file mode 100644 index 00000000..300c788f --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useLpDetail.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLpDetail } from '../api/lp'; + +const useLpDetail = (lpId: number) => { + return useQuery({ + queryKey: ['lp', lpId], // lpId 포함 + queryFn: () => getLpDetail(lpId), + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + }); +}; + +export default useLpDetail; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useLps.ts b/mission/chapter08/mission_3/src/hooks/useLps.ts new file mode 100644 index 00000000..731431b6 --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useLps.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLps } from '../api/lp'; + +const useLps = (order: 'asc' | 'desc') => { + return useQuery({ + queryKey: ['lps', order], // order 바뀌면 자동으로 리패치 + queryFn: () => getLps(order), + staleTime: 1000 * 60 * 5, // 5분 동안 캐시 유지 + gcTime: 1000 * 60 * 10, // 10분 후 캐시 삭제 + }); +}; + +export default useLps; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useMyPage.ts b/mission/chapter08/mission_3/src/hooks/useMyPage.ts new file mode 100644 index 00000000..d48d78c1 --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useMyPage.ts @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { updateMyProfile, deleteMyAccount } from '../api/user'; + +export const useMyPage = () => { + const navigate = useNavigate(); + const { user, logout, updateNickname } = useAuth(); + const queryClient = useQueryClient(); + + const { mutate: updateProfileMutate, isPending: isUpdating } = useMutation({ + mutationFn: (profileData: { name: string; bio: string; avatar: string }) => + updateMyProfile(profileData), + onMutate: async (profileData) => { + const previousNickname = user?.nickname; + updateNickname(profileData.name); + return { previousNickname }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['myProfile'] }); + alert('프로필이 수정되었습니다.'); + }, + onError: (_error, _variables, context) => { + if (context?.previousNickname) updateNickname(context.previousNickname); + alert('프로필 수정에 실패했습니다.'); + }, + }); + + const { mutate: deleteAccountMutate, isPending: isDeleting } = useMutation({ + mutationFn: deleteMyAccount, + onSuccess: () => { logout(); navigate('/login'); }, + onError: () => { alert('회원 탈퇴에 실패했습니다.'); }, + }); + + const { mutate: logoutMutate } = useMutation({ + mutationFn: async () => { await logout(); }, + onSuccess: () => { navigate('/'); }, + }); + + return { + user, + isUpdating, + isDeleting, + updateProfileMutate, + deleteAccountMutate, + logoutMutate, + }; +}; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useSidebar.ts b/mission/chapter08/mission_3/src/hooks/useSidebar.ts new file mode 100644 index 00000000..576902c0 --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useSidebar.ts @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react'; + +function useSidebar() { + const [isOpen, setIsOpen] = useState(false); + + const open = () => setIsOpen(true); + const close = () => setIsOpen(false); + const toggle = () => setIsOpen((prev) => !prev); + + // ESC 키로 닫기 + 클린업으로 메모리 누수 방지 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + close(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + // 클린업: 컴포넌트 언마운트 시 이벤트 리스너 제거 + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + // 사이드바 열릴 때 배경 스크롤 방지 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + // 클린업: 언마운트 시 overflow 초기화 + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + return { isOpen, open, close, toggle }; +} + +export default useSidebar; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/hooks/useThrottle.ts b/mission/chapter08/mission_3/src/hooks/useThrottle.ts new file mode 100644 index 00000000..3d3eba6b --- /dev/null +++ b/mission/chapter08/mission_3/src/hooks/useThrottle.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from 'react'; + +function useThrottle(value: T, interval: number = 1000): T { + const [throttledValue, setThrottledValue] = useState(value); + + const lastExecuted = useRef(0); + const latestValue = useRef(value); + const timerRef = useRef | null>(null); + + useEffect(() => { + latestValue.current = value; + + if (timerRef.current) return; + + const elapsed = Date.now() - lastExecuted.current; + const remainingTime = interval - elapsed; + + if (remainingTime <= 0) { + setThrottledValue(latestValue.current); + lastExecuted.current = Date.now(); + return; + } + + timerRef.current = setTimeout(() => { + setThrottledValue(latestValue.current); + lastExecuted.current = Date.now(); + timerRef.current = null; + }, remainingTime); + }, [value, interval]); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + return throttledValue; +} + +export default useThrottle; diff --git a/mission/chapter08/mission_3/src/index.css b/mission/chapter08/mission_3/src/index.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/mission/chapter08/mission_3/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter08/mission_3/src/layouts/HomeLayout.tsx b/mission/chapter08/mission_3/src/layouts/HomeLayout.tsx new file mode 100644 index 00000000..7eaba748 --- /dev/null +++ b/mission/chapter08/mission_3/src/layouts/HomeLayout.tsx @@ -0,0 +1,32 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import Sidebar from '../components/Sidebar'; +import useSidebar from '../hooks/useSidebar'; + +const HomeLayout = () => { + const { isOpen, open, close } = useSidebar(); + + return ( +
+ +
+ {/* 사이드바 - md 이상에서는 항상 보임 */} + + + {/* 메인 콘텐츠 */} +
+ +
+
+ + {/* 모바일 사이드바 */} +
+ +
+
+ ); +}; + +export default HomeLayout; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/main.tsx b/mission/chapter08/mission_3/src/main.tsx new file mode 100644 index 00000000..896bc7ce --- /dev/null +++ b/mission/chapter08/mission_3/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import './index.css'; +import App from './App.tsx'; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById('root')!).render( + + + + + + , +); diff --git a/mission/chapter08/mission_3/src/pages/GoogleCallBackPage.tsx b/mission/chapter08/mission_3/src/pages/GoogleCallBackPage.tsx new file mode 100644 index 00000000..866cfa46 --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/GoogleCallBackPage.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const GoogleCallbackPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + const name = params.get('name') || ''; + const userId = params.get('userId') || ''; + + if (accessToken && refreshToken) { + login({ email: userId, nickname: name }, accessToken, refreshToken); + navigate('/'); + } else { + navigate('/login'); + } + }, []); + + return ( +
+

로그인 처리 중...

+
+ ); +}; + +export default GoogleCallbackPage; diff --git a/mission/chapter08/mission_3/src/pages/HomePage.tsx b/mission/chapter08/mission_3/src/pages/HomePage.tsx new file mode 100644 index 00000000..968f2ae5 --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/HomePage.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; +import useInfiniteLps from '../hooks/useInfiniteLps'; +import useDebounce from '../hooks/useDebounce'; +import useThrottle from '../hooks/useThrottle'; + +import LpCard from '../components/LpCard'; +import LpCardSkeleton from '../components/LpCardSkeleton'; +import CreateLpModal from '../components/CreateLpModal'; + +const HomePage = () => { + const [order, setOrder] = useState<'asc' | 'desc'>('desc'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [search, setSearch] = useState(''); + + const bottomRef = useRef(null); + + const debouncedSearch = useDebounce(search, 300); + + const [scrollTrigger, setScrollTrigger] = useState(0); + const throttledScrollTrigger = useThrottle(scrollTrigger, 1000); + + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteLps(order, debouncedSearch); + + const lps = data?.pages.flatMap((page) => page.data.data) ?? []; + + useEffect(() => { + if (isLoading || isError) return; + if (!bottomRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + console.log('[Observer] 바닥 감지'); + setScrollTrigger((prev) => prev + 1); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(bottomRef.current); + + return () => observer.disconnect(); + }, [isLoading, isError]); + + useEffect(() => { + if (throttledScrollTrigger > 0 && hasNextPage && !isFetchingNextPage) { + console.log('[Throttle] 1초에 한 번만 다음 페이지 요청'); + fetchNextPage(); + } + }, [throttledScrollTrigger, hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+
+ setSearch(e.target.value)} + placeholder="LP 검색..." + className="w-full border border-gray-600 bg-transparent rounded-md px-4 py-2 text-sm outline-none focus:border-pink-400 text-white" + /> + + {search !== debouncedSearch && ( +

입력 감지 중...

+ )} +
+ +
+ + + +
+ + {debouncedSearch && !isLoading && ( +

+ "{debouncedSearch}" 검색 결과{' '} + {lps.length === 0 ? '없음' : `${lps.length}건`} +

+ )} + + {isLoading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} + + {isError && ( +
+

데이터를 불러오는데 실패했습니다.

+ + +
+ )} + + {!isLoading && !isError && ( + <> + {lps.length === 0 && debouncedSearch ? ( +
+

검색 결과가 없습니다.

+
+ ) : ( +
+ {lps.map((lp) => ( + + ))} +
+ )} + + {isFetchingNextPage && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + +
+ + )} + + + + {isModalOpen && setIsModalOpen(false)} />} +
+ ); +}; + +export default HomePage; diff --git a/mission/chapter08/mission_3/src/pages/LoginPage.tsx b/mission/chapter08/mission_3/src/pages/LoginPage.tsx new file mode 100644 index 00000000..554d1fae --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/LoginPage.tsx @@ -0,0 +1,114 @@ +import { useNavigate, useLocation } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { signinSchema, type SigninFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; +import { signin } from '../api/auth'; +import Button from '../components/Button'; +import Input from '../components/Input'; + +const LoginPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { login } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onChange', + }); + + const from = (location.state as { from?: Location })?.from?.pathname || '/'; + + const { mutate: signinMutate, isPending } = useMutation({ + mutationFn: ({ email, password }: SigninFormValues) => + signin(email, password), + + onSuccess: (data, variables) => { + console.log('로그인 응답:', data.data); + + const { name, accessToken, refreshToken, id, userId } = data.data; + + login( + { + id: Number(id ?? userId), + email: variables.email, + nickname: name, + }, + accessToken, + refreshToken, + ); + + navigate(from, { replace: true }); + }, + + onError: () => { + alert('이메일 또는 비밀번호가 올바르지 않습니다.'); + }, + }); + + const onSubmit = (data: SigninFormValues) => { + signinMutate(data); + }; + + const onGoogleLogin = () => { + window.location.href = import.meta.env.VITE_GOOGLE_LOGIN_URL; + }; + + return ( +
+
+ +

로그인

+
+ +
+
+ + + + + + + +
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/pages/LpDetailPage.tsx b/mission/chapter08/mission_3/src/pages/LpDetailPage.tsx new file mode 100644 index 00000000..e71dfb18 --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/LpDetailPage.tsx @@ -0,0 +1,457 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import useLpDetail from '../hooks/useLpDetail'; +import useInfiniteComments from '../hooks/useInfiniteComments'; +import { useAuth } from '../context/AuthContext'; +import { + likeLp, + unlikeLp, + deleteLp, + createComment, + updateComment, + deleteComment, +} from '../api/lp'; +import CommentSkeleton from '../components/CommentSkeleton'; +import type { Comment } from '../types/lp'; + +const LpDetailPage = () => { + const { lpId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const bottomRef = useRef(null); + + const [commentOrder, setCommentOrder] = useState<'asc' | 'desc'>('asc'); + const [commentText, setCommentText] = useState(''); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + const [openMenuId, setOpenMenuId] = useState(null); + + const { data, isLoading, isError, refetch } = useLpDetail(Number(lpId)); + + const { + data: commentsData, + isLoading: isCommentsLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteComments(Number(lpId), commentOrder); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + if (bottomRef.current) observer.observe(bottomRef.current); + + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const lp = data?.data; + + const comments: Comment[] = + (commentsData as any)?.pages?.flatMap((page: any) => page.data.data) ?? []; + + const { mutate: likeMutate } = useMutation({ + mutationFn: () => { + const alreadyLiked = lp?.likes?.some( + (like: any) => Number(like.userId) === Number(user?.id), + ); + + return alreadyLiked ? unlikeLp(Number(lpId)) : likeLp(Number(lpId)); + }, + + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: ['lp', Number(lpId)], + }); + + const previousData = queryClient.getQueryData(['lp', Number(lpId)]); + + queryClient.setQueryData(['lp', Number(lpId)], (old: any) => { + if (!old || !user) return old; + + const currentLp = old.data; + + const alreadyLiked = currentLp.likes?.some( + (like: any) => Number(like.userId) === Number(user.id), + ); + + return { + ...old, + data: { + ...currentLp, + likes: alreadyLiked + ? currentLp.likes.filter( + (like: any) => Number(like.userId) !== Number(user.id), + ) + : [ + ...(currentLp.likes ?? []), + { + id: Date.now(), + userId: user.id, + lpId: Number(lpId), + }, + ], + }, + }; + }); + + return { previousData }; + }, + + onError: (_error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(['lp', Number(lpId)], context.previousData); + } + + alert('좋아요 처리에 실패했습니다.'); + }, + + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ['lp', Number(lpId)], + }); + }, + }); + + const { mutate: deleteLpMutate, isPending: isDeletingLp } = useMutation({ + mutationFn: () => deleteLp(Number(lpId)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + navigate('/'); + }, + onError: () => alert('LP 삭제에 실패했습니다.'), + }); + + const { mutate: createCommentMutate, isPending: isCreatingComment } = + useMutation({ + mutationFn: (content: string) => createComment(Number(lpId), content), + onSuccess: () => { + setCommentText(''); + queryClient.invalidateQueries({ + queryKey: ['lpComments', Number(lpId)], + }); + }, + onError: () => alert('댓글 작성에 실패했습니다.'), + }); + + const { mutate: updateCommentMutate, isPending: isUpdatingComment } = + useMutation({ + mutationFn: ({ + commentId, + content, + }: { + commentId: number; + content: string; + }) => updateComment(Number(lpId), commentId, content), + onSuccess: () => { + setEditingCommentId(null); + setEditingContent(''); + queryClient.invalidateQueries({ + queryKey: ['lpComments', Number(lpId)], + }); + }, + onError: () => alert('댓글 수정에 실패했습니다.'), + }); + + const { mutate: deleteCommentMutate } = useMutation({ + mutationFn: (commentId: number) => deleteComment(Number(lpId), commentId), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['lpComments', Number(lpId)] }), + onError: () => alert('댓글 삭제에 실패했습니다.'), + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError || !lp) { + return ( +
+

데이터를 불러오는데 실패했습니다.

+ +
+ ); + } + + const lpAuthorId = (lp as any).authorId ?? (lp as any).author?.id; + const isLpOwner = + user && lpAuthorId && Number(user.id) === Number(lpAuthorId); + + const isCommentOwner = (comment: any) => { + const commentAuthorId = comment.authorId ?? comment.author?.id; + return ( + user && commentAuthorId && Number(user.id) === Number(commentAuthorId) + ); + }; + + const isLiked = lp.likes?.some( + (like: any) => Number(like.userId) === Number(user?.id), + ); + + return ( +
+ + + {lp.title} + +

{lp.title}

+ +
+ {new Date(lp.createdAt).toLocaleDateString('ko-KR')} + ❤️ {lp.likes?.length ?? 0} +
+ +
+ {lp.tags?.map((tag) => ( + + #{tag.name} + + ))} +
+ +

{lp.content}

+ +
+ + + {isLpOwner && ( + <> + + + + + )} +
+ +
+
+

댓글

+ +
+ + + +
+
+ +
{ + e.preventDefault(); + if (!commentText.trim()) return; + createCommentMutate(commentText); + }} + className="flex gap-2 mb-6" + > + setCommentText(e.target.value)} + placeholder="댓글을 입력해주세요" + className="flex-1 bg-[#1e1e1e] border border-gray-700 rounded-md px-4 py-2 text-sm outline-none focus:border-pink-500 text-white" + /> + + +
+ + {isCommentsLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!isCommentsLoading && ( +
+ {comments.map((comment: any) => ( +
+
+
+
+ {comment.author?.name?.[0] ?? '?'} +
+ + + {comment.author?.name} + + + + {new Date(comment.createdAt).toLocaleDateString('ko-KR')} + +
+ + {isCommentOwner(comment) && ( +
+ + + {openMenuId === comment.id && ( +
+ + + +
+ )} +
+ )} +
+ + {editingCommentId === comment.id ? ( +
+ setEditingContent(e.target.value)} + className="flex-1 bg-[#1e1e1e] border border-gray-600 rounded-md px-3 py-1 text-sm text-white outline-none focus:border-pink-500" + /> + + +
+ ) : ( +

+ {comment.content} +

+ )} +
+ ))} + + {isFetchingNextPage && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + +
+
+ )} +
+
+ ); +}; + +export default LpDetailPage; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/pages/MyPage.tsx b/mission/chapter08/mission_3/src/pages/MyPage.tsx new file mode 100644 index 00000000..837cea95 --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/MyPage.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import DeleteConfirmModal from '../components/DeleteConfirmModal'; +import { useMyPage } from '../hooks/useMyPage'; + +const MyPage = () => { + const navigate = useNavigate(); + const { + user, + isUpdating, + isDeleting, + updateProfileMutate, + deleteAccountMutate, + logoutMutate, +} = useMyPage(); + + const [isEditing, setIsEditing] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [name, setName] = useState(user?.nickname ?? ''); + const [bio, setBio] = useState(''); + const [avatar, setAvatar] = useState(''); + + + + const handleProfileSubmit = () => { + if (!name.trim()) { + alert('닉네임을 입력해주세요.'); + return; + } + + updateProfileMutate({ + name, + bio, + avatar, + }); + + setIsEditing(false); +}; + + return ( +
+
+ +

마이페이지

+
+ +
+ {isEditing ? ( +
+
+ {avatar ? ( + avatar + ) : ( + 👤 + )} +
+ +
+ setName(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500 pr-10" + placeholder="닉네임" + /> + + +
+ + setBio(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500" + placeholder="bio" + /> + + setAvatar(e.target.value)} + className="w-full bg-transparent border border-gray-600 rounded-lg px-4 py-2 text-black outline-none focus:border-pink-500" + placeholder="avatar URL" + /> + +

{user?.email}

+ + +
+ ) : ( + <> +
+
+ {avatar ? ( + avatar + ) : ( + '👤' + )} +
+ + +
+ +
+

+ {user?.nickname} +

+

{user?.email}

+
+ + + + + + )} +
+ + {showDeleteModal && ( + deleteAccountMutate()} + onCancel={() => setShowDeleteModal(false)} + isPending={isDeleting} + /> + )} +
+ ); +}; + +export default MyPage; diff --git a/mission/chapter08/mission_3/src/pages/NotFoundPage.tsx b/mission/chapter08/mission_3/src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..6f316cb9 --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/NotFoundPage.tsx @@ -0,0 +1,5 @@ +const NotFound = () => { + return
찾을 수 없는 페이지입니다
; +}; + +export default NotFound; diff --git a/mission/chapter08/mission_3/src/pages/SignupPage.tsx b/mission/chapter08/mission_3/src/pages/SignupPage.tsx new file mode 100644 index 00000000..d2f11148 --- /dev/null +++ b/mission/chapter08/mission_3/src/pages/SignupPage.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupSchema, type SignupFormValues } from '../utils/validate'; +import axiosInstance from '../api/axios'; + +const SignupPage = () => { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordCheck, setShowPasswordCheck] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormValues) => { + try { + // 서버에 회원가입 요청 + await axiosInstance.post('/auth/signup', { + name: data.name, + email: data.email, + password: data.password, + }); + + // 회원가입 성공 → 로그인 페이지로 이동 + alert('회원가입 성공! 로그인 해주세요 😊'); + navigate('/login'); + } catch (error) { + console.error('회원가입 실패:', error); + alert('회원가입에 실패했습니다.'); + } + }; + + const email = watch('email'); + const password = watch('password'); + const passwordCheck = watch('passwordCheck'); + const name = watch('name'); + + const isStep1Valid = !errors.email && email?.length > 0; + const isStep2Valid = + !errors.password && + !errors.passwordCheck && + password?.length > 0 && + passwordCheck?.length > 0 && + password === passwordCheck; + const isStep3Valid = !errors.name && name?.length > 0; + + return ( +
+
+ +

회원가입

+
+ +
+
+ {step === 1 && ( + <> + + {errors.email && ( +
{errors.email.message}
+ )} + + + )} + + {step === 2 && ( + <> +
{email}
+
+ + +
+ {errors.password && ( +
{errors.password.message}
+ )} +
+ + +
+ {passwordCheck?.length > 0 && password !== passwordCheck && ( +
비밀번호가 일치하지 않습니다
+ )} + + + )} + + {step === 3 && ( + <> + + {errors.name && ( +
{errors.name.message}
+ )} + + + )} +
+
+
+ ); +}; + +export default SignupPage; \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/types/lp.ts b/mission/chapter08/mission_3/src/types/lp.ts new file mode 100644 index 00000000..30719af3 --- /dev/null +++ b/mission/chapter08/mission_3/src/types/lp.ts @@ -0,0 +1,65 @@ +export interface Tag { + id: number; + name: string; +} + +export interface Like { + id: number; + userId: number; + lpId: number; +} + +export interface Lp { + id: number; + title: string; + content: string; + thumbnail: string; + published: boolean; + createdAt: string; + updatedAt: string; + tags: Tag[]; + likes: Like[]; +} + +// 댓글 타입 +export interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + lpId: number; + authorId: number; + author: { + id: number; + name: string; + }; +} +export interface LpListResponse { + status: boolean; + statusCode: number; + message: string; + data: { + data: Lp[]; + nextCursor: number | null; + hasNext: boolean; + }; +} + +export interface LpDetailResponse { + status: boolean; + statusCode: number; + message: string; + data: Lp; +} + +// 댓글 목록 응답 타입 +export interface CommentListResponse { + status: boolean; + statusCode: number; + message: string; + data: { + data: Comment[]; + nextCursor: number | null; + hasNext: boolean; + }; +} \ No newline at end of file diff --git a/mission/chapter08/mission_3/src/utils/validate.ts b/mission/chapter08/mission_3/src/utils/validate.ts new file mode 100644 index 00000000..6fddb70b --- /dev/null +++ b/mission/chapter08/mission_3/src/utils/validate.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const signinSchema = z.object({ + email: z.string().email('올바른 이메일 형식이 아닙니다'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .max(20, '비밀번호는 20자 이하여야 합니다'), +}); + +export const signupSchema = z + .object({ + name: z.string().min(1, '이름을 입력해주세요'), + email: z.string().email('올바른 이메일 형식이 아닙니다'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다') + .max(20, '비밀번호는 20자 이하여야 합니다'), + passwordCheck: z.string(), + }) + .refine((data) => data.password === data.passwordCheck, { + message: '비밀번호가 일치하지 않습니다', + path: ['passwordCheck'], + }); + +export type SigninFormValues = z.infer; +export type SignupFormValues = z.infer;