Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
495 changes: 495 additions & 0 deletions keyword/chapter08/keyword08.md

Large diffs are not rendered by default.

Empty file.
63 changes: 63 additions & 0 deletions mission/chapter08/mission_1/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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: <HomeLayout />,
errorElement: <NotFound />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'mypage',
element: (
<ProtectedRoute>
<MyPage />
</ProtectedRoute>
),
},
{
path: 'lps/:lpId', // LP 상세 페이지
element: (
<ProtectedRoute>
<LpDetailPage />
</ProtectedRoute>
),
},
],
},
{
path: '/login',
element: <LoginPage />,
errorElement: <NotFound />,
},
{
path: '/signup',
element: <SignupPage />,
errorElement: <NotFound />,
},
{
path: '/v1/auth/google/callback',
element: <GoogleCallbackPage />,
},
]);

function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}

export default App;
13 changes: 13 additions & 0 deletions mission/chapter08/mission_1/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -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;
};
94 changes: 94 additions & 0 deletions mission/chapter08/mission_1/src/api/axios.ts
Original file line number Diff line number Diff line change
@@ -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;
98 changes: 98 additions & 0 deletions mission/chapter08/mission_1/src/api/lp.ts
Original file line number Diff line number Diff line change
@@ -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<LpListResponse> => {
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<LpDetailResponse> => {
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<CommentListResponse> => {
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;
};
11 changes: 11 additions & 0 deletions mission/chapter08/mission_1/src/api/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import axiosInstance from './axios';

export const uploadImage = async (file: File): Promise<string> => {
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;
};
23 changes: 23 additions & 0 deletions mission/chapter08/mission_1/src/api/user.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Binary file added mission/chapter08/mission_1/src/assets/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mission/chapter08/mission_1/src/assets/react.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading