diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/3\354\243\274\354\260\250 \353\257\270\354\205\230/mission1/WEB_STUDY" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/3\354\243\274\354\260\250 \353\257\270\354\205\230/mission1/WEB_STUDY"
new file mode 160000
index 00000000..8d92b9f9
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/3\354\243\274\354\260\250 \353\257\270\354\205\230/mission1/WEB_STUDY"
@@ -0,0 +1 @@
+Subproject commit 8d92b9f9ed25efbf01de71589c639b873024cff1
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/.gitignore" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/.gitignore"
new file mode 100644
index 00000000..1cac5597
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/.gitignore"
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.env
\ No newline at end of file
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/index.html" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/index.html"
new file mode 100644
index 00000000..440d0f63
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/index.html"
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Movie App
+
+
+
+
+
+
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/package.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/package.json"
new file mode 100644
index 00000000..b9a04171
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/package.json"
@@ -0,0 +1,35 @@
+{
+ "name": "mission1",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.3",
+ "@tanstack/react-query": "^5.74.8",
+ "@tanstack/react-query-devtools": "^5.74.8",
+ "axios": "^1.9.0",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-router-dom": "^7.5.0",
+ "tailwindcss": "^4.1.3"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.21.0",
+ "@types/react": "^19.1.4",
+ "@types/react-dom": "^19.1.4",
+ "@vitejs/plugin-react": "^4.3.4",
+ "eslint": "^9.21.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "globals": "^15.15.0",
+ "typescript": "~5.7.2",
+ "typescript-eslint": "^8.24.1",
+ "vite": "^6.2.0"
+ }
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/App.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/App.tsx"
new file mode 100644
index 00000000..c04f62db
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/App.tsx"
@@ -0,0 +1,22 @@
+import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import Home from "./pages/home";
+import Movies from "./pages/movies";
+import MovieDetail from "./pages/moviedetail";
+import Layout from "./components/Navbar";
+
+function App() {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
+
+export default App;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/apis/movie.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/apis/movie.ts"
new file mode 100644
index 00000000..6524d4f4
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/apis/movie.ts"
@@ -0,0 +1,31 @@
+import axios from "axios";
+
+const TMDB_TOKEN = import.meta.env.VITE_TMDB_TOKEN;
+const BASE_URL = import.meta.env.VITE_TMDB_BASE_URL;
+
+export type MovieType = "popular" | "now_playing" | "top_rated" | "upcoming";
+
+export const getMovies = async (type: MovieType, page: number = 1) => {
+ const response = await axios.get(`${BASE_URL}/movie/${type}`, {
+ headers: {
+ Authorization: `Bearer ${TMDB_TOKEN}`,
+ },
+ params: {
+ language: "ko-KR",
+ page,
+ },
+ });
+ return response.data;
+};
+
+export const getMovieDetail = async (id: number) => {
+ const response = await axios.get(`${BASE_URL}/movie/${id}`, {
+ headers: {
+ Authorization: `Bearer ${TMDB_TOKEN}`,
+ },
+ params: {
+ language: "ko-KR",
+ },
+ });
+ return response.data;
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/assets/profile.png" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/assets/profile.png"
new file mode 100644
index 00000000..cccf18f9
Binary files /dev/null and "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/assets/profile.png" differ
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Error.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Error.tsx"
new file mode 100644
index 00000000..6fa8762f
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Error.tsx"
@@ -0,0 +1,16 @@
+import React from "react";
+
+interface ErrorProps {
+ message: string;
+}
+
+const Error = ({ message }: ErrorProps) => {
+ return (
+
+
오류가 발생했습니다
+
{message}
+
+ );
+};
+
+export default Error;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Layout.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Layout.tsx"
new file mode 100644
index 00000000..a44d5743
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Layout.tsx"
@@ -0,0 +1,16 @@
+import { ReactElement } from "react";
+import { Outlet } from "react-router-dom";
+import Navbar from "./Navbar";
+
+const Layout = (): ReactElement => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default Layout;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Loading.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Loading.tsx"
new file mode 100644
index 00000000..0b908ca7
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Loading.tsx"
@@ -0,0 +1,13 @@
+import { ReactElement } from "react";
+import React from "react";
+
+const Loading = (): ReactElement => {
+ return (
+
+ );
+};
+
+export default Loading;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Loadingspinner.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Loadingspinner.tsx"
new file mode 100644
index 00000000..9e85a830
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Loadingspinner.tsx"
@@ -0,0 +1,12 @@
+import { ReactElement } from "react";
+
+const LoadingSpinner = (): ReactElement => {
+ return (
+
+ );
+};
+
+export default LoadingSpinner;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieCard.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieCard.tsx"
new file mode 100644
index 00000000..d7e209d7
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieCard.tsx"
@@ -0,0 +1,39 @@
+import React from "react";
+import { Link } from "react-router-dom";
+import profileImage from "../assets/Profile.png";
+
+interface MovieCardProps {
+ id: number;
+ title: string;
+ poster_path: string;
+ vote_average: number;
+ release_date: string;
+ overview: string;
+}
+
+const MovieCard = ({ id, title, poster_path, overview }: MovieCardProps) => {
+ return (
+
+
+

{
+ const target = e.target as HTMLImageElement;
+ target.src = profileImage;
+ }}
+ />
+
+
+
+ );
+};
+
+export default MovieCard;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieGrid.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieGrid.tsx"
new file mode 100644
index 00000000..4e3ea2e8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieGrid.tsx"
@@ -0,0 +1,20 @@
+// components/MovieGrid.tsx
+import React from "react";
+import MovieCard from "./MovieCard";
+import { Movie } from "../hooks/useCustomFetch";
+
+interface MovieGridProps {
+ movies: Movie[];
+}
+
+const MovieGrid = ({ movies }: MovieGridProps) => {
+ return (
+
+ {movies.map((movie) => (
+
+ ))}
+
+ );
+};
+
+export default MovieGrid;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieList.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieList.tsx"
new file mode 100644
index 00000000..64fde314
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/MovieList.tsx"
@@ -0,0 +1,41 @@
+import { useCustomFetch, MovieType } from "../hooks/useCustomFetch";
+import MovieGrid from "./MovieGrid";
+import Loading from "./Loading";
+import Error from "./Error";
+import Pagination from "./renderPagination";
+import React from "react";
+
+interface MovieListProps {
+ type: MovieType;
+}
+
+const MovieList = ({ type }: MovieListProps) => {
+ const { data, loading, error, goToPage, currentPage } = useCustomFetch({
+ type,
+ });
+
+ return (
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ ) : (
+ <>
+ {data && data.total_pages > 1 && (
+
+ )}
+
+ >
+ )}
+
+ );
+};
+
+export default MovieList;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Navbar.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Navbar.tsx"
new file mode 100644
index 00000000..5d25bed8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/Navbar.tsx"
@@ -0,0 +1,39 @@
+import { Link, useLocation, Outlet } from "react-router-dom";
+import React from "react";
+
+const Layout = () => {
+ const location = useLocation();
+
+ const links = [
+ { path: "/", label: "홈" },
+ { path: "/movies/popular", label: "인기" },
+ { path: "/movies/now_playing", label: "상영 중" },
+ { path: "/movies/top_rated", label: "평점 높은" },
+ { path: "/movies/upcoming", label: "개봉 예정" },
+ ];
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default Layout;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/renderPagination.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/renderPagination.tsx"
new file mode 100644
index 00000000..b7d312fc
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/components/renderPagination.tsx"
@@ -0,0 +1,33 @@
+import React from "react";
+
+type PaginationProps = {
+ currentPage: number;
+ totalPages: number;
+ goToPage: (page: number) => void;
+};
+
+const Pagination = ({ currentPage, totalPages, goToPage }: PaginationProps) => (
+
+
+
+ {currentPage} / {totalPages}
+
+
+
+);
+
+export default Pagination;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useCustomFetch.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useCustomFetch.ts"
new file mode 100644
index 00000000..937c1608
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useCustomFetch.ts"
@@ -0,0 +1,64 @@
+import { useState, useEffect } from "react";
+import { useSearchParams } from "react-router-dom";
+import { getMovies } from "../apis/movie";
+
+export type MovieType = "popular" | "now_playing" | "top_rated" | "upcoming";
+
+interface UseCustomFetchProps {
+ type: MovieType;
+}
+
+export interface Movie {
+ id: number;
+ title: string;
+ poster_path: string;
+ release_date: string;
+ vote_average: number;
+ overview: string;
+}
+
+interface MovieResponse {
+ results: Movie[];
+ total_pages: number;
+ total_results: number;
+ page: number;
+}
+
+export const useCustomFetch = ({ type }: UseCustomFetchProps) => {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const page = parseInt(searchParams.get("page") || "1", 10);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ const response = await getMovies(type, page);
+ setData(response);
+ setError(null);
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err
+ : new Error("알 수 없는 오류가 발생했습니다.")
+ );
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [type, page]);
+
+ const goToPage = (newPage: number) => {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("page", newPage.toString());
+ setSearchParams(newSearchParams);
+ };
+
+ return { data, loading, error, goToPage, currentPage: page };
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useCustomMovies.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useCustomMovies.ts"
new file mode 100644
index 00000000..15ab741e
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useCustomMovies.ts"
@@ -0,0 +1,70 @@
+import { useState, useEffect } from "react";
+import axios from "axios";
+
+interface Movie {
+ id: number;
+ title: string;
+ overview: string;
+ poster_path: string;
+ vote_average: number;
+ release_date: string;
+}
+
+interface UseLatestMoviesReturn {
+ movies: Movie[];
+ totalPages: number;
+ loading: boolean;
+ error: Error | null;
+}
+
+const BASE_URL = "https://api.themoviedb.org/3";
+const TOKEN = import.meta.env.VITE_TMDB_TOKEN;
+
+export const useLatestMovies = (page: number = 1): UseLatestMoviesReturn => {
+ const [movies, setMovies] = useState([]);
+ const [totalPages, setTotalPages] = useState(1);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchLatestMovies = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const headers = {
+ Authorization: `Bearer ${TOKEN}`,
+ accept: "application/json",
+ };
+
+ const response = await axios.get(
+ `${BASE_URL}/movie/now_playing?language=ko-KR&page=${page}`,
+ { headers }
+ );
+
+ if (!response.data) {
+ throw new Error("API 응답이 비어있습니다.");
+ }
+
+ if (!response.data.results || !response.data.total_pages) {
+ throw new Error("영화 데이터 형식이 올바르지 않습니다.");
+ }
+
+ setMovies(response.data.results);
+ setTotalPages(response.data.total_pages);
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err
+ : new Error("영화 데이터를 불러오는데 실패했습니다.")
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchLatestMovies();
+ }, [page]);
+
+ return { movies, totalPages, loading, error };
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useMovieData.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useMovieData.ts"
new file mode 100644
index 00000000..51e8d064
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/hooks/useMovieData.ts"
@@ -0,0 +1,88 @@
+import { useState, useEffect } from "react";
+import axios from "axios";
+
+interface MovieData {
+ id: number;
+ title: string;
+ overview: string;
+ poster_path: string;
+ vote_average: number;
+ release_date: string;
+ runtime?: number;
+ tagline?: string;
+ genres?: { id: number; name: string }[];
+}
+
+interface MovieCredits {
+ cast: {
+ id: number;
+ name: string;
+ character: string;
+ profile_path: string | null;
+ }[];
+ crew: {
+ id: number;
+ name: string;
+ job: string;
+ }[];
+}
+
+interface UseMovieDataReturn {
+ data: MovieData | null;
+ credits: MovieCredits | null;
+ loading: boolean;
+ error: Error | null;
+}
+
+const BASE_URL = "https://api.themoviedb.org/3";
+const TOKEN = import.meta.env.VITE_TMDB_TOKEN;
+
+export const useMovieData = (
+ movieId: string | undefined
+): UseMovieDataReturn => {
+ const [data, setData] = useState(null);
+ const [credits, setCredits] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchMovieData = async () => {
+ if (!movieId) {
+ setLoading(false);
+ return;
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const headers = {
+ Authorization: `Bearer ${TOKEN}`,
+ accept: "application/json",
+ };
+
+ const [movieResponse, creditsResponse] = await Promise.all([
+ axios.get(`${BASE_URL}/movie/${movieId}?language=ko-KR`, { headers }),
+ axios.get(`${BASE_URL}/movie/${movieId}/credits?language=ko-KR`, {
+ headers,
+ }),
+ ]);
+
+ setData(movieResponse.data);
+ setCredits(creditsResponse.data);
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err
+ : new Error("An error occurred while fetching movie data")
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchMovieData();
+ }, [movieId]);
+
+ return { data, credits, loading, error };
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/index.css" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/index.css"
new file mode 100644
index 00000000..c5dd9fc4
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/index.css"
@@ -0,0 +1,435 @@
+@import "tailwindcss/preflight";
+@import "tailwindcss/utilities";
+@import "tailwindcss";
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ overflow-x: hidden;
+}
+
+.layout {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
+.navbar {
+ background-color: #1a1a1a;
+ padding: 1rem 0;
+ width: 100%;
+ position: sticky;
+ top: 0;
+ z-index: 20;
+}
+
+.navbar-container {
+ width: 100%;
+ padding: 0;
+ display: flex;
+ gap: 2rem;
+ justify-content: flex-start;
+ margin-left: 100px;
+}
+
+.nav-link {
+ color: #ffffff;
+ text-decoration: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+}
+
+.nav-link:hover {
+ background-color: #333333;
+}
+
+.nav-link.active {
+ background-color: #4a4a4a;
+}
+
+main {
+ flex: 1;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.movie-grid {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 20px;
+ justify-items: center;
+}
+
+/* 반응형 디자인 */
+@media (max-width: 1200px) {
+ .movie-grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .movie-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (max-width: 576px) {
+ .movie-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 320px) {
+ .movie-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.movie-card {
+ position: relative;
+ width: 100%;
+ padding-top: 150%;
+ cursor: pointer;
+ transition: transform 0.2s;
+ overflow: hidden;
+ border-radius: 8px;
+ text-decoration: none;
+ color: inherit;
+}
+
+.movie-poster {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.movie-poster img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ transition: filter 0.3s ease;
+}
+
+.movie-card:hover img {
+ filter: blur(5px) brightness(0.3);
+}
+
+.movie-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ border-radius: 8px;
+}
+
+.movie-card:hover .movie-overlay {
+ opacity: 1;
+}
+
+.movie-title {
+ color: white;
+ font-size: 1rem;
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+ text-align: center;
+}
+
+.movie-overview {
+ color: white;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ padding: 0 1rem;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ text-overflow: ellipsis;
+}
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 20px;
+ margin: 1rem 0;
+ padding: 1rem;
+ position: sticky;
+ top: 64px;
+ z-index: 10;
+ background-color: transparent;
+}
+
+.pagination-button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 4px;
+ background-color: #1a1a1a;
+ color: white;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.pagination-button:hover:not(.disabled) {
+ background-color: #333333;
+}
+
+.pagination-button.disabled {
+ background-color: #cccccc;
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
+.pagination-info {
+ font-size: 1rem;
+ color: #333333;
+ min-width: 100px;
+ text-align: center;
+}
+
+.loading-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - 300px);
+ margin-top: 2rem;
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 5px solid #f3f3f3;
+ border-top: 5px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.loading-text {
+ margin-top: 1rem;
+ color: #666;
+ font-size: 1.1rem;
+ text-align: center;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.error-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 50vh;
+ padding: 2rem;
+ text-align: center;
+}
+
+.error-message {
+ color: #e74c3c;
+ font-size: 1.2rem;
+ margin-bottom: 1rem;
+}
+
+.retry-button {
+ padding: 0.5rem 1rem;
+ background-color: #3498db;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: background-color 0.2s;
+}
+
+.retry-button:hover {
+ background-color: #2980b9;
+}
+
+.movie-detail {
+ padding-top: 4rem;
+ padding-bottom: 3rem;
+ padding-left: 2rem;
+ padding-right: 2rem;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.movie-content {
+ display: flex;
+ gap: 2.5rem;
+ margin-bottom: 3rem;
+ align-items: flex-start;
+}
+
+.movie-detail-poster {
+ flex-shrink: 0;
+ width: 300px;
+ height: auto;
+}
+
+.movie-detail-poster img {
+ width: 100%;
+ height: auto;
+ border-radius: 8px;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+ object-fit: cover;
+}
+
+.movie-info {
+ flex: 1;
+}
+
+.movie-info h1 {
+ font-size: 2.5rem;
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+ line-height: 1.2;
+}
+
+.tagline {
+ font-size: 1.2rem;
+ color: #666;
+ margin-bottom: 1.5rem;
+ font-style: italic;
+}
+
+.movie-meta {
+ display: flex;
+ gap: 1.5rem;
+ margin-bottom: 1.5rem;
+ color: #555;
+}
+
+.movie-meta span {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.genres {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.genre {
+ background: #e9ecef;
+ padding: 0.4rem 1rem;
+ border-radius: 20px;
+ font-size: 0.9rem;
+ color: #495057;
+}
+
+.overview {
+ font-size: 1.1rem;
+ line-height: 1.6;
+ color: #444;
+ margin-bottom: 2rem;
+}
+
+.credits-section {
+ margin-top: 2rem;
+}
+
+.credits-section h2 {
+ font-size: 1.8rem;
+ margin: 0 0 1.5rem;
+ color: #1a1a1a;
+ border-bottom: 2px solid #e9ecef;
+ padding-bottom: 0.5rem;
+}
+
+.credits-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 2rem;
+ margin-top: 2rem;
+}
+
+.credit-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.credit-image {
+ width: 150px;
+ height: 225px;
+ border-radius: 8px;
+ object-fit: cover;
+ margin-bottom: 1rem;
+}
+
+.credit-info {
+ width: 100%;
+}
+
+.credit-info h3 {
+ font-size: 1rem;
+ margin-bottom: 0.5rem;
+}
+
+.credit-info p {
+ font-size: 0.9rem;
+ color: #666;
+}
+
+@media (max-width: 768px) {
+ .movie-content {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .movie-detail-poster {
+ width: 100%;
+ max-width: 300px;
+ margin-bottom: 1rem;
+ }
+
+ .movie-info {
+ width: 100%;
+ text-align: center;
+ }
+
+ .movie-meta {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .genres {
+ justify-content: center;
+ }
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/main.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/main.tsx"
new file mode 100644
index 00000000..5d2898b1
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/main.tsx"
@@ -0,0 +1,13 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./index.css";
+
+const rootElement = document.getElementById("root");
+if (!rootElement) throw new Error("Failed to find the root element");
+
+ReactDOM.createRoot(rootElement).render(
+
+
+
+);
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/home.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/home.tsx"
new file mode 100644
index 00000000..ad0d8e12
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/home.tsx"
@@ -0,0 +1,151 @@
+import { useEffect, useState, useCallback } from "react";
+import { useSearchParams } from "react-router-dom";
+import axios from "axios";
+import MovieCard from "../components/MovieCard";
+import LoadingSpinner from "../components/Loadingspinner";
+import type { TMovie, TMovieResponse } from "../types/movie";
+
+const Home = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [movies, setMovies] = useState([]);
+ const [totalPages, setTotalPages] = useState(1);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const currentPage = Number(searchParams.get("page")) || 1;
+
+ const fetchMovies = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ const response = await axios.get(
+ `https://api.themoviedb.org/3/movie/popular?language=en-US&page=${currentPage}`,
+ {
+ headers: {
+ Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+ },
+ }
+ );
+
+ if (
+ !response.data ||
+ !response.data.results ||
+ !response.data.total_pages
+ ) {
+ throw new Error("영화 데이터 형식이 올바르지 않습니다.");
+ }
+
+ setMovies(response.data.results as TMovie[]);
+
+ const maxPages = Math.min(response.data.total_pages, 500);
+ setTotalPages(maxPages);
+ } catch (error) {
+ console.error("Error fetching movies:", error);
+ setError(
+ error instanceof Error
+ ? error.message
+ : "영화 데이터를 불러오는데 실패했습니다."
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, [currentPage]);
+
+ useEffect(() => {
+ fetchMovies();
+ }, [fetchMovies]);
+
+ const handlePageChange = (newPage: number) => {
+ if (newPage < 1 || newPage > totalPages) return;
+ setSearchParams({ page: newPage.toString() });
+ };
+
+ const handlePageJump = (e: React.FormEvent) => {
+ e.preventDefault();
+ const pageInput = (
+ e.currentTarget.elements.namedItem("page") as HTMLInputElement
+ ).value;
+ const pageNum = Number(pageInput);
+ if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
+ handlePageChange(pageNum);
+ }
+ };
+
+ const renderPagination = () => (
+
+
+
+ {currentPage} / {totalPages}
+
+
+
+
+ );
+
+ if (isLoading) {
+ return (
+
+ {renderPagination()}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {renderPagination()}
+
+ {movies.map((movie: TMovie) => (
+
+ ))}
+
+
+ );
+};
+
+export default Home;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/moviedetail.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/moviedetail.tsx"
new file mode 100644
index 00000000..793b2ed0
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/moviedetail.tsx"
@@ -0,0 +1,57 @@
+import { useState, useEffect } from "react";
+import { useParams } from "react-router-dom";
+import axios from "axios";
+import type { MovieDetail as MovieDetailType, Credits } from "../types/movie";
+import Loading from "../components/Loading";
+import Error from "../components/Error";
+
+const MovieDetail = () => {
+ const { id } = useParams();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchMovie = async () => {
+ try {
+ setLoading(true);
+ const response = await axios.get(
+ `https://api.themoviedb.org/3/movie/${id}?api_key=${
+ import.meta.env.VITE_TMDB_TOKEN
+ }&language=ko-KR&append_to_response=credits`
+ );
+ setData(response.data);
+ } catch (err: any) {
+ setError(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchMovie();
+ }, [id]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (!data) return null;
+
+ return (
+
+
+ {data.title}
+
+

+
+ {data.overview}
+
+
+ );
+};
+
+export default MovieDetail;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/movies.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/movies.tsx"
new file mode 100644
index 00000000..39d9ea4d
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/pages/movies.tsx"
@@ -0,0 +1,21 @@
+import { useParams } from "react-router-dom";
+import { MovieType } from "../hooks/useCustomFetch";
+import MovieList from "../components/MovieList";
+
+const Movies = () => {
+ const { category } = useParams<{ category: string }>();
+
+ const getMovieType = (): MovieType => {
+ const categoryToTypeMap: Record = {
+ popular: "popular",
+ now_playing: "now_playing",
+ top_rated: "top_rated",
+ upcoming: "upcoming",
+ };
+ return categoryToTypeMap[category || ""] || "popular";
+ };
+
+ return ;
+};
+
+export default Movies;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/postcss.config.js" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/postcss.config.js"
new file mode 100644
index 00000000..2aa7205d
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/postcss.config.js"
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/routes/routes.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/routes/routes.tsx"
new file mode 100644
index 00000000..9df27c3f
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/routes/routes.tsx"
@@ -0,0 +1,16 @@
+import { createBrowserRouter } from "react-router-dom";
+import Home from "../pages/home";
+import React from "react";
+
+const router = createBrowserRouter([
+ {
+ path: "/home",
+ element: ,
+ errorElement: 404 Not Found
,
+ },
+ {
+ path: "/",
+ element: ,
+ },
+]);
+export default router;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/tailwind.config.js" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/tailwind.config.js"
new file mode 100644
index 00000000..3a400ba0
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/tailwind.config.js"
@@ -0,0 +1,7 @@
+export default {
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/tsconfig.app.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/tsconfig.app.json"
new file mode 100644
index 00000000..5645a1f8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/tsconfig.app.json"
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "node",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "allowSyntheticDefaultImports": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Types */
+ "types": ["vite/client", "react/jsx-runtime"]
+ },
+ "include": ["src"]
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/types/movie.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/types/movie.ts"
new file mode 100644
index 00000000..7b6d78ac
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/types/movie.ts"
@@ -0,0 +1,61 @@
+export interface MovieDetail {
+ id: number;
+ title: string;
+ overview: string;
+ poster_path: string;
+ release_date: string;
+ vote_average: number;
+ genres: { id: number; name: string }[];
+ runtime: number;
+ budget: number;
+ revenue: number;
+ tagline: string;
+ status: string;
+ production_companies: {
+ id: number;
+ name: string;
+ logo_path: string | null;
+ }[];
+}
+
+export interface Credits {
+ cast: {
+ id: number;
+ name: string;
+ character: string;
+ profile_path: string | null;
+ credit_id: string;
+ }[];
+ crew: Crew[];
+}
+
+export interface Crew {
+ id: number;
+ name: string;
+ job: string;
+ profile_path: string | null;
+ credit_id: string;
+}
+
+// types/movie.ts
+export type TMovie = {
+ id: number;
+ title: string;
+ poster_path: string;
+ vote_average: number;
+ release_date: string;
+ overview: string;
+};
+
+// types/movie.ts
+export type TMovieResponse = {
+ page: number;
+ results: {
+ id: number;
+ title: string;
+ poster_path: string;
+ vote_average: number;
+ }[];
+ total_pages: number;
+ total_results: number;
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/vite-env.d.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/vite-env.d.ts"
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/src/vite-env.d.ts"
@@ -0,0 +1 @@
+///
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/tsconfig.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/tsconfig.json"
new file mode 100644
index 00000000..81ff59e3
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/tsconfig.json"
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "strict": true,
+ "jsx": "react-jsx",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src", "vite-env.d.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/tsconfig.node.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/tsconfig.node.json"
new file mode 100644
index 00000000..db0becc8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/tsconfig.node.json"
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/vite.config.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/vite.config.ts"
new file mode 100644
index 00000000..3e8f4fb8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\230/vite.config.ts"
@@ -0,0 +1,5 @@
+import { defineConfig } from "vite";
+import tailwindcss from "@tailwindcss/vite";
+export default defineConfig({
+ plugins: [tailwindcss()],
+});
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/.env" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/.env"
new file mode 100644
index 00000000..93fa24af
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/.env"
@@ -0,0 +1 @@
+VITE_API_URL=http://localhost:8000
\ No newline at end of file
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/index.html" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/index.html"
new file mode 100644
index 00000000..e0d1c840
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/index.html"
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/package.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/package.json"
new file mode 100644
index 00000000..510d6548
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/package.json"
@@ -0,0 +1,36 @@
+{
+ "name": "week4-mission2",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@hookform/resolvers": "^5.0.1",
+ "@tailwindcss/vite": "^4.1.6",
+ "axios": "^1.9.0",
+ "react-hook-form": "^7.56.3",
+ "react-router-dom": "^7.6.0",
+ "zod": "^3.24.4"
+ },
+ "description": "",
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.1.7",
+ "@types/axios": "^0.9.36",
+ "@types/node": "^22.15.17",
+ "@types/react": "^19.1.4",
+ "@types/react-dom": "^19.1.4",
+ "@types/react-router-dom": "^5.3.3",
+ "@vitejs/plugin-react": "^4.4.1",
+ "autoprefixer": "^10.4.21",
+ "postcss": "^8.5.3",
+ "tailwindcss": "^4.1.7",
+ "vite": "^6.3.5"
+ }
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/pnpm-lock.yaml" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/pnpm-lock.yaml"
new file mode 100644
index 00000000..0721ff56
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/pnpm-lock.yaml"
@@ -0,0 +1,1002 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.6
+ version: 4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))
+ tailwindcss:
+ specifier: ^4.1.6
+ version: 4.1.6
+
+packages:
+
+ '@ampproject/remapping@2.3.0':
+ resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
+ engines: {node: '>=6.0.0'}
+
+ '@esbuild/aix-ppc64@0.25.4':
+ resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.4':
+ resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.4':
+ resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.4':
+ resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.4':
+ resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.4':
+ resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.4':
+ resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.4':
+ resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.4':
+ resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.4':
+ resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.4':
+ resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.4':
+ resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.4':
+ resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.4':
+ resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.4':
+ resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.4':
+ resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.4':
+ resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.4':
+ resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.4':
+ resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.4':
+ resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.4':
+ resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.25.4':
+ resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.4':
+ resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.4':
+ resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.4':
+ resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
+ '@jridgewell/gen-mapping@0.3.8':
+ resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/set-array@1.2.1':
+ resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.0':
+ resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+
+ '@jridgewell/trace-mapping@0.3.25':
+ resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+
+ '@rollup/rollup-android-arm-eabi@4.40.2':
+ resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.40.2':
+ resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.40.2':
+ resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.40.2':
+ resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.40.2':
+ resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.40.2':
+ resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.40.2':
+ resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.40.2':
+ resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.40.2':
+ resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.40.2':
+ resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.40.2':
+ resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
+ resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.40.2':
+ resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.40.2':
+ resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.40.2':
+ resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.40.2':
+ resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.40.2':
+ resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-win32-arm64-msvc@4.40.2':
+ resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.40.2':
+ resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.40.2':
+ resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.1.6':
+ resolution: {integrity: sha512-ed6zQbgmKsjsVvodAS1q1Ld2BolEuxJOSyyNc+vhkjdmfNUDCmQnlXBfQkHrlzNmslxHsQU/bFmzcEbv4xXsLg==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.6':
+ resolution: {integrity: sha512-VHwwPiwXtdIvOvqT/0/FLH/pizTVu78FOnI9jQo64kSAikFSZT7K4pjyzoDpSMaveJTGyAKvDjuhxJxKfmvjiQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.6':
+ resolution: {integrity: sha512-weINOCcqv1HVBIGptNrk7c6lWgSFFiQMcCpKM4tnVi5x8OY2v1FrV76jwLukfT6pL1hyajc06tyVmZFYXoxvhQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.6':
+ resolution: {integrity: sha512-3FzekhHG0ww1zQjQ1lPoq0wPrAIVXAbUkWdWM8u5BnYFZgb9ja5ejBqyTgjpo5mfy0hFOoMnMuVDI+7CXhXZaQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.6':
+ resolution: {integrity: sha512-4m5F5lpkBZhVQJq53oe5XgJ+aFYWdrgkMwViHjRsES3KEu2m1udR21B1I77RUqie0ZYNscFzY1v9aDssMBZ/1w==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.6':
+ resolution: {integrity: sha512-qU0rHnA9P/ZoaDKouU1oGPxPWzDKtIfX7eOGi5jOWJKdxieUJdVV+CxWZOpDWlYTd4N3sFQvcnVLJWJ1cLP5TA==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.6':
+ resolution: {integrity: sha512-jXy3TSTrbfgyd3UxPQeXC3wm8DAgmigzar99Km9Sf6L2OFfn/k+u3VqmpgHQw5QNfCpPe43em6Q7V76Wx7ogIQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.6':
+ resolution: {integrity: sha512-8kjivE5xW0qAQ9HX9reVFmZj3t+VmljDLVRJpVBEoTR+3bKMnvC7iLcoSGNIUJGOZy1mLVq7x/gerVg0T+IsYw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.6':
+ resolution: {integrity: sha512-A4spQhwnWVpjWDLXnOW9PSinO2PTKJQNRmL/aIl2U/O+RARls8doDfs6R41+DAXK0ccacvRyDpR46aVQJJCoCg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.6':
+ resolution: {integrity: sha512-YRee+6ZqdzgiQAHVSLfl3RYmqeeaWVCk796MhXhLQu2kJu2COHBkqlqsqKYx3p8Hmk5pGCQd2jTAoMWWFeyG2A==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.6':
+ resolution: {integrity: sha512-qAp4ooTYrBQ5pk5jgg54/U1rCJ/9FLYOkkQ/nTE+bVMseMfB6O7J8zb19YTpWuu4UdfRf5zzOrNKfl6T64MNrQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.6':
+ resolution: {integrity: sha512-nqpDWk0Xr8ELO/nfRUDjk1pc9wDJ3ObeDdNMHLaymc4PJBWj11gdPCWZFKSK2AVKjJQC7J2EfmSmf47GN7OuLg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.6':
+ resolution: {integrity: sha512-5k9xF33xkfKpo9wCvYcegQ21VwIBU1/qEbYlVukfEIyQbEA47uK8AAwS7NVjNE3vHzcmxMYwd0l6L4pPjjm1rQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.6':
+ resolution: {integrity: sha512-0bpEBQiGx+227fW4G0fLQ8vuvyy5rsB1YIYNapTq3aRsJ9taF3f5cCaovDjN5pUGKKzcpMrZst/mhNaKAPOHOA==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/vite@4.1.6':
+ resolution: {integrity: sha512-zjtqjDeY1w3g2beYQtrMAf51n5G7o+UwmyOjtsDMP7t6XyoRMOidcoKP32ps7AkNOHIXEOK0bhIC05dj8oJp4w==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6
+
+ '@types/estree@1.0.7':
+ resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
+
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
+ detect-libc@2.0.4:
+ resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
+ engines: {node: '>=8'}
+
+ enhanced-resolve@5.18.1:
+ resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
+ engines: {node: '>=10.13.0'}
+
+ esbuild@0.25.4:
+ resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fdir@6.4.4:
+ resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ jiti@2.4.2:
+ resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
+ hasBin: true
+
+ lightningcss-darwin-arm64@1.29.2:
+ resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.29.2:
+ resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.29.2:
+ resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.29.2:
+ resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.29.2:
+ resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.29.2:
+ resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.29.2:
+ resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.29.2:
+ resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.29.2:
+ resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.29.2:
+ resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.29.2:
+ resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==}
+ engines: {node: '>= 12.0.0'}
+
+ magic-string@0.30.17:
+ resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+
+ minipass@7.1.2:
+ resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minizlib@3.0.2:
+ resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
+ engines: {node: '>= 18'}
+
+ mkdirp@3.0.1:
+ resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.2:
+ resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.3:
+ resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rollup@4.40.2:
+ resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tailwindcss@4.1.6:
+ resolution: {integrity: sha512-j0cGLTreM6u4OWzBeLBpycK0WIh8w7kSwcUsQZoGLHZ7xDTdM69lN64AgoIEEwFi0tnhs4wSykUa5YWxAzgFYg==}
+
+ tapable@2.2.1:
+ resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
+ engines: {node: '>=6'}
+
+ tar@7.4.3:
+ resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.13:
+ resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
+ engines: {node: '>=12.0.0'}
+
+ vite@6.3.5:
+ resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
+snapshots:
+
+ '@ampproject/remapping@2.3.0':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.8
+ '@jridgewell/trace-mapping': 0.3.25
+
+ '@esbuild/aix-ppc64@0.25.4':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/android-arm@0.25.4':
+ optional: true
+
+ '@esbuild/android-x64@0.25.4':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.4':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.4':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.4':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.4':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.4':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.4':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.4':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.4':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.4':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.4':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.4':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.4':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.4':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.4':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.4':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.4':
+ optional: true
+
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.2
+
+ '@jridgewell/gen-mapping@0.3.8':
+ dependencies:
+ '@jridgewell/set-array': 1.2.1
+ '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping': 0.3.25
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/set-array@1.2.1': {}
+
+ '@jridgewell/sourcemap-codec@1.5.0': {}
+
+ '@jridgewell/trace-mapping@0.3.25':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.0
+
+ '@rollup/rollup-android-arm-eabi@4.40.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.40.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.40.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.40.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.40.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.40.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.40.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.40.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.40.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.40.2':
+ optional: true
+
+ '@tailwindcss/node@4.1.6':
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ enhanced-resolve: 5.18.1
+ jiti: 2.4.2
+ lightningcss: 1.29.2
+ magic-string: 0.30.17
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.6
+
+ '@tailwindcss/oxide-android-arm64@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.6':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.6':
+ dependencies:
+ detect-libc: 2.0.4
+ tar: 7.4.3
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.6
+ '@tailwindcss/oxide-darwin-arm64': 4.1.6
+ '@tailwindcss/oxide-darwin-x64': 4.1.6
+ '@tailwindcss/oxide-freebsd-x64': 4.1.6
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.6
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.6
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.6
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.6
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.6
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.6
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.6
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.6
+
+ '@tailwindcss/vite@4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))':
+ dependencies:
+ '@tailwindcss/node': 4.1.6
+ '@tailwindcss/oxide': 4.1.6
+ tailwindcss: 4.1.6
+ vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
+
+ '@types/estree@1.0.7': {}
+
+ chownr@3.0.0: {}
+
+ detect-libc@2.0.4: {}
+
+ enhanced-resolve@5.18.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.2.1
+
+ esbuild@0.25.4:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.4
+ '@esbuild/android-arm': 0.25.4
+ '@esbuild/android-arm64': 0.25.4
+ '@esbuild/android-x64': 0.25.4
+ '@esbuild/darwin-arm64': 0.25.4
+ '@esbuild/darwin-x64': 0.25.4
+ '@esbuild/freebsd-arm64': 0.25.4
+ '@esbuild/freebsd-x64': 0.25.4
+ '@esbuild/linux-arm': 0.25.4
+ '@esbuild/linux-arm64': 0.25.4
+ '@esbuild/linux-ia32': 0.25.4
+ '@esbuild/linux-loong64': 0.25.4
+ '@esbuild/linux-mips64el': 0.25.4
+ '@esbuild/linux-ppc64': 0.25.4
+ '@esbuild/linux-riscv64': 0.25.4
+ '@esbuild/linux-s390x': 0.25.4
+ '@esbuild/linux-x64': 0.25.4
+ '@esbuild/netbsd-arm64': 0.25.4
+ '@esbuild/netbsd-x64': 0.25.4
+ '@esbuild/openbsd-arm64': 0.25.4
+ '@esbuild/openbsd-x64': 0.25.4
+ '@esbuild/sunos-x64': 0.25.4
+ '@esbuild/win32-arm64': 0.25.4
+ '@esbuild/win32-ia32': 0.25.4
+ '@esbuild/win32-x64': 0.25.4
+
+ fdir@6.4.4(picomatch@4.0.2):
+ optionalDependencies:
+ picomatch: 4.0.2
+
+ fsevents@2.3.3:
+ optional: true
+
+ graceful-fs@4.2.11: {}
+
+ jiti@2.4.2: {}
+
+ lightningcss-darwin-arm64@1.29.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.29.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.29.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.29.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.29.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.29.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.29.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.29.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.29.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.29.2:
+ optional: true
+
+ lightningcss@1.29.2:
+ dependencies:
+ detect-libc: 2.0.4
+ optionalDependencies:
+ lightningcss-darwin-arm64: 1.29.2
+ lightningcss-darwin-x64: 1.29.2
+ lightningcss-freebsd-x64: 1.29.2
+ lightningcss-linux-arm-gnueabihf: 1.29.2
+ lightningcss-linux-arm64-gnu: 1.29.2
+ lightningcss-linux-arm64-musl: 1.29.2
+ lightningcss-linux-x64-gnu: 1.29.2
+ lightningcss-linux-x64-musl: 1.29.2
+ lightningcss-win32-arm64-msvc: 1.29.2
+ lightningcss-win32-x64-msvc: 1.29.2
+
+ magic-string@0.30.17:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.0
+
+ minipass@7.1.2: {}
+
+ minizlib@3.0.2:
+ dependencies:
+ minipass: 7.1.2
+
+ mkdirp@3.0.1: {}
+
+ nanoid@3.3.11: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.2: {}
+
+ postcss@8.5.3:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rollup@4.40.2:
+ dependencies:
+ '@types/estree': 1.0.7
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.40.2
+ '@rollup/rollup-android-arm64': 4.40.2
+ '@rollup/rollup-darwin-arm64': 4.40.2
+ '@rollup/rollup-darwin-x64': 4.40.2
+ '@rollup/rollup-freebsd-arm64': 4.40.2
+ '@rollup/rollup-freebsd-x64': 4.40.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.40.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.40.2
+ '@rollup/rollup-linux-arm64-gnu': 4.40.2
+ '@rollup/rollup-linux-arm64-musl': 4.40.2
+ '@rollup/rollup-linux-loongarch64-gnu': 4.40.2
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.40.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.40.2
+ '@rollup/rollup-linux-riscv64-musl': 4.40.2
+ '@rollup/rollup-linux-s390x-gnu': 4.40.2
+ '@rollup/rollup-linux-x64-gnu': 4.40.2
+ '@rollup/rollup-linux-x64-musl': 4.40.2
+ '@rollup/rollup-win32-arm64-msvc': 4.40.2
+ '@rollup/rollup-win32-ia32-msvc': 4.40.2
+ '@rollup/rollup-win32-x64-msvc': 4.40.2
+ fsevents: 2.3.3
+
+ source-map-js@1.2.1: {}
+
+ tailwindcss@4.1.6: {}
+
+ tapable@2.2.1: {}
+
+ tar@7.4.3:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.2
+ minizlib: 3.0.2
+ mkdirp: 3.0.1
+ yallist: 5.0.0
+
+ tinyglobby@0.2.13:
+ dependencies:
+ fdir: 6.4.4(picomatch@4.0.2)
+ picomatch: 4.0.2
+
+ vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2):
+ dependencies:
+ esbuild: 0.25.4
+ fdir: 6.4.4(picomatch@4.0.2)
+ picomatch: 4.0.2
+ postcss: 8.5.3
+ rollup: 4.40.2
+ tinyglobby: 0.2.13
+ optionalDependencies:
+ fsevents: 2.3.3
+ jiti: 2.4.2
+ lightningcss: 1.29.2
+
+ yallist@5.0.0: {}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/postcss.config.cjs" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/postcss.config.cjs"
new file mode 100644
index 00000000..f7f7a3be
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/postcss.config.cjs"
@@ -0,0 +1,6 @@
+// 수정 ㄴㄴ
+module.exports = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/public/google-icon.svg" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/public/google-icon.svg"
new file mode 100644
index 00000000..c911c1a9
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/public/google-icon.svg"
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/App.css" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/App.css"
new file mode 100644
index 00000000..e69de29b
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/App.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/App.tsx"
new file mode 100644
index 00000000..382b9578
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/App.tsx"
@@ -0,0 +1,53 @@
+import { createBrowserRouter, RouterProvider } from "react-router-dom";
+import LoginPage from "./pages/LoginPage";
+import HomeLayout from "./layouts/HomeLayout";
+import HomePage from "./pages/HomePage";
+import SignupPage from "./pages/SignupPage";
+import NotFoundPage from "./pages/NotFoundPage";
+import MyPage from "./pages/MyPage";
+import { AuthProvider } from "./context/AuthContext";
+import { RouteObject } from "react-router-dom";
+import ProtectedLayout from "./layouts/ProtectedLayout";
+import GoogleLoginRedirectPage from "./pages/GoogleLoginRedirectPage";
+
+//바로 접근 가능
+const publicRoutes: RouteObject[] = [
+ {
+ path: "/",
+ element: ,
+ errorElement: ,
+ children: [
+ { index: true, element: },
+ { path: "login", element: },
+ { path: "signup", element: },
+ { path: "v1/auth/google/callback", element: },
+ ],
+ },
+];
+
+//인증하기기
+const protectedRoutes: RouteObject[] = [
+ {
+ path: "/my-page",
+ element: ,
+ errorElement: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ ],
+ },
+];
+
+const router = createBrowserRouter([...publicRoutes, ...protectedRoutes]);
+
+function App() {
+ return (
+
+
+
+ );
+}
+
+export default App;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/apis/auth.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/apis/auth.ts"
new file mode 100644
index 00000000..54dad6ca
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/apis/auth.ts"
@@ -0,0 +1,70 @@
+import { axiosInstance } from "./axios";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+import { ResponseMyInfoDto } from "../types/auth";
+import { AxiosError } from "axios";
+
+// 회원가입
+export async function postSignup(data: {
+ email: string;
+ password: string;
+ name: string;
+}) {
+ try {
+ const response = await axiosInstance.post("/v1/auth/signup", data); // ← 슬래시 추가
+ console.log(" signup 응답 확인:", response.data); // ← 확인용 로그 추가
+ return response.data;
+ } catch (error) {
+ console.error("Signup Error:", error);
+ if (error instanceof AxiosError) {
+ console.error("응답 내용:", error.response?.data);
+ }
+ throw error;
+ }
+}
+
+// 로그인
+export async function postSignin(data: { email: string; password: string }) {
+ try {
+ const response = await axiosInstance.post("/v1/auth/signin", data);
+ console.log(" signin 응답 확인:", response.data); // ← 확인용 로그 추가
+ return response.data;
+ } catch (error) {
+ console.error("Signin Error:", error);
+ if (error instanceof AxiosError) {
+ console.error("응답 내용:", error.response?.data);
+ }
+ throw error;
+ }
+}
+
+// 내 정보 가져오기
+export async function getMyInfo(): Promise {
+ const token = localStorage.getItem(LOCAL_STORAGE_KEY.accessToken);
+ try {
+ const response = await axiosInstance.get("/v1/users/me", {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ return response.data;
+ } catch (error) {
+ console.error("getMyInfo Error:", error);
+ throw error;
+ }
+}
+
+// 로그아웃
+export const postLogout = async () => {
+ const token = localStorage.getItem(LOCAL_STORAGE_KEY.accessToken);
+ try {
+ const response = await axiosInstance.post("/v1/auth/signout", null, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ return response.data;
+ } catch (error) {
+ console.error("Logout Error:", error);
+ throw error;
+ }
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/apis/axios.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/apis/axios.ts"
new file mode 100644
index 00000000..ce675f38
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/apis/axios.ts"
@@ -0,0 +1,108 @@
+import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
+import { LOCAL_STORAGE_KEY } from "../constants/key.ts";
+import { useLocalStorage } from "../hooks/useLocalStorage";
+
+interface CustomAxiosRequestConfig extends AxiosRequestConfig {
+ _retry?: boolean;
+}
+
+let refreshPromise: Promise | null = null;
+
+export const axiosInstance = axios.create({
+ baseURL: import.meta.env.VITE_API_URL,
+});
+
+// 요청 인터셉터
+axiosInstance.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ const { getItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
+ const accessToken = getItem();
+
+ // accessToken이 존재하면 Authorization 헤더에 Bearer 토큰 형식으로 추가한다.
+ if (accessToken) {
+ config.headers = config.headers || {};
+ config.headers["Authorization"] = `Bearer ${accessToken}`;
+ }
+
+ return config;
+ },
+ (error) => Promise.reject(error)
+);
+
+// 응답 인터셉터
+axiosInstance.interceptors.response.use(
+ (response) => response, //정상 응답 그대로 반환환
+ async (error) => {
+ const originalRequest = error.config as CustomAxiosRequestConfig;
+
+ if (
+ error.response &&
+ error.response.status === 401 &&
+ !originalRequest._retry
+ ) {
+ if (originalRequest.url === "/v1/auth/refresh") {
+ const { removeItem: removeAccessToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.accessToken
+ );
+ const { removeItem: removeRefreshToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.refreshToken
+ );
+
+ removeAccessToken();
+ removeRefreshToken();
+ window.location.href = "/login";
+ return Promise.reject(error);
+ }
+
+ originalRequest._retry = true;
+
+ if (!refreshPromise) {
+ refreshPromise = (async () => {
+ const { getItem: getRefreshToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.refreshToken
+ );
+ const refreshToken = getRefreshToken();
+
+ const { data } = await axiosInstance.post("/v1/auth/refresh", {
+ refresh: refreshToken,
+ });
+
+ const { setItem: setAccessToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.accessToken
+ );
+ const { setItem: setRefreshToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.refreshToken
+ );
+
+ setAccessToken(data.data.accessToken);
+ setRefreshToken(data.data.refreshToken);
+
+ return data.data.accessToken;
+ })()
+ .catch(() => {
+ const { removeItem: removeAccessToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.accessToken
+ );
+ const { removeItem: removeRefreshToken } = useLocalStorage(
+ LOCAL_STORAGE_KEY.refreshToken
+ );
+
+ removeAccessToken();
+ removeRefreshToken();
+ })
+ .finally(() => {
+ refreshPromise = null;
+ });
+ }
+
+ return refreshPromise.then((newAccessToken) => {
+ if (originalRequest.headers) {
+ originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
+ }
+ return axiosInstance(originalRequest);
+ });
+ }
+
+ return Promise.reject(error);
+ }
+);
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/assets/react.svg" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/assets/react.svg"
new file mode 100644
index 00000000..e69de29b
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/components/Navbar.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/components/Navbar.tsx"
new file mode 100644
index 00000000..b3160ef9
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/components/Navbar.tsx"
@@ -0,0 +1,57 @@
+import { Link } from "react-router-dom";
+import { useAuth } from "../context/AuthContext";
+
+const Navbar = () => {
+ const { accessToken, logout } = useAuth();
+
+ return (
+
+ );
+};
+
+export default Navbar;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/constants/key.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/constants/key.ts"
new file mode 100644
index 00000000..4b6c9308
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/constants/key.ts"
@@ -0,0 +1,5 @@
+export const LOCAL_STORAGE_KEY: { accessToken: string; refreshToken: string } =
+ {
+ accessToken: "accessToken",
+ refreshToken: "refreshToken",
+ };
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/context/AuthContext.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/context/AuthContext.tsx"
new file mode 100644
index 00000000..9be3bb22
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/context/AuthContext.tsx"
@@ -0,0 +1,113 @@
+import { useLocalStorage } from "../hooks/useLocalStorage";
+import { RequestSigninDto } from "../types/auth";
+import {
+ createContext,
+ PropsWithChildren,
+ useState,
+ useContext,
+ useEffect,
+} from "react";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+import { postSignin, postLogout } from "../apis/auth";
+
+interface AuthContextType {
+ accessToken: string | null;
+ refreshToken: string | null;
+ login: (signinData: RequestSigninDto) => Promise;
+ logout: () => Promise;
+}
+
+export const AuthContext: React.Context =
+ createContext({
+ accessToken: null,
+ refreshToken: null,
+ login: async () => {},
+ logout: async () => {},
+ });
+
+export const AuthProvider = ({ children }: PropsWithChildren) => {
+ const {
+ getItem: getAccessTokenFromStorage,
+ setItem: setAccessTokenInStorage,
+ removeItem: removeAccessTokenFromStorage,
+ } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
+
+ const {
+ getItem: getRefreshTokenFromStorage,
+ setItem: setRefreshTokenInStorage,
+ removeItem: removeRefreshTokenFromStorage,
+ } = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
+
+ const [accessToken, setAccessToken] = useState(null);
+ const [refreshToken, setRefreshToken] = useState(null);
+
+ useEffect(() => {
+ const storedAccessToken = getAccessTokenFromStorage();
+ const storedRefreshToken = getRefreshTokenFromStorage();
+
+ if (storedAccessToken && storedRefreshToken) {
+ setAccessToken(storedAccessToken);
+ setRefreshToken(storedRefreshToken);
+ } else {
+ setAccessToken(null);
+ setRefreshToken(null);
+ }
+ }, []);
+
+ const login = async (signinData: RequestSigninDto) => {
+ try {
+ console.log("로그인 시도:", signinData);
+ const response = await postSignin(signinData);
+ console.log("로그인 응답:", response);
+
+ if (response && response.data) {
+ const { accessToken, refreshToken } = response.data;
+ console.log("토큰 정보:", { accessToken, refreshToken });
+
+ setAccessToken(accessToken);
+ setRefreshToken(refreshToken);
+ setAccessTokenInStorage(accessToken);
+ setRefreshTokenInStorage(refreshToken);
+
+ console.log("토큰 저장 완료");
+ alert("로그인 성공");
+ window.location.href = "/my-page";
+ } else {
+ console.error("로그인 응답이 올바르지 않습니다:", response);
+ alert("로그인 실패: 응답이 올바르지 않습니다");
+ }
+ } catch (error) {
+ console.error("로그인 오류:", error);
+ alert("로그인 실패");
+ }
+ };
+
+ const logout = async () => {
+ try {
+ const token = getAccessTokenFromStorage();
+ if (token) {
+ await postLogout();
+ }
+ removeAccessTokenFromStorage();
+ removeRefreshTokenFromStorage();
+ setAccessToken(null);
+ setRefreshToken(null);
+ alert("로그아웃 성공");
+ window.location.href = "/";
+ } catch (error) {
+ console.error("로그아웃 오류", error);
+ alert("로그아웃 실패");
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ return context;
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/hooks/useForms.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/hooks/useForms.ts"
new file mode 100644
index 00000000..6ddab44b
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/hooks/useForms.ts"
@@ -0,0 +1,53 @@
+import { useState, useEffect } from "react";
+
+interface UseFormProps {
+ initialValues: T;
+ validate: (values: T) => Partial;
+ onSubmit: (values: T) => void;
+}
+
+function useForm({ initialValues, validate, onSubmit }: UseFormProps) {
+ const [values, setValues] = useState(initialValues);
+ const [touched, setTouched] = useState>(
+ {} as Record
+ );
+ const [errors, setErrors] = useState>({});
+
+ const handleChange = (name: keyof T, text: string) => {
+ setValues({
+ ...values,
+ [name]: text,
+ });
+ };
+
+ const handleBlur = (name: keyof T) => {
+ setTouched({
+ ...touched,
+ [name]: true,
+ });
+ };
+
+ const getInputProps = (name: keyof T) => {
+ const value = values[name];
+ const onChange = (
+ e: React.ChangeEvent
+ ) => handleChange(name, e.target.value);
+ const onBlur = () => handleBlur(name);
+
+ return { value, onChange, onBlur };
+ };
+
+ useEffect(() => {
+ const newErrors = validate(values);
+ setErrors(newErrors);
+ }, [validate, values]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSubmit(values);
+ };
+
+ return { errors, touched, getInputProps, handleSubmit, values };
+}
+
+export default useForm;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/hooks/useLocalStorage.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/hooks/useLocalStorage.ts"
new file mode 100644
index 00000000..2bede60f
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/hooks/useLocalStorage.ts"
@@ -0,0 +1,27 @@
+export const useLocalStorage = (key: string) => {
+ const setItem = (value: unknown) => {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const getItem = () => {
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : null;
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const removeItem = () => {
+ try {
+ window.localStorage.removeItem(key);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+ return { setItem, getItem, removeItem };
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/index.css" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/index.css"
new file mode 100644
index 00000000..f1d8c73c
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/index.css"
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/layouts/HomeLayout.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/layouts/HomeLayout.tsx"
new file mode 100644
index 00000000..a8297440
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/layouts/HomeLayout.tsx"
@@ -0,0 +1,15 @@
+import { Outlet } from "react-router-dom";
+import Navbar from "../components/Navbar";
+
+const HomeLayout = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default HomeLayout;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/layouts/ProtectedLayout.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/layouts/ProtectedLayout.tsx"
new file mode 100644
index 00000000..40088fca
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/layouts/ProtectedLayout.tsx"
@@ -0,0 +1,13 @@
+import { useAuth } from "../context/AuthContext";
+import { Navigate, Outlet } from "react-router-dom";
+
+const ProtectedLayout = () => {
+ const { accessToken } = useAuth();
+
+ if (!accessToken) {
+ return ;
+ }
+ return ;
+};
+
+export default ProtectedLayout;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/main.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/main.tsx"
new file mode 100644
index 00000000..0ebf5a7c
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/main.tsx"
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.tsx";
+import "./index.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/GoogleLoginRedirectPage.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/GoogleLoginRedirectPage.tsx"
new file mode 100644
index 00000000..330a7f6c
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/GoogleLoginRedirectPage.tsx"
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { LOCAL_STORAGE_KEY } from "../constants/key";
+
+function GoogleLoginRedirectPage() {
+ useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+
+ const accessToken = urlParams.get(LOCAL_STORAGE_KEY.accessToken);
+ const refreshToken = urlParams.get(LOCAL_STORAGE_KEY.refreshToken);
+
+ if (accessToken) {
+ // 로컬 스토리지에 저장
+ localStorage.setItem(LOCAL_STORAGE_KEY.accessToken, accessToken);
+ }
+
+ if (refreshToken) {
+ localStorage.setItem(LOCAL_STORAGE_KEY.refreshToken, refreshToken);
+ }
+
+ window.location.href = "/my-page";
+ }, []);
+
+ return 구글 로그인 리다이렉트 화면
;
+}
+
+export default GoogleLoginRedirectPage;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/HomePage.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/HomePage.tsx"
new file mode 100644
index 00000000..d5a244d2
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/HomePage.tsx"
@@ -0,0 +1,10 @@
+function HomePage() {
+ return (
+
+ );
+}
+
+export default HomePage;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/LoginPage.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/LoginPage.tsx"
new file mode 100644
index 00000000..ea7f8c74
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/LoginPage.tsx"
@@ -0,0 +1,107 @@
+import useForm from "../hooks/useForms";
+import { validateSignin, UserSigninInformation } from "../utils/validate";
+import { AxiosError } from "axios";
+import { useAuth } from "../context/AuthContext";
+import { Link } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
+import { useEffect } from "react";
+
+function LoginPage() {
+ const { login, accessToken } = useAuth();
+ const navigate = useNavigate();
+
+ // useEffect(() => {
+ // if (accessToken) {
+ // navigate("/my-page");
+ // }
+ // }, [accessToken, navigate]);
+
+ const { errors, touched, getInputProps, handleSubmit } =
+ useForm({
+ initialValues: {
+ email: "",
+ password: "",
+ },
+ validate: validateSignin,
+ onSubmit: async (values) => {
+ console.log("로그인 성공:", values);
+ try {
+ await login(values);
+ } catch (error) {
+ if (error instanceof AxiosError) {
+ const errorMessage =
+ error.response?.data?.message || "로그인에 실패했습니다.";
+ alert(errorMessage);
+ } else {
+ alert("알 수 없는 오류가 발생했습니다.");
+ }
+ }
+ },
+ });
+
+ const handleGoogleLogin = () => {
+ window.location.href =
+ import.meta.env.VITE_GOOGLE_LOGIN_URL + "v1/auth/google/login";
+ };
+
+ return (
+
+
+
+
+ <
+
+
로그인
+
+
+
+
+
+
+ );
+}
+
+export default LoginPage;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/MyPage.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/MyPage.tsx"
new file mode 100644
index 00000000..096b03d7
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/MyPage.tsx"
@@ -0,0 +1,98 @@
+import { useEffect, useState } from "react";
+import { getMyInfo } from "../apis/auth";
+import { ResponseMyInfoDto } from "../types/auth";
+import { useNavigate } from "react-router-dom";
+import { useAuth } from "../context/AuthContext";
+
+const MyPage = () => {
+ const navigate = useNavigate();
+ const { logout } = useAuth();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const getData = async () => {
+ try {
+ setLoading(true);
+ const response = await getMyInfo();
+ console.log("MyInfo Response:", response);
+ setData(response);
+ setError(null);
+ } catch (error) {
+ console.error("Failed to fetch user info:", error);
+ setError("사용자 정보를 가져오는데 실패했습니다.");
+ } finally {
+ setLoading(false);
+ }
+ };
+ getData();
+ }, []);
+
+ const handleLogout = async () => {
+ await logout();
+ navigate("/");
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+
+ );
+ }
+
+ if (!data || !data.data || !data.data.name) {
+ return (
+
+
사용자 정보가 없습니다.
+
+
+ );
+ }
+
+ return (
+
+
+
+ {data.data.name}님 환영합니다.
+
+ {data.data.avatar && (
+

+ )}
+
{data.data.email}
+
+
+
+ );
+};
+
+export default MyPage;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/NotFoundPage.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/NotFoundPage.tsx"
new file mode 100644
index 00000000..91f72221
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/NotFoundPage.tsx"
@@ -0,0 +1,10 @@
+function NotFoundPage() {
+ return (
+
+
404
+
페이지를 찾을 수 없습니다.
+
+ );
+}
+
+export default NotFoundPage;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/SignupPage.tsx" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/SignupPage.tsx"
new file mode 100644
index 00000000..810f2fb1
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/pages/SignupPage.tsx"
@@ -0,0 +1,166 @@
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Link } from "react-router-dom";
+import { postSignup } from "../apis/auth"; //여기에 내 api
+import { AxiosError } from "axios";
+import { SignupForm, signupSchema } from "../utils/schema";
+
+//
+function SignupPage() {
+ const [showPassword, setShowPassword] = useState(false);
+ const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
+ const [isComplete, setIsComplete] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(signupSchema),
+ mode: "onChange",
+ });
+
+ const onSubmit = async (data: SignupForm) => {
+ console.log("회원가입 성공:", data);
+ try {
+ const response = await postSignup({
+ email: data.email,
+ password: data.password,
+ name: data.nickname,
+ });
+
+ if (response) {
+ setIsComplete(true);
+ } else alert("회원가입에 실패했습니다.");
+ } catch (error) {
+ if (error instanceof AxiosError) {
+ alert(error.response?.data?.message || "회원가입에 실패했습니다.");
+ } else alert("알 수 없는 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+
+
+
+
+ <
+
+
회원가입
+
+
+ {!isComplete ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+
+ ✓
+
+
회원가입 완료!
+
+ 로그인하기
+
+
+ )}
+
+
+ );
+}
+
+export default SignupPage;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/types/auth.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/types/auth.ts"
new file mode 100644
index 00000000..f233a2e7
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/types/auth.ts"
@@ -0,0 +1,41 @@
+import { CommonResponse } from "./common";
+
+export type RequestSignupDto = {
+ name: string;
+ email: string;
+ bio?: string;
+ avatar?: string;
+ password: string;
+};
+
+export type ResponseSignupDto = CommonResponse<{
+ id: number;
+ name: string;
+ email: string;
+ bio: string | null;
+ avatar: string | null;
+ createAt: string;
+ updateAt: string;
+}>;
+
+export type RequestSigninDto = {
+ email: string;
+ password: string;
+};
+
+export type ResponseSigninDto = CommonResponse<{
+ id: number;
+ name: string;
+ accessToken: string;
+ refreshToken: string;
+}>;
+
+export type ResponseMyInfoDto = CommonResponse<{
+ id: number;
+ name: string;
+ email: string;
+ bio: string | null;
+ avatar: string | null;
+ createAt: string;
+ updateAt: string;
+}>;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/types/common.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/types/common.ts"
new file mode 100644
index 00000000..0a1b1c2d
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/types/common.ts"
@@ -0,0 +1,6 @@
+export type CommonResponse = {
+ status: boolean;
+ statusCode: number;
+ message: string;
+ data: T;
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/utils/schema.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/utils/schema.ts"
new file mode 100644
index 00000000..0fd3dfa0
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/utils/schema.ts"
@@ -0,0 +1,21 @@
+import { z } from "zod";
+
+export const signupSchema = z
+ .object({
+ email: z.string().email("이메일 형식이 올바르지 않습니다."),
+ password: z
+ .string()
+ .min(8, "비밀번호는 8자 이상이어야 합니다.")
+ .max(20, "비밀번호는 20자 이하여야 합니다."),
+ passwordConfirm: z.string(),
+ nickname: z
+ .string()
+ .min(2, "닉네임은 2자 이상이어야 합니다.")
+ .max(10, "닉네임은 10자 이하여야 합니다."),
+ })
+ .refine((data) => data.password === data.passwordConfirm, {
+ message: "비밀번호가 일치하지 않습니다.",
+ path: ["passwordConfirm"],
+ });
+
+export type SignupForm = z.infer;
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/utils/validate.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/utils/validate.ts"
new file mode 100644
index 00000000..bfee1409
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/utils/validate.ts"
@@ -0,0 +1,65 @@
+export type UserSigninInformation = {
+ email: string;
+ password: string;
+};
+
+export type SignupForm = {
+ email: string;
+ password: string;
+ passwordConfirm: string;
+ nickname: string;
+};
+
+export function validateSignin(
+ values: UserSigninInformation
+): Partial {
+ const errors: Partial = {};
+
+ if (!values.email) {
+ errors.email = "이메일을 입력해주세요.";
+ } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
+ errors.email = "올바른 이메일 형식이 아닙니다.";
+ }
+
+ if (!values.password) {
+ errors.password = "비밀번호를 입력해주세요";
+ } else if (values.password.length < 8 || values.password.length > 20) {
+ errors.password = "비밀번호는 8자 이상 20자 이하로 입력해주세요";
+ }
+
+ return errors;
+}
+
+export function validateSignupPassword(
+ values: SignupForm
+): Partial {
+ const errors: Partial = {};
+
+ if (!values.password) {
+ errors.password = "비밀번호를 입력해주세요";
+ } else if (values.password.length < 8 || values.password.length > 20) {
+ errors.password = "비밀번호는 8자 이상 20자 이하로 입력해주세요";
+ }
+
+ if (!values.passwordConfirm) {
+ errors.passwordConfirm = "비밀번호를 다시 입력해주세요";
+ } else if (values.password !== values.passwordConfirm) {
+ errors.passwordConfirm = "비밀번호가 일치하지 않습니다";
+ }
+
+ return errors;
+}
+
+export function validateSignupNickname(
+ values: Pick
+): Partial> {
+ const errors: Partial> = {};
+
+ if (!values.nickname) {
+ errors.nickname = "닉네임을 입력해주세요";
+ } else if (values.nickname.length < 2 || values.nickname.length > 10) {
+ errors.nickname = "닉네임은 2자 이상 10자 이하로 입력해주세요";
+ }
+
+ return errors;
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/vite-env.d.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/vite-env.d.ts"
new file mode 100644
index 00000000..1a593c36
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/src/vite-env.d.ts"
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL: string; // 로긘 요청할 백엔드 API 주소
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tailwind.config.js" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tailwind.config.js"
new file mode 100644
index 00000000..614c86b4
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tailwind.config.js"
@@ -0,0 +1,8 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.app.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.app.json"
new file mode 100644
index 00000000..5645a1f8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.app.json"
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "node",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "allowSyntheticDefaultImports": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Types */
+ "types": ["vite/client", "react/jsx-runtime"]
+ },
+ "include": ["src"]
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.json"
new file mode 100644
index 00000000..f2574791
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.json"
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "strict": true,
+ "types": ["vite/client"]
+ }
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.node.json" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.node.json"
new file mode 100644
index 00000000..db0becc8
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/tsconfig.node.json"
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/vite.config.ts" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/vite.config.ts"
new file mode 100644
index 00000000..1d8fa731
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/4\354\243\274\354\260\250_\353\257\270\354\205\2302/vite.config.ts"
@@ -0,0 +1,7 @@
+// vite.config.ts
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+});
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/.gitignore" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/.gitignore"
new file mode 100644
index 00000000..1cac5597
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/.gitignore"
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.env
\ No newline at end of file
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/README.md" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/README.md"
new file mode 100644
index 00000000..40ede56e
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/README.md"
@@ -0,0 +1,54 @@
+# 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/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## 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 tseslint.config({
+ extends: [
+ // 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,
+ ],
+ languageOptions: {
+ // other options...
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+})
+```
+
+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 tseslint.config({
+ plugins: {
+ // Add the react-x and react-dom plugins
+ 'react-x': reactX,
+ 'react-dom': reactDom,
+ },
+ rules: {
+ // other rules...
+ // Enable its recommended typescript rules
+ ...reactX.configs['recommended-typescript'].rules,
+ ...reactDom.configs.recommended.rules,
+ },
+})
+```
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/eslint.config.js" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/eslint.config.js"
new file mode 100644
index 00000000..092408a9
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/eslint.config.js"
@@ -0,0 +1,28 @@
+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'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+ },
+)
diff --git "a/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/index.html" "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/index.html"
new file mode 100644
index 00000000..478cafed
--- /dev/null
+++ "b/\353\252\250\354\271\264_\354\236\245\355\235\254\354\240\225/6\354\243\274\354\260\250_\353\257\270\354\205\230/mission1/index.html"
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+