diff --git a/keyword/chapter08/debounce-and-throttle.md b/keyword/chapter08/debounce-and-throttle.md new file mode 100644 index 00000000..93b78aa2 --- /dev/null +++ b/keyword/chapter08/debounce-and-throttle.md @@ -0,0 +1,364 @@ +# Debounce & Throttle + +## 왜 필요한가 + +브라우저 이벤트(키 입력, 스크롤, 리사이즈, 마우스 이동)는 짧은 시간 안에 수십~수백 번 발생할 수 있음 + +핸들러가 매 이벤트마다 실행되면 다음과 같은 문제가 생김 + +- **불필요한 네트워크 요청** — 검색어 한 글자마다 API 호출 +- **렌더링 부하** — 스크롤 핸들러가 1초에 60번 실행되며 레이아웃 계산 반복 +- **배터리·CPU 낭비** — 특히 모바일 환경에서 두드러짐 + +이를 해결하는 두 가지 전략이 **Debounce**와 **Throttle**임 + +--- + +## Debounce + +### 개념 + +이벤트가 연달아 발생하는 동안에는 실행을 미루다가, **마지막 이벤트 이후 지정한 시간(delay)이 지나면 딱 한 번 실행**하는 기법 + +- 새 이벤트가 들어올 때마다 **기존 타이머를 취소하고 새 타이머를 시작**함 +- 핵심: **"타이머 리셋"** +- 실행 결과가 **마지막 입력 기준으로 결정**되는 상황에 적합 + +### 시각적 흐름 (delay = 300ms) + +``` +시간(ms) 0 100 200 300 400 500 +키 입력 A─────B─────C +타이머 ↻리셋 ↻리셋 └────대기 300ms────┘ +실행 ⚡ (1회) +``` + +- A(0ms), B(100ms), C(200ms) 모두 300ms 안에 이어져 타이머가 매번 리셋됨 +- C 이후 300ms 동안 추가 입력 없음 → 500ms에 단 한 번 실행 + +### Trailing vs Leading + +| 모드 | 실행 시점 | 특징 | +|------|----------|------| +| **trailing** (기본) | 마지막 이벤트 후 delay 경과 시 | 입력이 끝난 뒤 응답 | +| **leading** | 첫 이벤트 즉시 실행, 이후 delay 동안 추가 실행 억제 | 즉각 반응 + 연타 방지 | +| **leading + trailing** | 첫 이벤트와 마지막 이벤트 모두 실행 | 시작과 끝 모두 처리 | + +> Lodash `_.debounce`는 `{ leading: true, trailing: false }` 옵션으로 모드를 바꿀 수 있음 + +### maxWait 옵션 + +```ts +debounce(fn, 300, { maxWait: 1000 }) +``` + +이벤트가 **계속 들어와도 최대 1000ms 안에는 반드시 한 번 실행**되도록 보장 + +입력이 끊이지 않으면 영영 실행되지 않는 상황을 방지함 + +### 직접 구현 + +```ts +function debounce( + fn: (...args: T) => void, + delay: number, +): (...args: T) => void { + let timerId: ReturnType | null = null; + + return function (...args: T) { + if (timerId !== null) clearTimeout(timerId); + + timerId = setTimeout(() => { + fn(...args); + timerId = null; + }, delay); + }; +} +``` + +### React에서 사용: `useDebounce` 훅 + +```tsx +import { useState, useEffect } from 'react'; + +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); // 클린업: 다음 렌더 전에 타이머 취소 + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; +``` + +**사용 예시 — 검색 자동완성** + +```tsx +function SearchInput() { + const [query, setQuery] = useState(''); + const debouncedQuery = useDebounce(query, 300); + + useEffect(() => { + if (!debouncedQuery) return; + fetch(`/api/search?q=${debouncedQuery}`); + }, [debouncedQuery]); + + return setQuery(e.target.value)} />; +} +``` + +- `query`가 바뀔 때마다 UI는 즉시 반응(입력창 내용 갱신) +- 실제 API 호출은 300ms 동안 입력이 멈춰야 발생 → `"apple"` 입력 시 5회 → 1회로 감소 + +--- + +## Throttle + +### 개념 + +이벤트가 아무리 자주 발생해도 **지정한 주기(interval)마다 최대 한 번씩만 실행**되도록 제한하는 기법 + +- 한 번 실행하면 interval 동안 **쿨다운(cooldown)** 이 걸림 +- 핵심: **"쿨다운 윈도우"** +- 이벤트가 진행 중에도 **주기적으로 반응**해야 할 때 적합 + +### 시각적 흐름 (interval = 300ms) + +``` +시간(ms) 0 100 200 300 400 500 600 700 +이벤트 A─────B─────C──────────D────────────E +쿨다운 │←──── 300ms 무시 ────→│←─ 300ms ──→│ +실행 ⚡ ⚡ ⚡ +``` + +- A(0ms) → 즉시 실행, 300ms 쿨다운 시작 +- B(100ms), C(200ms) → 쿨다운 구간이라 **무시** +- D(400ms) → 쿨다운 종료 → **재실행** +- E(700ms) → 쿨다운 종료 → **재실행** + +### 직접 구현 + +```ts +function throttle( + fn: (...args: T) => void, + interval: number, +): (...args: T) => void { + let lastTime = 0; + + return function (...args: T) { + const now = Date.now(); + if (now - lastTime >= interval) { + lastTime = now; + fn(...args); + } + }; +} +``` + +### React에서 사용: `useThrottle` 훅 + +```tsx +import { useRef, useCallback } from 'react'; + +function useThrottle( + fn: (...args: T) => void, + interval: number, +): (...args: T) => void { + const lastTimeRef = useRef(0); + + return useCallback( + (...args: T) => { + const now = Date.now(); + if (now - lastTimeRef.current >= interval) { + lastTimeRef.current = now; + fn(...args); + } + }, + [fn, interval], + ); +} + +export default useThrottle; +``` + +**사용 예시 — 무한 스크롤 트리거** + +```tsx +function InfiniteList() { + const { fetchNextPage, hasNextPage } = useInfiniteQuery(/* ... */); + + const handleScroll = useThrottle(() => { + const nearBottom = + window.innerHeight + window.scrollY >= document.body.offsetHeight - 300; + if (nearBottom && hasNextPage) fetchNextPage(); + }, 300); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [handleScroll]); + + return
{/* 목록 렌더링 */}
; +} +``` + +- 스크롤 이벤트는 초당 수십 번 발생하지만, `fetchNextPage`는 **300ms마다 최대 1회** 호출됨 + +--- + +## Debounce vs Throttle 비교 + +| 항목 | Debounce | Throttle | +|------|----------|----------| +| **실행 시점** | 마지막 이벤트 후 delay 경과 시 | 주기(interval)마다 최대 1회 | +| **이벤트 도중** | 실행 없음 (계속 미룸) | 주기마다 실행 | +| **사용자 체감** | 입력 끝나면 응답 | 도중에도 반응 | +| **검색 자동완성** | ✅ 적합 | ❌ 중간 글자 무시됨 | +| **스크롤 / 리사이즈** | ⚠️ 멈춰야만 반응 | ✅ 적합 | +| **무한 스크롤 트리거** | ⚠️ 한 박자 늦음 | ✅ 적합 | +| **버튼 연타 방지** | ✅ (leading 모드) | ✅ | +| **maxWait 옵션** | ✅ lodash 지원 | — | +| **입력 안 멈추면** | 영영 실행 안 될 수 있음 | 주기마다 꼬박꼬박 실행됨 | + +> **결정 팁**: "마지막 값만 중요하다" → Debounce / "도중에도 반응해야 한다" → Throttle + +--- + +## 라이브러리 활용 + +직접 구현 대신 **lodash**, **es-toolkit** 등을 쓰면 edge case까지 처리된 구현을 활용할 수 있음 + +```ts +import { debounce, throttle } from 'lodash-es'; + +const debouncedSearch = debounce((q: string) => fetch(`/api/search?q=${q}`), 300); +const throttledScroll = throttle(() => console.log(window.scrollY), 300); +``` + +> lodash는 트리쉐이킹을 위해 `lodash-es` 패키지를 사용하는 것이 권장됨 + +--- + +## React에서 주의할 점 + +### 컴포넌트 외부에서 생성 + +함수형 컴포넌트 안에서 `debounce(fn, 300)`을 직접 호출하면 **렌더마다 새 함수**가 생성되어 타이머가 매번 리셋됨 + +```tsx +// ❌ 렌더마다 새 debounce 함수 생성 +function Bad() { + const handler = debounce(() => fetch('/api'), 300); // 매 렌더에 재생성됨 + return ; +} + +// ✅ useCallback 또는 useRef로 참조 고정 +function Good() { + const handler = useCallback( + debounce(() => fetch('/api'), 300), + [], + ); + return ; +} +``` + +### 클린업 (메모리 누수 방지) + +컴포넌트 언마운트 시 pending 타이머가 남아 있으면 메모리 누수 또는 상태 업데이트 경고가 발생함 + +```tsx +useEffect(() => { + const debouncedFn = debounce(fn, 300); + element.addEventListener('input', debouncedFn); + return () => { + debouncedFn.cancel(); // lodash 제공 메서드로 pending 타이머 취소 + element.removeEventListener('input', debouncedFn); + }; +}, []); +``` + +--- + +## 직접 구현 vs 자동완성 비교 정리 (🍠 과제) + +### Debounce 개념 정리 + +- 이벤트 마지막 발생 후 일정 시간 동안 추가 발생이 없으면 그제서야 실행 +- 구현 핵심: `setTimeout` + `clearTimeout` +- 사용처: 검색 자동완성, 입력 폼 검증, 창 리사이즈 후 레이아웃 재계산 + +### Debounce 코드 + +```ts +function debounce( + fn: (...args: T) => void, + delay: number, +): (...args: T) => void { + let timerId: ReturnType | null = null; + + return function (...args: T) { + if (timerId !== null) clearTimeout(timerId); + timerId = setTimeout(() => { + fn(...args); + timerId = null; + }, delay); + }; +} + +// 사용 +const debouncedSearch = debounce((query: string) => { + console.log('검색:', query); +}, 300); + +document.querySelector('input')?.addEventListener('input', (e) => { + debouncedSearch((e.target as HTMLInputElement).value); +}); +``` + +### Throttle 개념 정리 + +- 이벤트 발생 빈도에 관계없이 지정된 주기 안에서 최대 1회만 실행 +- 구현 핵심: 마지막 실행 시각 기록 후 `Date.now() - lastTime >= interval` 조건 확인 +- 사용처: 스크롤, 리사이즈, 드래그, 마우스 이동, 무한 스크롤 트리거 + +### Throttle 코드 + +```ts +function throttle( + fn: (...args: T) => void, + interval: number, +): (...args: T) => void { + let lastTime = 0; + + return function (...args: T) { + const now = Date.now(); + if (now - lastTime >= interval) { + lastTime = now; + fn(...args); + } + }; +} + +// 사용 +const throttledScroll = throttle(() => { + console.log('스크롤 위치:', window.scrollY); +}, 300); + +window.addEventListener('scroll', throttledScroll); +``` + +--- + +## 참고 자료 + +- [MDN — setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) +- [MDN — clearTimeout](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) +- [Lodash — _.debounce](https://lodash.com/docs/#debounce) +- [Lodash — _.throttle](https://lodash.com/docs/#throttle) +- [Debounce vs Throttle: Definitive Visual Guide — kettanaito.com](https://kettanaito.com/blog/debounce-vs-throttle) +- [WHATWG HTML Living Standard — event loop processing model](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model) +- [React Docs — useEffect cleanup](https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) diff --git a/mission/chapter08/eslint.config.js b/mission/chapter08/eslint.config.js new file mode 100644 index 00000000..5e6b472f --- /dev/null +++ b/mission/chapter08/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/mission/chapter08/index.html b/mission/chapter08/index.html new file mode 100644 index 00000000..4c80f958 --- /dev/null +++ b/mission/chapter08/index.html @@ -0,0 +1,12 @@ + + + + + + React App + + +
+ + + diff --git a/mission/chapter08/package.json b/mission/chapter08/package.json new file mode 100644 index 00000000..66c4b6f6 --- /dev/null +++ b/mission/chapter08/package.json @@ -0,0 +1,20 @@ +{ + "name": "chapter07-mission", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@tailwindcss/vite": "^4.2.2", + "axios": "^1.14.0", + "react-hook-form": "^7.74.0", + "tailwindcss": "^4.2.2", + "zod": "^4.3.6" + } +} diff --git a/mission/chapter08/src/App.css b/mission/chapter08/src/App.css new file mode 100644 index 00000000..6b459712 --- /dev/null +++ b/mission/chapter08/src/App.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body, #root { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background-color: #111111; + color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} diff --git a/mission/chapter08/src/App.tsx b/mission/chapter08/src/App.tsx new file mode 100644 index 00000000..21c4b19e --- /dev/null +++ b/mission/chapter08/src/App.tsx @@ -0,0 +1,48 @@ +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router'; +import ProtectedRoute from './components/ProtectedRoute'; +import AppLayout from './layouts/AppLayout'; + +const AuthPage = lazy(() => import('./pages/auth/AuthPage')); +const GoogleCallbackPage = lazy(() => import('./pages/auth/GoogleCallbackPage')); +const LpDetailPage = lazy(() => import('./pages/lps/LpDetailPage')); +const LpsPage = lazy(() => import('./pages/lps/LpsPage')); +const UsersPage = lazy(() => import('./pages/users/UsersPage')); + +function PageLoader() { + return ( +
+
+
+ ); +} + +function App() { + return ( + + }> + + {/* 헤더+사이드바 레이아웃 */} + }> + } /> + } /> + + }> + } /> + } /> + + + + {/* 인증 전용 페이지 (레이아웃 없음) */} + } /> + } /> + } /> + + } /> + + + + ); +} + +export default App; diff --git a/mission/chapter08/src/apis/authApi.ts b/mission/chapter08/src/apis/authApi.ts new file mode 100644 index 00000000..6ed24029 --- /dev/null +++ b/mission/chapter08/src/apis/authApi.ts @@ -0,0 +1,31 @@ +import type { + SigninRequest, + SigninResponseData, + SignupRequest, + SignupResponseData, +} from '../types/auth'; +import { request } from './http'; + +export function signup(payload: SignupRequest) { + return request({ + method: 'post', + url: '/auth/signup', + data: payload, + }); +} + +export function signin(payload: SigninRequest) { + return request({ + method: 'post', + url: '/auth/signin', + data: payload, + }); +} + +export function signout() { + return request({ + method: 'post', + url: '/auth/signout', + data: {}, + }); +} diff --git a/mission/chapter08/src/apis/http.ts b/mission/chapter08/src/apis/http.ts new file mode 100644 index 00000000..67257134 --- /dev/null +++ b/mission/chapter08/src/apis/http.ts @@ -0,0 +1,138 @@ +import axios, { + AxiosError, + type AxiosRequestConfig, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios'; +import { + clearAuthTokens, + getAccessToken, + getRefreshToken, + setAccessToken, + setRefreshToken, +} from '../utils/authToken'; +import type { ApiResponse, SigninResponseData } from '../types/auth'; +import { ApiError } from '../utils/apiError'; + +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/v1'; +export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type RequestOptions = { + method: HttpMethod; + url: string; + data?: unknown; + params?: AxiosRequestConfig['params']; +}; + +type RetriableRequestConfig = InternalAxiosRequestConfig & { + _retry?: boolean; +}; + +const apiClient = axios.create({ + baseURL: API_BASE_URL, +}); + +apiClient.interceptors.request.use((config) => { + const accessToken = getAccessToken(); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}); + +let refreshPromise: Promise | null = null; + +const SKIP_REFRESH_URLS = ['/auth/signin', '/auth/signup', '/auth/refresh'] as const; + +const shouldSkipRefresh = (url?: string) => { + if (!url) return false; + return SKIP_REFRESH_URLS.some((path) => url.includes(path)); +}; + +function toApiError(error: unknown): ApiError { + if (error instanceof ApiError) return error; + + if (axios.isAxiosError>(error)) { + const status = error.response?.status ?? 0; + const message = error.response?.data?.message || error.message || '요청에 실패했습니다.'; + return new ApiError(message, status); + } + + if (error instanceof Error) return new ApiError(error.message, 0); + return new ApiError('요청에 실패했습니다.', 0); +} + +apiClient.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const originalRequest = error.config as RetriableRequestConfig | undefined; + const isUnauthorized = error.response?.status === 401; + const refreshToken = getRefreshToken(); + + if (!originalRequest || !isUnauthorized || originalRequest._retry || shouldSkipRefresh(originalRequest.url) || !refreshToken) { + return Promise.reject(error); + } + + originalRequest._retry = true; + + if (!refreshPromise) { + refreshPromise = axios + .post>(`${API_BASE_URL}/auth/refresh`, { refresh: refreshToken }) + .then((response) => { + if (!response.data.status || !response.data.data?.accessToken) return null; + const newAccessToken = response.data.data.accessToken; + setAccessToken(newAccessToken); + if (response.data.data.refreshToken) { + setRefreshToken(response.data.data.refreshToken); + } + return newAccessToken; + }) + .catch(() => { + clearAuthTokens(); + window.location.href = '/auth/signin'; + return null; + }) + .finally(() => { + refreshPromise = null; + }); + } + + const newAccessToken = await refreshPromise; + + if (!newAccessToken) { + return Promise.reject(error); + } + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return apiClient(originalRequest); + }, +); + +export async function request({ method, url, data, params }: RequestOptions): Promise> { + try { + const response = await apiClient.request>({ + method, + url, + data, + params, + }); + + if (!response.data.status) { + const status = response.status ?? 0; + throw new ApiError(response.data.message || '요청에 실패했습니다.', status); + } + + return response.data; + } catch (error) { + throw toApiError(error); + } +} + +export async function requestData(options: RequestOptions): Promise { + const response = await request(options); + return response.data; +} + +export default apiClient; diff --git a/mission/chapter08/src/apis/lpsApi.ts b/mission/chapter08/src/apis/lpsApi.ts new file mode 100644 index 00000000..311b39ff --- /dev/null +++ b/mission/chapter08/src/apis/lpsApi.ts @@ -0,0 +1,89 @@ +import type { + CommentDto, + CommentListData, + CreateLpRequest, + GetCommentsParams, + GetLpsParams, + LpDetailDto, + LpDto, + LpListData, + UpdateLpRequest, +} from '../types/lp'; +import { request, requestData } from './http'; + +export function getLps(params?: GetLpsParams) { + return requestData({ + method: 'get', + url: '/lps', + params, + }); +} + +export function getLpById(lpId: number) { + return requestData({ + method: 'get', + url: `/lps/${lpId}`, + }); +} + +export function createLp(data: CreateLpRequest) { + return request({ + method: 'post', + url: '/lps', + data, + }); +} + +export function updateLp(lpId: number, data: UpdateLpRequest) { + return request({ + method: 'patch', + url: `/lps/${lpId}`, + data, + }); +} + +export function deleteLp(lpId: number) { + return request({ + method: 'delete', + url: `/lps/${lpId}`, + }); +} + +export function toggleLike(lpId: number) { + return request({ + method: 'post', + url: `/lps/${lpId}/likes`, + data: {}, + }); +} + +export function getComments(lpId: number, params?: GetCommentsParams) { + return requestData({ + method: 'get', + url: `/lps/${lpId}/comments`, + params, + }); +} + +export function createComment(lpId: number, content: string) { + return request({ + method: 'post', + url: `/lps/${lpId}/comments`, + data: { content }, + }); +} + +export function updateComment(lpId: number, commentId: number, content: string) { + return request({ + method: 'patch', + url: `/lps/${lpId}/comments/${commentId}`, + data: { content }, + }); +} + +export function deleteComment(lpId: number, commentId: number) { + return request({ + method: 'delete', + url: `/lps/${lpId}/comments/${commentId}`, + }); +} diff --git a/mission/chapter08/src/apis/uploadsApi.ts b/mission/chapter08/src/apis/uploadsApi.ts new file mode 100644 index 00000000..6d25d393 --- /dev/null +++ b/mission/chapter08/src/apis/uploadsApi.ts @@ -0,0 +1,18 @@ +import type { ApiResponse } from '../types/auth'; +import apiClient from './http'; + +/** + * 이미지 파일을 서버에 업로드하고 URL을 반환합니다. + * - Content-Type은 axios가 FormData를 감지해 boundary를 포함한 값으로 자동 설정합니다. + * - Authorization 헤더는 apiClient 인터셉터가 자동으로 처리합니다. + */ +export async function uploadImage(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post>('/uploads', formData); + + const url = response.data.data?.url; + if (!url) throw new Error('이미지 업로드에 실패했습니다.'); + return url; +} diff --git a/mission/chapter08/src/apis/usersApi.ts b/mission/chapter08/src/apis/usersApi.ts new file mode 100644 index 00000000..f005dfba --- /dev/null +++ b/mission/chapter08/src/apis/usersApi.ts @@ -0,0 +1,37 @@ +import type { UserInfo } from '../types/user'; +import { request, requestData } from './http'; + +export type UpdateUserRequest = { + name?: string; + bio?: string | null; + avatar?: string | null; +}; + +export function getMyInfo() { + return requestData({ + method: 'get', + url: '/users/me', + }); +} + +export function getUserInfo(userId: string) { + return requestData({ + method: 'get', + url: `/users/${userId}`, + }); +} + +export function updateMyInfo(data: UpdateUserRequest) { + return requestData({ + method: 'patch', + url: '/users/me', + data, + }); +} + +export function deleteMyAccount() { + return request({ + method: 'delete', + url: '/users/me', + }); +} diff --git a/mission/chapter08/src/components/ProtectedRoute.tsx b/mission/chapter08/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..0ae4e9fb --- /dev/null +++ b/mission/chapter08/src/components/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import { Navigate, Outlet, useLocation } from 'react-router'; +import { useAuth } from '../contexts/AuthContext'; + +function ProtectedRoute() { + const location = useLocation(); + const { loggedIn } = useAuth(); + + if (!loggedIn) { + return ; + } + + return ; +} + +export default ProtectedRoute; diff --git a/mission/chapter08/src/components/auth/AuthInput.tsx b/mission/chapter08/src/components/auth/AuthInput.tsx new file mode 100644 index 00000000..e257f87a --- /dev/null +++ b/mission/chapter08/src/components/auth/AuthInput.tsx @@ -0,0 +1,44 @@ +import type { UseFormRegisterReturn } from 'react-hook-form'; + +type AuthInputProps = { + id: string; + label: string; + type?: 'text' | 'email' | 'password' | 'url'; + placeholder?: string; + autoComplete?: string; + registration: UseFormRegisterReturn; + error?: string; +}; + +function AuthInput({ + id, + label, + type = 'text', + placeholder, + autoComplete, + registration, + error, +}: AuthInputProps) { + return ( +
+ + + {error ?

{error}

: null} +
+ ); +} + +export default AuthInput; diff --git a/mission/chapter08/src/components/modals/ConfirmModal.tsx b/mission/chapter08/src/components/modals/ConfirmModal.tsx new file mode 100644 index 00000000..ab0df806 --- /dev/null +++ b/mission/chapter08/src/components/modals/ConfirmModal.tsx @@ -0,0 +1,39 @@ +type ConfirmModalProps = { + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; +}; + +function ConfirmModal({ + message, + confirmLabel = '확인', + cancelLabel = '취소', + onConfirm, + onCancel, +}: ConfirmModalProps) { + return ( +
+
+

{message}

+
+ + +
+
+
+ ); +} + +export default ConfirmModal; diff --git a/mission/chapter08/src/components/modals/LoginModal.tsx b/mission/chapter08/src/components/modals/LoginModal.tsx new file mode 100644 index 00000000..323e91ce --- /dev/null +++ b/mission/chapter08/src/components/modals/LoginModal.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from 'react-router'; + +type LoginModalProps = { + from?: string; +}; + +function LoginModal({ from }: LoginModalProps) { + const navigate = useNavigate(); + + const handleConfirm = () => { + navigate('/auth/signin', { state: { from: from ?? '/' } }); + }; + + return ( +
+
+

로그인이 필요합니다

+

+ 이 페이지를 보려면 로그인이 필요합니다. +
+ 로그인 페이지로 이동하시겠습니까? +

+
+ + +
+
+
+ ); +} + +export default LoginModal; diff --git a/mission/chapter08/src/components/modals/LpCreateModal.tsx b/mission/chapter08/src/components/modals/LpCreateModal.tsx new file mode 100644 index 00000000..4c4d662f --- /dev/null +++ b/mission/chapter08/src/components/modals/LpCreateModal.tsx @@ -0,0 +1,112 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRef } from 'react'; +import { createLp } from '../../apis/lpsApi'; +import { uploadImage } from '../../apis/uploadsApi'; +import { useLpForm } from '../../hooks/useLpForm'; +import LpFormFields from './LpFormFields'; + +type LpCreateModalProps = { + onClose: () => void; +}; + +function LpCreateModal({ onClose }: LpCreateModalProps) { + const queryClient = useQueryClient(); + const overlayRef = useRef(null); + + const form = useLpForm(); + + const createMutation = useMutation({ + mutationFn: async () => { + let thumbnailUrl: string | null = null; + if (form.thumbnailFile) { + try { + thumbnailUrl = await uploadImage(form.thumbnailFile); + } catch { + // 업로드 실패 시 썸네일 없이 진행 + } + } + return createLp({ + title: form.title.trim(), + content: form.content.trim(), + thumbnail: thumbnailUrl, + published: true, + tags: form.tags, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lps'] }); + onClose(); + }, + onError: (err) => { + form.setError(err instanceof Error ? err.message : 'LP 생성에 실패했습니다.'); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const validationError = form.validate(); + if (validationError) { + form.setError(validationError); + return; + } + createMutation.mutate(); + }; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }; + + return ( +
+
+
+

LP 추가하기

+ +
+ +
+ + + + +
+
+ ); +} + +export default LpCreateModal; diff --git a/mission/chapter08/src/components/modals/LpEditModal.tsx b/mission/chapter08/src/components/modals/LpEditModal.tsx new file mode 100644 index 00000000..bf7bdd5e --- /dev/null +++ b/mission/chapter08/src/components/modals/LpEditModal.tsx @@ -0,0 +1,119 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRef } from 'react'; +import { updateLp } from '../../apis/lpsApi'; +import { uploadImage } from '../../apis/uploadsApi'; +import { useLpForm } from '../../hooks/useLpForm'; +import type { LpDetailDto } from '../../types/lp'; +import LpFormFields from './LpFormFields'; + +type LpEditModalProps = { + lp: LpDetailDto; + onClose: () => void; +}; + +function LpEditModal({ lp, onClose }: LpEditModalProps) { + const queryClient = useQueryClient(); + const overlayRef = useRef(null); + + const form = useLpForm({ + initialTitle: lp.title, + initialContent: lp.content, + initialThumbnail: lp.thumbnail, + initialTags: lp.tags.map((t) => t.name), + }); + + const updateMutation = useMutation({ + mutationFn: async () => { + let thumbnailUrl: string | null | undefined = form.thumbnailPreview; + if (form.thumbnailFile) { + try { + thumbnailUrl = await uploadImage(form.thumbnailFile); + } catch { + // 업로드 실패 시 기존 썸네일 유지 + } + } + return updateLp(lp.id, { + title: form.title.trim(), + content: form.content.trim(), + thumbnail: thumbnailUrl, + tags: form.tags, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lp', lp.id] }); + queryClient.invalidateQueries({ queryKey: ['lps'] }); + onClose(); + }, + onError: (err) => { + form.setError(err instanceof Error ? err.message : 'LP 수정에 실패했습니다.'); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const validationError = form.validate(); + if (validationError) { + form.setError(validationError); + return; + } + updateMutation.mutate(); + }; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }; + + return ( +
+
+
+

LP 수정하기

+ +
+ +
+ + + + +
+
+ ); +} + +export default LpEditModal; diff --git a/mission/chapter08/src/components/modals/LpFormFields.tsx b/mission/chapter08/src/components/modals/LpFormFields.tsx new file mode 100644 index 00000000..df006956 --- /dev/null +++ b/mission/chapter08/src/components/modals/LpFormFields.tsx @@ -0,0 +1,154 @@ +type LpFormFieldsProps = { + title: string; + onTitleChange: (v: string) => void; + content: string; + onContentChange: (v: string) => void; + thumbnailPreview: string | null; + onFileChange: (e: React.ChangeEvent) => void; + onRemoveThumbnail: () => void; + tagInput: string; + onTagInputChange: (v: string) => void; + tags: string[]; + onAddTag: () => void; + onTagKeyDown: (e: React.KeyboardEvent) => void; + onRemoveTag: (tag: string) => void; + error: string; + titleId?: string; + contentId?: string; +}; + +export default function LpFormFields({ + title, + onTitleChange, + content, + onContentChange, + thumbnailPreview, + onFileChange, + onRemoveThumbnail, + tagInput, + onTagInputChange, + tags, + onAddTag, + onTagKeyDown, + onRemoveTag, + error, + titleId = 'lp-title', + contentId = 'lp-content', +}: LpFormFieldsProps) { + return ( + <> + {/* 썸네일 */} +
+ + + {thumbnailPreview && ( + + )} +
+ + {/* 제목 */} +
+ + onTitleChange(e.target.value)} + placeholder="LP 제목을 입력하세요" + className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-slate-500 outline-none transition-colors focus:border-pink-500/50" + /> +
+ + {/* 내용 */} +
+ +