diff --git a/week8/m1/.gitignore b/week8/m1/.gitignore
new file mode 100644
index 00000000..92ef6c1d
--- /dev/null
+++ b/week8/m1/.gitignore
@@ -0,0 +1,36 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+package-lock.json
+yarn.lock
+pnpm-lock.yaml
+lerna-lock.json
+tsconfig.app.json
+tsconfig.jason
+tsconfig.node.json
+package.json
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+/package.json
+/pnpm-lock.yaml
+
+.env
\ No newline at end of file
diff --git a/week8/m1/README.md b/week8/m1/README.md
new file mode 100644
index 00000000..7dbf7ebf
--- /dev/null
+++ b/week8/m1/README.md
@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/week8/m1/eslint.config.js b/week8/m1/eslint.config.js
new file mode 100644
index 00000000..ef614d25
--- /dev/null
+++ b/week8/m1/eslint.config.js
@@ -0,0 +1,22 @@
+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: {
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/week8/m1/index.html b/week8/m1/index.html
new file mode 100644
index 00000000..2e9d48ac
--- /dev/null
+++ b/week8/m1/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ m1
+
+
+
+
+
+
diff --git a/week8/m1/public/favicon.svg b/week8/m1/public/favicon.svg
new file mode 100644
index 00000000..6893eb13
--- /dev/null
+++ b/week8/m1/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/week8/m1/public/icons.svg b/week8/m1/public/icons.svg
new file mode 100644
index 00000000..e9522193
--- /dev/null
+++ b/week8/m1/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/week8/m1/src/App.css b/week8/m1/src/App.css
new file mode 100644
index 00000000..e69de29b
diff --git a/week8/m1/src/App.tsx b/week8/m1/src/App.tsx
new file mode 100644
index 00000000..47ca9c76
--- /dev/null
+++ b/week8/m1/src/App.tsx
@@ -0,0 +1,64 @@
+import './App.css'
+import { createBrowserRouter, RouterProvider, type RouteObject } from 'react-router-dom'
+import Home from './pages/Home'
+import NotFoundErr from './pages/NotFoundErr'
+import Login from './pages/Login'
+import HomeLayout from './layouts/HomeLayout'
+import SignUp from './pages/SignUp'
+import MyPage from './pages/Mypage'
+import { AuthProvider } from './context/AuthContext'
+import PrivateLayout from './layouts/PrivateLayout'
+import GoogleLoginRedirect from './pages/GoogleLoginRedirect'
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import LpDetailPage from './pages/LpDetail'
+
+const publicRoutes:RouteObject[] = [
+ {
+ path: "/",
+ element: ,
+ errorElement: ,
+ children: [
+ {index: true, element: },
+ {path: 'login', element: },
+ {path: 'signup', element: },
+ {path: '/v1/auth/google/callback', element: }, //추가
+ {path: 'lp/:lpId', element: }, //추가
+ {path: '/v1/auth/google/callback', element: }, //구글 로그인 리다이렉트 페이지 추가
+ ]
+ },
+];
+
+const privateRoutes:RouteObject[] = [
+ {
+ path: "/",
+ element: ,
+ errorElement: ,
+ children: [
+ {path: 'my', element: },
+ ],
+ },
+];
+
+export const queryClient = new QueryClient();
+
+function App() {
+ const router = createBrowserRouter([
+ ...publicRoutes,
+ ...privateRoutes,
+ ]);
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+
+ )
+}
+
+export default App
diff --git a/week8/m1/src/apis/auth.ts b/week8/m1/src/apis/auth.ts
new file mode 100644
index 00000000..4afc96aa
--- /dev/null
+++ b/week8/m1/src/apis/auth.ts
@@ -0,0 +1,68 @@
+import axios from "axios";
+import type { ReqSignInDto,
+ ReqSignUpDto,
+ ResMyInfoDto,
+ ResSignInDto,
+ ResSignUpDto,
+ ReqUpdateMyInfoDto } from "../types/auth";
+import { useLocalStorage } from "../hooks/useLocalStorage";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+
+
+const getAuthHeader = () => {
+ try {
+ const token = JSON.parse(
+ localStorage.getItem(LOCAL_STORAGE_KEY.ACCESS_TOKEN) ?? "null"
+ );
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ } catch {
+ return {};
+ }
+};
+
+export const postSignup = async (body: ReqSignUpDto):Promise => {
+ const { data } = await axios.post(`${import.meta.env.VITE_SERVER_API_URL}/v1/auth/signup`, body);
+ return data;
+}
+
+export const postSignin = async (body: ReqSignInDto):Promise => {
+ const { data } = await axios.post(`${import.meta.env.VITE_SERVER_API_URL}/v1/auth/signin`, body);
+ return data;
+}
+
+export const getMyInfo = async ():Promise => {
+ const { getItem } = useLocalStorage(LOCAL_STORAGE_KEY.ACCESS_TOKEN);
+ const { data } = await axios.get(`${import.meta.env.VITE_SERVER_API_URL}/v1/users/me`, {
+ headers: {
+ Authorization: `Bearer ${getItem()}`,
+ }
+ });
+ return data;
+}
+// 로그아웃 API
+export const postLogout = async () => {
+ const { data } = await axios.post(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/auth/signout`,
+ {},
+ { headers: getAuthHeader() }
+ );
+ return data;
+};
+
+// 내 정보 수정 API
+export const patchMyInfo = async (
+ body: ReqUpdateMyInfoDto
+): Promise => {
+ const { data } = await axios.patch(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/users`,
+ body,
+ { headers: getAuthHeader() }
+ );
+ return data;
+};
+
+export const deleteAccount = async () => {
+ await axios.delete(`${import.meta.env.VITE_SERVER_API_URL}/v1/users`, {
+ headers: getAuthHeader(),
+ });
+};
\ No newline at end of file
diff --git a/week8/m1/src/apis/lp.ts b/week8/m1/src/apis/lp.ts
new file mode 100644
index 00000000..40001ccd
--- /dev/null
+++ b/week8/m1/src/apis/lp.ts
@@ -0,0 +1,126 @@
+import axios from "axios"
+import type { PaginationDto } from "../types/common"
+import type { ResponseLpListDto, ReqCreateLpDto } from "../types/lp";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+
+const getAuthHeader = () => {
+ try {
+ const token = JSON.parse(
+ localStorage.getItem(LOCAL_STORAGE_KEY.ACCESS_TOKEN) ?? "null"
+ );
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ } catch {
+ return {};
+ }
+};
+
+export const getLpList = async (paginationDto: PaginationDto): Promise => {
+ const { data } = await axios.get(`${import.meta.env.VITE_SERVER_API_URL}/v1/lps`, {
+ params: paginationDto,
+ });
+
+ return data;
+}; // LP 목록 조회 API
+
+export const postLp = async (body: ReqCreateLpDto) => {
+ const { data } = await axios.post(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps`,
+ body,
+ { headers: getAuthHeader() }
+ );
+ return data;
+}; // LP 생성 API 추가
+
+export const updateLp = async ({
+ lpId,
+ body,
+}: {
+ lpId: number;
+ body: Partial;
+}) => {
+ const { data } = await axios.patch(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}`,
+ body,
+ { headers: getAuthHeader() }
+ );
+ return data;
+}; // LP 수정 API 추가
+
+export const deleteLpApi = async (lpId: number) => {
+ await axios.delete(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}`,
+ { headers: getAuthHeader() }
+ );
+};// LP 삭제 API 추가
+
+export const toggleLike = async (lpId: number) => {
+ const { data } = await axios.post(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}/likes`,
+ {},
+ { headers: getAuthHeader() }
+ );
+ return data;
+}; // LP 좋아요 토글 API 추가
+
+export const postComment = async ({
+ lpId,
+ content,
+}: {
+ lpId: number;
+ content: string;
+}) => {
+ const { data } = await axios.post(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}/comments`,
+ { content },
+ { headers: getAuthHeader() }
+ );
+ return data;
+}; // 댓글 작성 API 추가
+
+export const updateComment = async ({
+ lpId,
+ commentId,
+ content,
+}: {
+ lpId: number;
+ commentId: number;
+ content: string;
+}) => {
+ const { data } = await axios.patch(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}/comments/${commentId}`,
+ { content },
+ { headers: getAuthHeader() }
+ );
+ return data;
+}; // 댓글 수정 API 추가
+
+export const deleteComment = async ({
+ lpId,
+ commentId,
+}: {
+ lpId: number;
+ commentId: number;
+}) => {
+ await axios.delete(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}/comments/${commentId}`,
+ { headers: getAuthHeader() }
+ );
+}; // 댓글 삭제 API 추가
+
+export const uploadImage = async (file: File) => {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const { data } = await axios.post(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/uploads`,
+ formData,
+ {
+ headers: {
+ ...getAuthHeader(),
+ "Content-Type": "multipart/form-data",
+ },
+ }
+ );
+
+ return data.data.imageUrl;
+}; // 이미지 업로드 API 추가
\ No newline at end of file
diff --git a/week8/m1/src/assets/hero.png b/week8/m1/src/assets/hero.png
new file mode 100644
index 00000000..02251f4b
Binary files /dev/null and b/week8/m1/src/assets/hero.png differ
diff --git a/week8/m1/src/assets/react.svg b/week8/m1/src/assets/react.svg
new file mode 100644
index 00000000..6c87de9b
--- /dev/null
+++ b/week8/m1/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/week8/m1/src/assets/vite.svg b/week8/m1/src/assets/vite.svg
new file mode 100644
index 00000000..5101b674
--- /dev/null
+++ b/week8/m1/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/week8/m1/src/components/Footer.tsx b/week8/m1/src/components/Footer.tsx
new file mode 100644
index 00000000..5468da44
--- /dev/null
+++ b/week8/m1/src/components/Footer.tsx
@@ -0,0 +1,21 @@
+import { Link } from "react-router-dom"
+
+const Footer = () => {
+ return (
+
+ )
+}
+
+export default Footer
+// Footer 컴포넌트는 사이트의 하단에 위치하는 공통 요소로, 저작권 정보와 함께 개인정보 처리방침, 이용약관, 연락처 등의 링크를 포함합니다. 디자인은 간단하면서도 깔끔하게 유지하여 사용자에게 필요한 정보를 제공하는 역할을 합니다.
\ No newline at end of file
diff --git a/week8/m1/src/components/HamburgerButton.tsx b/week8/m1/src/components/HamburgerButton.tsx
new file mode 100644
index 00000000..7d8de971
--- /dev/null
+++ b/week8/m1/src/components/HamburgerButton.tsx
@@ -0,0 +1,33 @@
+interface HamburgerButtonProps {
+ isOpen: boolean;
+ onClick: () => void;
+}
+
+export default function HamburgerButton({ isOpen, onClick }: HamburgerButtonProps) {
+ return (
+
+ );
+}
diff --git a/week8/m1/src/components/Sidebar.tsx b/week8/m1/src/components/Sidebar.tsx
new file mode 100644
index 00000000..466f5f98
--- /dev/null
+++ b/week8/m1/src/components/Sidebar.tsx
@@ -0,0 +1,211 @@
+import { useRef, useEffect, useState } from "react";
+import { useNavigate, useLocation } from "react-router-dom";
+import { useMutation } from "@tanstack/react-query";
+import { useAuth } from "../context/AuthContext";
+
+type ActiveNav = "search" | "mypage";
+
+const COLORS = {
+ bg: "#F4FFFC",
+ soft: "#E9FFFC",
+ main: "#0ECFD3",
+ dark: "#0BAEB3",
+ text: "#063B3D",
+ border: "#B8F3EE",
+ muted: "#5B8C8E",
+ faint: "#7AA6A8",
+};
+
+const NAV_ITEMS: { key: ActiveNav; icon: string; label: string; path: string }[] = [
+ { key: "search", icon: "🔍", label: "찾기", path: "/" },
+ { key: "mypage", icon: "👤", label: "마이페이지", path: "/my" },
+];
+
+interface SidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function Sidebar({ isOpen, onClose }: SidebarProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const sidebarRef = useRef(null);
+ const { deleteAccount } = useAuth();
+
+ const [showWithdrawModal, setShowWithdrawModal] = useState(false);
+
+ const activeKey: ActiveNav =
+ location.pathname.startsWith("/my") ? "mypage" : "search";
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (isOpen && sidebarRef.current && !sidebarRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [isOpen, onClose]);
+
+ const withdrawMutation = useMutation({
+ mutationFn: deleteAccount,
+ onSuccess: () => {
+ onClose();
+ navigate("/login");
+ },
+ });
+
+ return (
+ <>
+
+
+
+
+ {showWithdrawModal && (
+
+
+
+
⚠️
+
+ 정말 탈퇴하시겠습니까?
+
+
+ 탈퇴 시 모든 데이터가 삭제되며 복구할 수 없습니다.
+
+
+
+ {withdrawMutation.isError && (
+
+ 탈퇴 처리 중 오류가 발생했습니다.
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/week8/m1/src/constants/key.ts b/week8/m1/src/constants/key.ts
new file mode 100644
index 00000000..36f080f2
--- /dev/null
+++ b/week8/m1/src/constants/key.ts
@@ -0,0 +1,18 @@
+export const LOCAL_STORAGE_KEY = {
+ 'ACCESS_TOKEN': 'accessToken',
+ 'REFRESH_TOKEN': 'refreshToken',
+ 'USER_ID': 'userId',
+}
+
+export const QUERY_KEY = {
+ lps: 'lps',
+}
+
+export const NAV_ITEMS = [
+ { icon: "💿", label: "전체 LP" },
+ { icon: "⭐", label: "즐겨찾기" },
+ { icon: "🕒", label: "최근 재생" },
+ { icon: "🎸", label: "장르별" },
+ { icon: "📅", label: "연도별" },
+ { icon: "🔍", label: "찾기" },
+];
\ No newline at end of file
diff --git a/week8/m1/src/context/AuthContext.tsx b/week8/m1/src/context/AuthContext.tsx
new file mode 100644
index 00000000..2b7290dc
--- /dev/null
+++ b/week8/m1/src/context/AuthContext.tsx
@@ -0,0 +1,112 @@
+import { postSignin } from "../apis/auth";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+import { useLocalStorage } from "../hooks/useLocalStorage";
+import { type ReqSignInDto } from "../types/auth";
+import { postLogout, deleteAccount as deleteAccountApi } from "../apis/auth";
+import { createContext, useState, useContext, type PropsWithChildren } from "react";
+
+interface AuthContextType {
+ isAuthenticated: boolean;
+ accessToken: string | null;
+ refreshToken: string | null;
+ userId: number | null;
+ login: (signInData: ReqSignInDto) => Promise;
+ logout: () => Promise;
+ deleteAccount: () => Promise;
+}
+
+export const AuthContext = createContext({
+ isAuthenticated: false,
+ accessToken: null,
+ refreshToken: null,
+ userId: null,
+ login: async () => {},
+ logout: async () => {},
+ deleteAccount: async () => {},
+});
+
+export const AuthProvider = ({ children }: PropsWithChildren) => {
+ const {
+ getItem: getAccessTokenInStorage,
+ setItem: setAccessTokenInStorage,
+ removeItem: removeAccessTokenInStorage,
+ } = useLocalStorage(LOCAL_STORAGE_KEY.ACCESS_TOKEN);
+ const {
+ getItem: getRefreshTokenInStorage,
+ setItem: setRefreshTokenInStorage,
+ removeItem: removeRefreshTokenInStorage,
+ } = useLocalStorage(LOCAL_STORAGE_KEY.REFRESH_TOKEN);
+ const {
+ getItem: getUserIdFromStorage,
+ setItem: setUserIdInStorage,
+ removeItem: removeUserIdInStorage,
+ } = useLocalStorage(LOCAL_STORAGE_KEY.USER_ID);
+
+ const [accessToken, setAccessToken] = useState(
+ getAccessTokenInStorage()
+ );
+ const [refreshToken, setRefreshToken] = useState(
+ getRefreshTokenInStorage()
+ );
+ const [userId, setUserId] = useState(
+ getUserIdFromStorage()
+ );
+
+ const login = async (signInData: ReqSignInDto) => {
+ const { data } = await postSignin(signInData);
+
+ if (data) {
+ setAccessTokenInStorage(data.accessToken);
+ setRefreshTokenInStorage(data.refreshToken);
+ setUserIdInStorage(data.id);
+
+ setAccessToken(data.accessToken);
+ setRefreshToken(data.refreshToken);
+ setUserId(data.id);
+ }
+ };
+
+ const logout = async () => {
+ try {
+ await postLogout();
+ } catch (err) {
+ console.error("Logout API failed:", err);
+ } finally {
+ removeAccessTokenInStorage();
+ removeRefreshTokenInStorage();
+ removeUserIdInStorage();
+
+ setAccessToken(null);
+ setRefreshToken(null);
+ setUserId(null);
+ }
+ };
+
+ const deleteAccount = async () => {
+ await deleteAccountApi();
+ removeAccessTokenInStorage();
+ removeRefreshTokenInStorage();
+ removeUserIdInStorage();
+
+ setAccessToken(null);
+ setRefreshToken(null);
+ setUserId(null);
+ };
+
+ return (
+
+ {children}
+
+ );
+
+};
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/week8/m1/src/hooks/queries/useGetLpList.ts b/week8/m1/src/hooks/queries/useGetLpList.ts
new file mode 100644
index 00000000..6339a912
--- /dev/null
+++ b/week8/m1/src/hooks/queries/useGetLpList.ts
@@ -0,0 +1,25 @@
+import { useQuery } from "@tanstack/react-query";
+import type { PaginationDto } from "../../types/common";
+import { getLpList } from "../../apis/lp";
+import { QUERY_KEY } from "../../constants/key";
+
+function useGetLpList({cursor, search, order, limit}: PaginationDto) {
+ return useQuery({
+ queryKey:[QUERY_KEY.lps],
+ queryFn: () => getLpList({
+ cursor,
+ search,
+ order,
+ limit
+ }),
+ // 데이터가 신선하다고 간주하는 시간 ~ 해당 시간 동안에는 캐시된 데이터를 그대로 사용
+ staleTime: 1000 * 60 * 5, // ms단위이기 때문에 해당 시간은 5분 의미
+ // 사용되지 않는 상태인 쿼리 데이터가 캐시에 남아있는 시간
+ // staleTime이 지나고 데이터가 신선하지 않더라도 일정 시간 동안 메모리에 보관
+ gcTime: 1000 * 60 * 10, // 10분
+ // 조건에 따라 쿼리 실행 여부 제어
+ enabled: true
+ });
+}
+
+export default useGetLpList;
\ No newline at end of file
diff --git a/week8/m1/src/hooks/useDebounce.ts b/week8/m1/src/hooks/useDebounce.ts
new file mode 100644
index 00000000..928387ca
--- /dev/null
+++ b/week8/m1/src/hooks/useDebounce.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/week8/m1/src/hooks/useForm.ts b/week8/m1/src/hooks/useForm.ts
new file mode 100644
index 00000000..52a652c2
--- /dev/null
+++ b/week8/m1/src/hooks/useForm.ts
@@ -0,0 +1,72 @@
+/*
+useForm
+→ input 값 관리 + 에러 검증 도와주는 훅
+*/
+
+import { useEffect, useState, type ChangeEvent } from "react";
+
+/*
+useState → 입력값, 에러, touched 상태 저장
+useEffect → 입력값이 바뀔 때마다 검증 실행
+ChangeEvent → input 변경 이벤트 타입
+*/
+
+interface UseFormProps {
+ init_val: T; // input들의 초기값
+ validate: (values: T) => Record; // 입력값이 맞는지 검사하는 함수
+}
+
+function useForm({ init_val, validate }: UseFormProps) {
+ const [values, setValues] = useState(init_val); // 현재 input 값들 저장
+ const [touched, setTouched] = useState>({});
+ // 사용자가 해당 input을 한 번이라도 건드렸는지 저장
+
+ const [errors, setErrors] = useState>({});
+ // 각 input의 에러 메시지 저장
+
+ const handleChange = (name: keyof T, text: string) => {
+ setValues({
+ ...values, // 기존 값 유지
+ [name]: text, // 바뀐 input만 새 값으로 바꾸기
+ });
+ };
+ // 사용자가 input에 글자를 입력하면 그 input의 값을 values에 저장
+
+ const handleBlur = (name: keyof T) => {
+ setTouched({
+ ...touched, // 기존 touched 상태 유지
+ [name]: true, // 해당 input은 이제 touched 됐다고 표시
+ });
+ };
+
+ const getInputProps = (name: keyof T) => {
+ const value = values[name];
+
+ const onChange = (
+ e: ChangeEvent
+ ) => {
+ handleChange(name, e.target.value);
+ };
+
+ const onBlur = () => {
+ handleBlur(name);
+ };
+
+ return { value, onChange, onBlur };
+ };
+ // input 컴포넌트에 getInputProps("username") 이렇게 쓰면
+ // 그 input의 value, onChange, onBlur가 자동으로 연결되도록 해주는 함수
+
+ useEffect(() => {
+ const newErrors = validate(values);
+ setErrors(newErrors);
+ }, [values, validate]);
+ /*
+ values가 바뀔 때마다 validate 함수로 에러 검사
+ 검사 결과를 errors에 저장
+ */
+
+ return { values, errors, touched, getInputProps };
+}
+
+export default useForm;
\ No newline at end of file
diff --git a/week8/m1/src/hooks/useLocalStorage.ts b/week8/m1/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000..a93c3106
--- /dev/null
+++ b/week8/m1/src/hooks/useLocalStorage.ts
@@ -0,0 +1,42 @@
+/*
+useLocalStorage
+→ localStorage 저장/조회/삭제 쉽게 해주는 훅
+새로고침해도 남아 있음
+브라우저 꺼도 남아 있음
+로그인 토큰 저장할 때 자주 씀
+*/
+
+export const useLocalStorage = (key: string) => {
+ // key 이름을 받아서 해당하는 localStorage 조작하는 함수들 만들어줌
+
+ const setItem = (value: unknown) => {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ // localStorage는 문자열만 저장할 수 있어서 JSON.stringify로 감싸서 저장
+ } catch (err) {
+ console.log(err);
+ }
+ };
+
+ const getItem = () => {
+ try {
+ const item = localStorage.getItem(key);
+ return item ? JSON.parse(item) : null;
+ // 저장할 때 JSON.stringify로 감싸줬으니까 꺼낼 때는 JSON.parse로 다시 객체로 만들어주기
+ } catch (err) {
+ console.log(err);
+ return null;
+ }
+ };
+
+ const removeItem = () => {
+ try {
+ localStorage.removeItem(key);
+ } catch (err) {
+ console.log(err);
+ }
+ };
+ // localStorage에서 해당 key 삭제
+
+ return { setItem, getItem, removeItem };
+};
\ No newline at end of file
diff --git a/week8/m1/src/hooks/useSidebar.ts b/week8/m1/src/hooks/useSidebar.ts
new file mode 100644
index 00000000..0068aab1
--- /dev/null
+++ b/week8/m1/src/hooks/useSidebar.ts
@@ -0,0 +1,11 @@
+import { useState } from "react";
+
+export function useSidebar() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const open = () => setIsOpen(true);
+ const close = () => setIsOpen(false);
+ const toggle = () => setIsOpen((prev) => !prev);
+
+ return { isOpen, setIsOpen, open, close, toggle };
+}
diff --git a/week8/m1/src/hooks/useThrottle.ts b/week8/m1/src/hooks/useThrottle.ts
new file mode 100644
index 00000000..2ae0314a
--- /dev/null
+++ b/week8/m1/src/hooks/useThrottle.ts
@@ -0,0 +1,36 @@
+import { useCallback, useRef } from "react";
+
+export function useThrottle void>(
+ callback: T,
+ delay: number
+) {
+ const lastRunTime = useRef(0);
+ const timerRef = useRef(null);
+
+ return useCallback(
+ (...args: Parameters) => {
+ const now = Date.now();
+ const remainingTime = delay - (now - lastRunTime.current);
+
+ if (remainingTime <= 0) {
+ if (timerRef.current) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+
+ lastRunTime.current = now;
+ callback(...args);
+ return;
+ }
+
+ if (!timerRef.current) {
+ timerRef.current = window.setTimeout(() => {
+ lastRunTime.current = Date.now();
+ timerRef.current = null;
+ callback(...args);
+ }, remainingTime);
+ }
+ },
+ [callback, delay]
+ );
+}
diff --git a/week8/m1/src/imgs/google.png b/week8/m1/src/imgs/google.png
new file mode 100644
index 00000000..9035a67e
Binary files /dev/null and b/week8/m1/src/imgs/google.png differ
diff --git a/week8/m1/src/index.css b/week8/m1/src/index.css
new file mode 100644
index 00000000..a461c505
--- /dev/null
+++ b/week8/m1/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
\ No newline at end of file
diff --git a/week8/m1/src/layouts/HomeLayout.tsx b/week8/m1/src/layouts/HomeLayout.tsx
new file mode 100644
index 00000000..79c59c60
--- /dev/null
+++ b/week8/m1/src/layouts/HomeLayout.tsx
@@ -0,0 +1,102 @@
+import { Outlet, useNavigate } from "react-router-dom";
+import { useAuth } from "../context/AuthContext";
+import { getMyInfo } from "../apis/auth";
+import { useState, useEffect } from "react";
+import type { ResMyInfoDto } from "../types/auth";
+import Footer from "../components/Footer";
+import Sidebar from "../components/Sidebar";
+import { useMutation } from "@tanstack/react-query";
+import { useSidebar } from "../hooks/useSidebar";
+// 로그인 여부에 따라 헤더에 로그인/회원가입 버튼 또는 사용자 이름과 로그아웃 버튼을 보여주는 레이아웃
+
+export default function HomeLayout() {
+ const navigate = useNavigate();
+ const { accessToken, logout } = useAuth();
+
+ const [data, setData] = useState({} as ResMyInfoDto);
+ const { isOpen: sidebarOpen, setIsOpen: setSidebarOpen } = useSidebar();
+
+ const logoutMutation = useMutation({
+ mutationFn: logout,
+ onSuccess: () => navigate("/"),
+ });
+
+ useEffect(() => {
+ if (!accessToken) return;
+ const myData = async () => {
+ try {
+ const response = await getMyInfo();
+ setData(response);
+ } catch {
+ // 토큰 만료 등으로 실패해도 레이아웃 유지
+ }
+ };
+ myData();
+ }, [accessToken]);
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+ {/* 사이드바 */}
+
setSidebarOpen(false)} />
+
+ {/* 페이지 콘텐츠 */}
+
+
+
+
+
+
+ );
+}
diff --git a/week8/m1/src/layouts/PrivateLayout.tsx b/week8/m1/src/layouts/PrivateLayout.tsx
new file mode 100644
index 00000000..519b919c
--- /dev/null
+++ b/week8/m1/src/layouts/PrivateLayout.tsx
@@ -0,0 +1,56 @@
+import { Outlet, useNavigate, Navigate } from "react-router-dom";
+import { useAuth } from "../context/AuthContext";
+import { getMyInfo } from "../apis/auth";
+import { useState, useEffect } from "react";
+import type { ResMyInfoDto } from "../types/auth";
+
+const PrivateLayout = () => {
+ const navigate = useNavigate();
+ const {accessToken, logout} = useAuth();
+
+ const handleLogout = async () => {
+ await logout();
+ navigate("/");
+ }
+
+ const [data, setData] = useState({} as ResMyInfoDto);
+
+ useEffect(() => {
+ const myData = async () => {
+ const response = await getMyInfo();
+ console.log(response);
+ setData(response);
+ }
+
+ myData();
+ }, [accessToken]);
+
+ if (!accessToken) {
+ return ; // 뒤로 가기 했을 때 history에 로그인 페이지가 남지 않도록 replace 옵션 사용
+ }
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export default PrivateLayout;
\ No newline at end of file
diff --git a/week8/m1/src/main.tsx b/week8/m1/src/main.tsx
new file mode 100644
index 00000000..bef5202a
--- /dev/null
+++ b/week8/m1/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/week8/m1/src/pages/GoogleLoginRedirect.tsx b/week8/m1/src/pages/GoogleLoginRedirect.tsx
new file mode 100644
index 00000000..e98d57ef
--- /dev/null
+++ b/week8/m1/src/pages/GoogleLoginRedirect.tsx
@@ -0,0 +1,26 @@
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+import { useLocalStorage } from "../hooks/useLocalStorage";
+import { useEffect } from "react";
+
+const GoogleLoginRedirect = () => {
+ const {setItem: setAccessToken} = useLocalStorage(LOCAL_STORAGE_KEY.ACCESS_TOKEN);
+ const {setItem: setRefreshToken} = useLocalStorage(LOCAL_STORAGE_KEY.REFRESH_TOKEN);
+
+ useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const accessToken = urlParams.get("accessToken");
+ const refreshToken = urlParams.get("refreshToken");
+
+ if (accessToken) {
+ setAccessToken(accessToken);
+ setRefreshToken(refreshToken);
+ window.location.href = "/my";
+ }
+ }, [setAccessToken, setRefreshToken]);
+ return (
+
+ 구글 로그인 리다이렉트 페이지
+
+ );
+}
+export default GoogleLoginRedirect;
\ No newline at end of file
diff --git a/week8/m1/src/pages/Home.tsx b/week8/m1/src/pages/Home.tsx
new file mode 100644
index 00000000..d84ffdce
--- /dev/null
+++ b/week8/m1/src/pages/Home.tsx
@@ -0,0 +1,577 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { postLp, uploadImage } from "../apis/lp";
+import type { ReqCreateLpDto } from "../types/lp";
+
+interface Tag { id: number; name: string; }
+interface Likes { id: number; userId: number; }
+interface Lp {
+ id: number; title: string; content: string; thumbnail: string;
+ published: boolean; authorId: number; createdAt: Date; updatedAt: Date;
+ tags: Tag[]; likes: Likes[];
+}
+
+interface LpPage {
+ data: Lp[];
+ nextCursor: number | null;
+}
+
+type SortOrder = "newest" | "oldest";
+
+const fileToBase64 = (file: File): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+const fetchLps = async (sort: SortOrder, cursor?: number): Promise => {
+ const order = sort === "newest" ? "desc" : "asc";
+ const params = new URLSearchParams({ order, limit: "20" });
+ if (cursor) params.append("cursor", String(cursor));
+
+ const { data } = await axios.get(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps?${params}`
+ );
+
+ const list: Lp[] = data?.data?.data ?? data?.data ?? data ?? [];
+ const nextCursor: number | null = data?.data?.nextCursor ?? null;
+ return { data: list, nextCursor };
+};
+
+// ── 스켈레톤 카드 (카드와 동일 크기) ─────────────────
+const SkeletonCard = () => (
+
+);
+
+const GridSkeleton = ({ count = 10 }: { count?: number }) => (
+
+ {Array.from({ length: count }).map((_, i) => )}
+
+);
+
+const GridError = ({ onRetry }: { onRetry: () => void }) => (
+
+
💿
+
데이터를 불러오지 못했습니다.
+
+
+);
+
+// ── LP 카드 ────────────────────────────────────────────
+const LpCard = ({ lp, onClick }: { lp: Lp; onClick: () => void }) => {
+ const [hovered, setHovered] = useState(false);
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onClick={onClick}
+ >
+ {lp.thumbnail ? (
+

+ ) : (
+
+ )}
+
+
+ {!lp.published && hovered && (
+
미발행
+ )}
+ {hovered && (
+
+ ❤️ {lp.likes.length}
+
+ )}
+
+
{lp.title}
+
{lp.content}
+ {lp.tags.length > 0 && (
+
+ {lp.tags.slice(0, 3).map((tag) => (
+
+ #{tag.name}
+
+ ))}
+ {lp.tags.length > 3 && (
+
+ +{lp.tags.length - 3}
+
+ )}
+
+ )}
+
+ {new Date(lp.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
+
+
+
+
+ );
+};
+// ── LP 작성 모달 ───────────────────────────────────────
+interface CreateLpModalProps {
+ onClose: () => void;
+}
+
+const CreateLpModal = ({ onClose }: CreateLpModalProps) => {
+ const queryClient = useQueryClient();
+
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [published, setPublished] = useState(true);
+ const [thumbnailFile, setThumbnailFile] = useState(null);
+ const [thumbnailPreview, setThumbnailPreview] = useState("");
+ const [tagInput, setTagInput] = useState("");
+ const [tags, setTags] = useState([]);
+
+ const overlayRef = useRef(null);
+
+ const createMutation = useMutation({
+ mutationFn: async (body: ReqCreateLpDto) => postLp(body),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["lps"] });
+ onClose();
+ },
+ });
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setThumbnailFile(file);
+ const preview = await fileToBase64(file);
+ setThumbnailPreview(preview);
+ };
+
+ const handleAddTag = () => {
+ const trimmed = tagInput.trim();
+ if (trimmed && !tags.includes(trimmed)) {
+ 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 = async () => {
+ if (!title.trim()) return;
+
+ let thumbnail: string | undefined;
+
+ if (thumbnailFile) {
+ thumbnail = await uploadImage(thumbnailFile);
+ }
+
+ createMutation.mutate({
+ title: title.trim(),
+ content: content.trim(),
+ thumbnail,
+ published,
+ tags,
+ });
+ };
+
+ const handleOverlayClick = (e: React.MouseEvent) => {
+ if (e.target === overlayRef.current) onClose();
+ };
+
+ return (
+
+
+ {/* 헤더 */}
+
+
새 LP 추가
+
+
+
+ {/* 썸네일 */}
+
+
+ {thumbnailPreview && (
+

+ )}
+
+
+
+ {/* 제목 */}
+
+
+ setTitle(e.target.value)}
+ placeholder="LP 제목을 입력하세요"
+ className="w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors"
+ style={{ borderColor: "#e0e9f8", color: "#1e738a" }} // 기본 테두리와 텍스트 색상
+ onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = "#3bf6f0")} // 포커스 시 테두리 색상 변경
+ onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = "#e0e9f8")}
+ />
+
+
+ {/* 내용 */}
+
+
+
+
+ {/* 태그 */}
+
+
+
+ setTagInput(e.target.value)}
+ onKeyDown={handleTagKeyDown}
+ placeholder="태그 입력 후 추가 버튼 클릭"
+ className="flex-1 rounded-lg border px-3 py-2 text-sm outline-none transition-colors"
+ style={{ borderColor: "#e0e9f8", color: "#1e738a" }}
+ onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = "#3bf6f0")}
+ onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = "#e0e9f8")}
+ />
+
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ #{tag}
+
+
+ ))}
+
+ )}
+
+
+ {/* 공개 여부 */}
+
+
+
+ {published ? "공개" : "비공개"}
+
+
+ {/* 에러 */}
+ {createMutation.isError && (
+
+ LP 생성에 실패했습니다. 다시 시도해주세요.
+
+ )}
+
+ {/* 제출 */}
+
+
+
+ );
+};
+
+// ── 메인 ──────────────────────────────────────────────
+export default function Home() {
+ const navigate = useNavigate();
+ const isLoggedIn = true; // 실제 auth 훅으로 교체
+
+ const [sortOrder, setSortOrder] = useState("newest");
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isModalOpen, setIsModalOpen] = useState(false); // LP 생성 모달 상태
+
+ // 무한스크롤 감지용 ref
+ const bottomRef = useRef(null);
+
+ const {
+ data,
+ isLoading,
+ isError,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: ["lps", sortOrder],
+ queryFn: ({ pageParam }) => fetchLps(sortOrder, pageParam as number | undefined),
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ initialPageParam: undefined,
+ staleTime: 1000 * 60,
+ gcTime: 1000 * 60 * 5,
+ });
+
+ // 모든 페이지 LP 평탄화
+ const lpList = data?.pages.flatMap((p) => p.data) ?? [];
+
+ const filtered = lpList.filter(
+ (lp) =>
+ lp.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ lp.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ lp.tags.some((t) => t.name.toLowerCase().includes(searchQuery.toLowerCase()))
+ );
+
+ // IntersectionObserver — 바닥 감지 시 fetchNextPage
+ const handleObserver = useCallback(
+ (entries: IntersectionObserverEntry[]) => {
+ if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ },
+ [hasNextPage, isFetchingNextPage, fetchNextPage]
+ );
+
+ useEffect(() => {
+ const el = bottomRef.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(handleObserver, { threshold: 0.1 });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [handleObserver]);
+
+ const handleCardClick = (lp: Lp) => {
+ if (!isLoggedIn) {
+ if (window.confirm("로그인이 필요한 서비스입니다.\n로그인 페이지로 이동하시겠습니까?")) {
+ navigate("/login");
+ }
+ return;
+ }
+ navigate(`/lp/${lp.id}`);
+ };
+
+ return (
+
+
+
+ {/* 서브헤더: 검색 + 정렬 */}
+
+
+ 🔍
+ setSearchQuery(e.target.value)}
+ className="w-full pl-9 pr-4 py-2 rounded-xl text-sm outline-none border transition-colors"
+ style={{ background: "white", borderColor: searchQuery ? "#3b82f6" : "#dbeafe", color: "#1e3a8a" }}
+ />
+
+
+ {/* 정렬 토글 — 변경 시 첫 페이지부터 다시 로딩 */}
+
+ {(["newest", "oldest"] as SortOrder[]).map((order) => (
+
+ ))}
+
+
+
+ {/* 결과 수 */}
+
+
+ {isLoading ? "불러오는 중..." : `${filtered.length}개의 LP`}
+
+
+
+ {/* 그리드 영역 */}
+
+
+ {/* 초기 로딩 — 상단 스켈레톤 */}
+ {isLoading &&
}
+
+ {isError &&
}
+
+ {!isLoading && !isError && (
+ <>
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((lp) => (
+ handleCardClick(lp)} />
+ ))}
+
+ )}
+
+ {/* 추가 로딩 — 하단 스켈레톤 */}
+ {isFetchingNextPage && (
+
+
+
+ )}
+
+ {/* IntersectionObserver 트리거 */}
+
+
+ {/* 마지막 페이지 안내 */}
+ {!hasNextPage && lpList.length > 0 && (
+
+ 모든 LP를 불러왔습니다
+
+ )}
+ >
+ )}
+
+
+
+ {/* 플로팅 + 버튼 */}
+
+
+ {isModalOpen &&
setIsModalOpen(false)} />} {/* 모달 컴포넌트는 추후 구현 */}
+
+ );
+}
diff --git a/week8/m1/src/pages/Login.tsx b/week8/m1/src/pages/Login.tsx
new file mode 100644
index 00000000..1c909524
--- /dev/null
+++ b/week8/m1/src/pages/Login.tsx
@@ -0,0 +1,123 @@
+import useForm from "../hooks/useForm";
+import { validateSignin, type UserSigninInfo } from "../utils/validate";
+import googleLogo from "../imgs/google.png";
+import { useNavigate } from "react-router-dom";
+import { useAuth } from "../context/AuthContext";
+import { useEffect } from "react";
+import { ChevronLeft } from "lucide-react";
+import { useMutation } from "@tanstack/react-query";
+
+export default function Login() {
+ const navigate = useNavigate();
+
+ const { login, isAuthenticated } = useAuth();
+
+ const { values, errors, touched, getInputProps } = useForm({
+ init_val: {
+ email: "",
+ password: "",
+ },
+ validate: validateSignin,
+ });
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ navigate("/");
+ }
+ }, [navigate, isAuthenticated]);
+
+ const loginMutation = useMutation({
+ mutationFn: () => login(values),
+ onSuccess: () => {
+ navigate("/");
+ },
+ onError: (err) => {
+ console.error("Login failed:", err);
+ },
+ });
+
+ const handleSubmit = () => {
+ loginMutation.mutate();
+ };
+
+ const handleGoogleLogin = () => {
+ window.location.href =
+ import.meta.env.VITE_SERVER_API_URL + "/v1/auth/google/login";
+ };
+
+ const isDisabled =
+ Object.values(errors || {}).some((err) => err) ||
+ !values.email ||
+ !values.password ||
+ loginMutation.isPending;
+
+ return (
+
+
+
+
로그인
+
+
+
+
+
+
+
+ OR
+
+
+
+
+
+ {errors?.email && touched?.email && (
+
{errors.email}
+ )}
+
+
+ {errors?.password && touched?.password && (
+
+ {errors.password}
+
+ )}
+
+
+ {loginMutation.isError && (
+
+ 이메일 또는 비밀번호가 올바르지 않습니다.
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/week8/m1/src/pages/LpDetail.tsx b/week8/m1/src/pages/LpDetail.tsx
new file mode 100644
index 00000000..c8608934
--- /dev/null
+++ b/week8/m1/src/pages/LpDetail.tsx
@@ -0,0 +1,632 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useAuth } from "../context/AuthContext";
+import {
+ deleteLpApi,
+ toggleLike,
+ postComment,
+ updateComment,
+ deleteComment,
+} from "../apis/lp";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+import axios from "axios";
+
+const COLORS = {
+ bg: "#F4FFFC",
+ soft: "#E9FFFC",
+ main: "#0ECFD3",
+ dark: "#0BAEB3",
+ text: "#063B3D",
+ border: "#B8F3EE",
+ muted: "#5B8C8E",
+ faint: "#7AA6A8",
+};
+
+const getAuthHeader = () => {
+ try {
+ const token = JSON.parse(
+ localStorage.getItem(LOCAL_STORAGE_KEY.ACCESS_TOKEN) ?? "null"
+ );
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ } catch {
+ return {};
+ }
+};
+
+interface Tag { id: number; name: string; }
+interface Likes { id: number; userId: number; }
+interface Lp {
+ id: number; title: string; content: string; thumbnail: string;
+ published: boolean; authorId: number; createdAt: Date; updatedAt: Date;
+ tags: Tag[]; likes: Likes[];
+}
+interface Comment {
+ id: number; content: string; authorId: number;
+ author: { name: string };
+ createdAt: Date;
+}
+interface CommentPage {
+ data: Comment[];
+ nextCursor: number | null;
+}
+
+type CommentOrder = "newest" | "oldest";
+
+const fetchLpDetail = async (lpId: string): Promise => {
+ const { data } = await axios.get(`${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}`);
+ const lp = data?.data ?? data;
+ if (!lp) throw new Error("LP 데이터가 없습니다.");
+ return lp;
+};
+
+const fetchComments = async (
+ lpId: string,
+ order: CommentOrder,
+ cursor?: number
+): Promise => {
+ const params = new URLSearchParams({
+ order: order === "newest" ? "desc" : "asc",
+ limit: "10",
+ });
+ if (cursor) params.append("cursor", String(cursor));
+
+ const { data } = await axios.get(
+ `${import.meta.env.VITE_SERVER_API_URL}/v1/lps/${lpId}/comments?${params}`,
+ { headers: getAuthHeader() }
+ );
+
+ return {
+ data: data?.data?.data ?? data?.data ?? [],
+ nextCursor: data?.data?.nextCursor ?? null,
+ };
+};
+
+const DetailSkeleton = () => (
+
+);
+
+const CommentSkeleton = () => (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+);
+
+const DetailError = ({ onRetry }: { onRetry: () => void }) => (
+
+
💿
+
데이터를 불러오지 못했습니다.
+
+
+);
+
+interface CommentMenuProps {
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+const CommentMenu = ({ onEdit, onDelete }: CommentMenuProps) => {
+ const [open, setOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ return (
+
+
+
+ {open && (
+
+
+
+
+ )}
+
+ );
+};
+
+export default function LpDetailPage() {
+ const { lpId } = useParams<{ lpId: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { userId: currentUserId } = useAuth();
+
+ const [commentOrder, setCommentOrder] = useState("newest");
+ const [commentInput, setCommentInput] = useState("");
+ const [commentError, setCommentError] = useState("");
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [editingContent, setEditingContent] = useState("");
+
+ const commentBottomRef = useRef(null);
+
+ const { data: lp, isLoading, isError, refetch } = useQuery({
+ queryKey: ["lp", lpId],
+ queryFn: () => fetchLpDetail(lpId!),
+ enabled: !!lpId,
+ staleTime: 1000 * 60,
+ gcTime: 1000 * 60 * 5,
+ });
+
+ const {
+ data: commentData,
+ isLoading: isCommentLoading,
+ isFetchingNextPage: isCommentFetchingNext,
+ fetchNextPage: fetchNextComments,
+ hasNextPage: hasNextComments,
+ } = useInfiniteQuery({
+ queryKey: ["lpComments", lpId, commentOrder],
+ queryFn: ({ pageParam }) =>
+ fetchComments(lpId!, commentOrder, pageParam as number | undefined),
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ initialPageParam: undefined,
+ enabled: !!lpId,
+ staleTime: 1000 * 30,
+ });
+
+ const comments = commentData?.pages.flatMap((p) => p.data) ?? [];
+
+ const handleCommentObserver = useCallback(
+ (entries: IntersectionObserverEntry[]) => {
+ if (entries[0].isIntersecting && hasNextComments && !isCommentFetchingNext) {
+ fetchNextComments();
+ }
+ },
+ [hasNextComments, isCommentFetchingNext, fetchNextComments]
+ );
+
+ useEffect(() => {
+ const el = commentBottomRef.current;
+ if (!el) return;
+
+ const observer = new IntersectionObserver(handleCommentObserver, { threshold: 0.1 });
+ observer.observe(el);
+
+ return () => observer.disconnect();
+ }, [handleCommentObserver]);
+
+ const deleteMutation = useMutation({
+ mutationFn: () => deleteLpApi(Number(lpId)),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["lps"] });
+ navigate(-1);
+ },
+ });
+
+ const likeMutation = useMutation({
+ mutationFn: () => toggleLike(Number(lpId)),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["lp", lpId] }),
+ });
+
+ const commentMutation = useMutation({
+ mutationFn: () =>
+ postComment({ lpId: Number(lpId), content: commentInput.trim() }),
+ onSuccess: () => {
+ setCommentInput("");
+ setCommentError("");
+ queryClient.invalidateQueries({ queryKey: ["lpComments", lpId] });
+ },
+ });
+
+ const editCommentMutation = useMutation({
+ mutationFn: ({ commentId, content }: { commentId: number; content: string }) =>
+ updateComment({ lpId: Number(lpId), commentId, content }),
+ onSuccess: () => {
+ setEditingCommentId(null);
+ setEditingContent("");
+ queryClient.invalidateQueries({ queryKey: ["lpComments", lpId] });
+ },
+ });
+
+ const deleteCommentMutation = useMutation({
+ mutationFn: (commentId: number) =>
+ deleteComment({ lpId: Number(lpId), commentId }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["lpComments", lpId] });
+ },
+ });
+
+ const handleCommentSubmit = () => {
+ if (commentInput.trim().length < 1) {
+ setCommentError("댓글을 입력해주세요.");
+ return;
+ }
+ if (commentInput.trim().length > 300) {
+ setCommentError("댓글은 300자 이하로 입력해주세요.");
+ return;
+ }
+ setCommentError("");
+ commentMutation.mutate();
+ };
+
+ const handleDelete = () => {
+ if (window.confirm("정말 삭제하시겠습니까?")) deleteMutation.mutate();
+ };
+
+ const handleEditStart = (comment: Comment) => {
+ setEditingCommentId(comment.id);
+ setEditingContent(comment.content);
+ };
+
+ const handleEditSubmit = (commentId: number) => {
+ if (!editingContent.trim()) return;
+ editCommentMutation.mutate({ commentId, content: editingContent.trim() });
+ };
+
+ const handleDeleteComment = (commentId: number) => {
+ if (window.confirm("댓글을 삭제하시겠습니까?")) {
+ deleteCommentMutation.mutate(commentId);
+ }
+ };
+
+ const isOwner = lp?.authorId === currentUserId;
+ const isLiked = lp?.likes.some((l) => l.userId === currentUserId) ?? false;
+
+ return (
+
+
+
+
+ {isOwner && lp && (
+
+
+
+
+ )}
+
+
+
+ {isLoading && }
+ {isError && }
+
+ {!isLoading && !isError && lp && (
+
+
+
+ {lp.thumbnail ? (
+

+ ) : (
+
+ )}
+
+
+
+
+ {!lp.published && (
+
+ 미발행
+
+ )}
+
+
+ {lp.title}
+
+
+
+ {new Date(lp.createdAt).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+ {lp.tags.length > 0 && (
+
+ {lp.tags.map((tag) => (
+
+ #{tag.name}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {lp.content}
+
+
+
+
+
+ 댓글 {comments.length}
+
+
+
+ {(["newest", "oldest"] as CommentOrder[]).map((order) => (
+
+ ))}
+
+
+
+
+
+ {isCommentLoading && }
+
+ {!isCommentLoading && (
+
+ {comments.length === 0 ? (
+
+ 첫 댓글을 남겨보세요!
+
+ ) : (
+ comments.map((comment) => {
+ const isMyComment = comment.authorId === currentUserId;
+ const isEditing = editingCommentId === comment.id;
+
+ return (
+
+
+
+ {comment.author?.name?.[0] ?? "?"}
+
+
+
+ {comment.author?.name ?? "익명"}
+
+
+
+ {new Date(comment.createdAt).toLocaleDateString("ko-KR", {
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ {isMyComment && (
+
handleEditStart(comment)}
+ onDelete={() => handleDeleteComment(comment.id)}
+ />
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {comment.content}
+
+ )}
+
+ );
+ })
+ )}
+
+ {isCommentFetchingNext && (
+
+ )}
+
+
+
+ {!hasNextComments && comments.length > 0 && (
+
+ 모든 댓글을 불러왔습니다
+
+ )}
+
+ )}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/week8/m1/src/pages/Mypage.tsx b/week8/m1/src/pages/Mypage.tsx
new file mode 100644
index 00000000..9138655f
--- /dev/null
+++ b/week8/m1/src/pages/Mypage.tsx
@@ -0,0 +1,297 @@
+import { useState, useRef } from "react";
+import { useNavigate } from "react-router";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { getMyInfo, patchMyInfo } from "../apis/auth";
+import { useAuth } from "../context/AuthContext";
+import type { ReqUpdateMyInfoDto } from "../types/auth";
+
+const fileToBase64 = (file: File): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+
+export default function MyPage() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { logout } = useAuth();
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editBio, setEditBio] = useState("");
+ const [avatarFile, setAvatarFile] = useState(null);
+ const [avatarPreview, setAvatarPreview] = useState("");
+
+ const fileInputRef = useRef(null);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["myInfo"],
+ queryFn: getMyInfo,
+ staleTime: 1000 * 60,
+ });
+
+ const user = data?.data;
+
+ const updateMutation = useMutation({
+ mutationFn: (body: ReqUpdateMyInfoDto) => patchMyInfo(body),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["myInfo"] });
+ setIsEditing(false);
+ setAvatarFile(null);
+ setAvatarPreview("");
+ },
+ });
+
+ const logoutMutation = useMutation({
+ mutationFn: logout,
+ onSuccess: () => navigate("/"),
+ });
+
+ const openEdit = () => {
+ setEditName(user?.name ?? "");
+ setEditBio(user?.bio ?? "");
+ setAvatarPreview(user?.avatar ?? "");
+ setIsEditing(true);
+ };
+
+ const handleAvatarChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setAvatarFile(file);
+ const preview = await fileToBase64(file);
+ setAvatarPreview(preview);
+ };
+
+ const handleSave = async () => {
+ const body: ReqUpdateMyInfoDto = {};
+ if (editName.trim()) body.name = editName.trim();
+ if (editBio !== undefined) body.bio = editBio;
+ if (avatarFile) {
+ body.avatar = await fileToBase64(avatarFile);
+ }
+ updateMutation.mutate(body);
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* 상단 헤더 */}
+
+
+ {/* 프로필 카드 */}
+
+ {/* 아바타 */}
+
+
+ {user?.avatar ? (
+

+ ) : (
+ user?.name?.[0] ?? "?"
+ )}
+
+
+
+ {/* 이름 */}
+
+
{user?.name}
+
{user?.email}
+ {user?.bio && (
+
{user.bio}
+ )}
+
+
+ {/* 버튼 영역 */}
+
+
+
+
+
+ {/* 가입일 */}
+ {user?.createdAt && (
+
+ 가입일: {new Date(user.createdAt).toLocaleDateString("ko-KR")}
+
+ )}
+
+
+ {/* 프로필 수정 모달 */}
+ {isEditing && (
+
{ if (e.target === e.currentTarget) setIsEditing(false); }}
+ >
+
+ {/* 헤더 */}
+
+
프로필 수정
+
+
+
+ {/* 아바타 수정 */}
+
+
fileInputRef.current?.click()}
+ >
+ {avatarPreview ? (
+

+ ) : (
+ user?.name?.[0] ?? "?"
+ )}
+
+
+
+
+
+ {/* 이름 */}
+
+
+ setEditName(e.target.value)}
+ placeholder="이름을 입력하세요"
+ className="w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors"
+ style={{ borderColor: "#e0e9f8", color: "#1e3a8a" }}
+ onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = "#0ECFD3")}
+ onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = "#e0e9f8")}
+ />
+
+
+ {/* Bio */}
+
+
+
+
+ {updateMutation.isError && (
+
저장에 실패했습니다. 다시 시도해주세요.
+ )}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ );
+
+}
diff --git a/week8/m1/src/pages/NotFoundErr.tsx b/week8/m1/src/pages/NotFoundErr.tsx
new file mode 100644
index 00000000..4f78dc76
--- /dev/null
+++ b/week8/m1/src/pages/NotFoundErr.tsx
@@ -0,0 +1,7 @@
+export default function NotFoundErr() {
+ return <>error>
+}
+
+/*
+라우터에서 경로 못 찾으면 NotFoundErr 페이지 보여주기
+*/
\ No newline at end of file
diff --git a/week8/m1/src/pages/SignUp.tsx b/week8/m1/src/pages/SignUp.tsx
new file mode 100644
index 00000000..427028da
--- /dev/null
+++ b/week8/m1/src/pages/SignUp.tsx
@@ -0,0 +1,178 @@
+import { useState } from "react";
+import { z } from "zod"; // 유효성 검사 라이브러리
+import { useForm, type SubmitHandler } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { postSignup } from "../apis/auth";
+import googleLogo from "../imgs/google.png";
+import { useNavigate } from "react-router-dom";
+import { Eye, EyeOff, ChevronLeft, Camera } from "lucide-react";
+
+
+// Schema 정의
+const schema = z.object({
+ email: z.string().email({ message: "올바른 이메일 형식이 아닙니다!" }),
+ password: z
+ .string()
+ .min(8, { message: "비밀번호는 8자 이상이어야 합니다!" })
+ .max(20, { message: "비밀번호는 20자 이하여야 합니다!" }),
+ passwordCheck: z.string(),
+ name: z
+ .string()
+ .min(2, { message: "닉네임은 2자 이상이어야 합니다." })
+ .max(10, { message: "닉네임은 10자 이하로 설정해주세요." })
+}).refine((data) => data.password === data.passwordCheck, {
+ message: "비밀번호가 일치하지 않습니다.",
+ path: ["passwordCheck"]
+});
+
+type FormFields = z.infer;
+
+export default function SignUp() {
+ const [step, setStep] = useState<1 | 2 | 3>(1);
+ const [showPw, setShowPw] = useState(false);
+ const [showPwCheck, setShowPwCheck] = useState(false);
+ const navigate = useNavigate();
+
+ const { register, handleSubmit, trigger, watch, formState: { errors, isSubmitting, isValid } } = useForm({
+ mode: "onChange",
+ defaultValues: { email: "", password: "", passwordCheck: "", name: "" },
+ resolver: zodResolver(schema),
+ });
+
+ const emailValue = watch("email");
+
+ // 단계별 유효성 검사 후 다음 단계로 이동
+ const goNext = async (fields: (keyof FormFields)[], nextStep: 1 | 2 | 3) => {
+ const isStepValid = await trigger(fields);
+ if (isStepValid) setStep(nextStep);
+ };
+
+ const onSubmit: SubmitHandler = async (data) => {
+ try {
+ const { passwordCheck, ...rest } = data;
+ await postSignup(rest); // rest에는 email, password, name(닉네임)이 포함됨
+ alert("회원가입이 완료되었습니다!");
+ navigate("/");
+ } catch (error: any) {
+ alert(error.response?.data?.message || "회원가입 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+
+ {/* 상단 헤더 */}
+
+
+
회원가입
+
+
+
+ {/* STEP 1: 이메일 입력 */}
+ {step === 1 && (
+
+
+
+
+ OR
+
+
+
+
+ {errors.email &&
{errors.email.message}
}
+
+
+
+ )}
+
+ {/* STEP 2: 비밀번호 설정 */}
+ {step === 2 && (
+
+
+
+
+
+
+ {errors.password &&
{errors.password.message}
}
+
+
+
+
+
+ {errors.passwordCheck &&
{errors.passwordCheck.message}
}
+
+
+
+ )}
+
+ {/* STEP 3: 닉네임(name) 및 프로필 설정 */}
+ {step === 3 && (
+
+
+
거의 다 왔어요!
+
사용하실 닉네임을 설정해주세요.
+
+
+ {/* 프로필 이미지 UI (Placeholder) */}
+
+
+
+
+ {errors.name &&
{errors.name.message}
}
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/week8/m1/src/types/auth.ts b/week8/m1/src/types/auth.ts
new file mode 100644
index 00000000..8b4ae23f
--- /dev/null
+++ b/week8/m1/src/types/auth.ts
@@ -0,0 +1,53 @@
+import type { CommonRes } from "./common";
+
+// 회원 가입
+export type ReqSignUpDto = { // 회원 가입 요청 시 필요한 데이터
+ name: string;
+ email: string;
+ bio?: string;
+ avatar?: string;
+ password: string;
+};
+
+export type ResSignUpDto = CommonRes<{ // 회원 가입 성공 시 반환되는 데이터
+ id: number;
+ name: string;
+ email: string;
+ bio: string | null;
+ avatar: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}>;
+
+// 로그인
+
+export type ReqSignInDto = { // 로그인 요청 시 필요한 데이터
+ email: string;
+ password: string;
+}
+
+export type ResSignInDto = CommonRes<{ // 로그인 성공 시 반환되는 데이터
+ id: number;
+ name: string;
+ accessToken: string;
+ refreshToken: string;
+}>
+
+// 내 정보 조회
+
+export type ResMyInfoDto = CommonRes<{ // 내 정보 조회 성공 시 반환되는 데이터
+ id: number;
+ name: string;
+ email: string;
+ bio: string | null;
+ avatar: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}>;
+
+// 프로필 수정
+export type ReqUpdateMyInfoDto = {
+ name?: string;
+ bio?: string;
+ avatar?: string;
+};
\ No newline at end of file
diff --git a/week8/m1/src/types/common.ts b/week8/m1/src/types/common.ts
new file mode 100644
index 00000000..ba384ece
--- /dev/null
+++ b/week8/m1/src/types/common.ts
@@ -0,0 +1,21 @@
+export type CommonRes = {
+ status: boolean;
+ message: string;
+ data: T
+}
+
+export type CursorBasedResponse = {
+ status: boolean;
+ statusCode: number;
+ message: string;
+ data: T;
+ nextCursor: number;
+ hasNext: boolean;
+}
+
+export type PaginationDto = {
+ cursor?: number;
+ limit?: number;
+ search?: string;
+ order?: "asc" | "desc";
+}
\ No newline at end of file
diff --git a/week8/m1/src/types/lp.ts b/week8/m1/src/types/lp.ts
new file mode 100644
index 00000000..a6444eab
--- /dev/null
+++ b/week8/m1/src/types/lp.ts
@@ -0,0 +1,51 @@
+import type { CursorBasedResponse, CommonRes } from "./common";
+
+export type Tag = {
+ id: number;
+ name: string;
+};
+
+export type Likes = {
+ id: number;
+ userId: number;
+ lpId: number;
+};
+
+export type LpData = {
+ id: number;
+ title: string;
+ content: string;
+ thumbnail: string;
+ published: boolean;
+ authorId: number;
+ createdAt: Date;
+ updatedAt: Date;
+ tags: Tag[];
+ likes: Likes[];
+};
+
+export type ResponseLpListDto = CursorBasedResponse<{
+ data: LpData;
+}>;
+
+export type ResLpDto = CommonRes;
+
+export type ReqCreateLpDto = {
+ title: string;
+ content: string;
+ thumbnail?: string;
+ published: boolean;
+ tags: string[];
+};
+
+export type CommentData = {
+ id: number;
+ content: string;
+ authorId: number;
+ author: { name: string };
+ createdAt: Date;
+};
+
+export type ResCommentListDto = CursorBasedResponse<{
+ data: CommentData[];
+}>;
\ No newline at end of file
diff --git a/week8/m1/src/utils/validate.ts b/week8/m1/src/utils/validate.ts
new file mode 100644
index 00000000..6ac21aaf
--- /dev/null
+++ b/week8/m1/src/utils/validate.ts
@@ -0,0 +1,27 @@
+export type UserSigninInfo = {
+ email: string;
+ password: string;
+}
+
+function validateUser(vals: UserSigninInfo) {
+ const errors = {
+ email: "",
+ password: ""
+ };
+
+ if (!(/^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i.test(vals.email)))
+ errors.email = "올바른 이메일 형식이 아닙니다!";
+
+ if (!(vals.password.length >= 8 && vals.password.length < 20))
+ errors.password = "비밀번호는 8 ~ 20자 사이로 입력해주세요!";
+
+ return errors;
+}
+
+// 로그인 유효성 검사
+
+function validateSignin(vals: UserSigninInfo) {
+ return validateUser(vals);
+}
+
+export { validateSignin }
\ No newline at end of file
diff --git a/week8/m1/tsconfig.json b/week8/m1/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/week8/m1/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/week8/m1/vite-env.d.ts b/week8/m1/vite-env.d.ts
new file mode 100644
index 00000000..f56ab64d
--- /dev/null
+++ b/week8/m1/vite-env.d.ts
@@ -0,0 +1,16 @@
+///
+
+interface ViteTypeOptions {
+ // 아래 라인을 추가하면, ImportMetaEnv 타입을 엄격하게 설정해
+ // 알 수 없는 키를 허용하지 않게 할 수 있습니다.
+ // strictImportMetaEnv: unknown
+}
+
+interface ImportMetaEnv {
+ readonly VITE_SERVER_API_URL: string
+ // 다른 환경 변수들에 대한 타입 정의...
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
\ No newline at end of file
diff --git a/week8/m1/vite.config.ts b/week8/m1/vite.config.ts
new file mode 100644
index 00000000..004a47f7
--- /dev/null
+++ b/week8/m1/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+});
\ No newline at end of file