+ {/* 1. ์ฌ์ด๋๋ฐ (๋ฐ์ํ) */}
+
+
+ {/* ์ฌ์ด๋๋ฐ ์ธ๋ถ ์์ญ ํด๋ฆญ ์ ๋ซ๊ธฐ (๋ชจ๋ฐ์ผ) */}
+ {isSidebarOpen &&
setIsSidebarOpen(false)} />}
+
+
+ {/* 2. ํค๋ */}
+
+
+ {/* 3. ๋ฉ์ธ ์ฝํ
์ธ */}
+
+ {children}
+
+
+ {/* 4. ํ๋กํ
๋ฒํผ (+) */}
+
+
+
+ );
+};
+
+export default Layout;
\ No newline at end of file
diff --git a/week6/client/src/components/LpCard.tsx b/week6/client/src/components/LpCard.tsx
new file mode 100644
index 00000000..d26c7654
--- /dev/null
+++ b/week6/client/src/components/LpCard.tsx
@@ -0,0 +1,36 @@
+import { useNavigate } from 'react-router-dom';
+
+const LpCard = ({ lp }: { lp: any }) => {
+ const navigate = useNavigate();
+
+ return (
+
navigate(`/lps/${lp.id}`)}
+ className="group relative cursor-pointer rounded-[24px] border border-white/10 bg-white/5 p-3 backdrop-blur-xl transition-all duration-500 hover:bg-white/10 hover:border-white/30"
+ >
+ {/* ์นด๋ ์ด๋ฏธ์ง - ์ ๋ฆฌ ์ ์ฌ๋ฌผ ๋๋ */}
+
+

+
+ {/* ๋ง์ฐ์ค ํธ๋ฒ ์ ์๋จ์์ ๋ด๋ ค์ค๋ ๋ฐ์ง์ด๋ ๋น ํจ๊ณผ */}
+
+
+
+ {/* ์ ๋ณด ํ
์คํธ */}
+
+
{lp.title}
+
+
+ {new Date(lp.createdAt).toLocaleDateString()}
+
+ ๐ค {lp.likes?.length || 0}
+
+
+
+ {/* ์ ๋ฆฌ ์ง๊ฐ์ ํต์ฌ: ๋ฏธ์ธํ ์ธ๊ณฝ์ ๋น */}
+
+
+ );
+};
+
+export default LpCard;
\ No newline at end of file
diff --git a/week6/client/src/components/ProtectedRoute.tsx b/week6/client/src/components/ProtectedRoute.tsx
new file mode 100644
index 00000000..c86b3e83
--- /dev/null
+++ b/week6/client/src/components/ProtectedRoute.tsx
@@ -0,0 +1,13 @@
+import { Navigate, Outlet } from 'react-router-dom';
+
+const ProtectedRoute = () => {
+
+ const isLogin = !!localStorage.getItem('accessToken');
+ if (!isLogin) {
+ alert('๋ก๊ทธ์ธ์ด ํ์ํ ํ์ด์ง์
๋๋ค!');
+ return
;
+ }
+ return
;
+};
+
+export default ProtectedRoute;
\ No newline at end of file
diff --git a/week4/client/src/hooks/useBallAnimation.ts b/week6/client/src/hooks/useBallAnimation.ts
similarity index 100%
rename from week4/client/src/hooks/useBallAnimation.ts
rename to week6/client/src/hooks/useBallAnimation.ts
diff --git a/week4/client/src/hooks/useForm.ts b/week6/client/src/hooks/useForm.ts
similarity index 100%
rename from week4/client/src/hooks/useForm.ts
rename to week6/client/src/hooks/useForm.ts
diff --git a/week6/client/src/index.css b/week6/client/src/index.css
new file mode 100644
index 00000000..bd6b0274
--- /dev/null
+++ b/week6/client/src/index.css
@@ -0,0 +1,43 @@
+@import "tailwindcss";
+
+
+@keyframes float {
+ 0% { transform: translateY(0px) rotate(0deg); }
+ 50% { transform: translateY(-20px) rotate(5deg); }
+ 100% { transform: translateY(0px) rotate(0deg); }
+}
+
+body {
+ margin: 0;
+ background: radial-gradient(circle at center, #101525 0%, #000000 100%);
+ min-height: 100vh;
+ overflow: hidden;
+ color: white;
+}
+
+.animate-float {
+ animation: float 6s ease-in-out infinite;
+}
+
+@layer base {
+ body {
+ @apply bg-black;
+ }
+}
+
+/* ์ ๋ฆฌ ์ปดํฌ๋ํธ ๊ดํ ํจ๊ณผ */
+.glass-panel {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
+ backdrop-filter: blur(20px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.8);
+}
+
+/* ์ปค์คํ
์คํฌ๋กค๋ฐ */
+.custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+}
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+}
\ No newline at end of file
diff --git a/week4/client/src/main.tsx b/week6/client/src/main.tsx
similarity index 100%
rename from week4/client/src/main.tsx
rename to week6/client/src/main.tsx
diff --git a/week6/client/src/pages/GoogleCallback.tsx b/week6/client/src/pages/GoogleCallback.tsx
new file mode 100644
index 00000000..643f786c
--- /dev/null
+++ b/week6/client/src/pages/GoogleCallback.tsx
@@ -0,0 +1,31 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+const GoogleCallback = () => {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+
+ const params = new URLSearchParams(window.location.search);
+ const accessToken = params.get('accessToken');
+ const refreshToken = params.get('refreshToken');
+
+ if (accessToken && refreshToken) {
+ localStorage.setItem('accessToken', accessToken);
+ localStorage.setItem('refreshToken', refreshToken);
+ alert("๊ตฌ๊ธ ๋ก๊ทธ์ธ์ ์ฑ๊ณตํ์ต๋๋ค! ");
+ navigate('/mypage');
+ } else {
+ alert("๋ก๊ทธ์ธ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค์ง ๋ชปํ์ต๋๋ค.");
+ navigate('/login');
+ }
+ }, [navigate]);
+
+ return (
+
+
๊ตฌ๊ธ ๋ก๊ทธ์ธ ์ฒ๋ฆฌ ์ค...
+
+ );
+};
+
+export default GoogleCallback;
\ No newline at end of file
diff --git a/week6/client/src/pages/LoginPage.tsx b/week6/client/src/pages/LoginPage.tsx
new file mode 100644
index 00000000..4c94348a
--- /dev/null
+++ b/week6/client/src/pages/LoginPage.tsx
@@ -0,0 +1,82 @@
+import { useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import axios from 'axios';
+import { type LoginFormValues } from '../utils/validate';
+import { useBallAnimation } from '../hooks/useBallAnimation';
+
+const LoginPage = () => {
+ const navigate = useNavigate();
+ const containerRef = useRef
(null);
+ const balls = useBallAnimation(containerRef);
+
+ const { register, handleSubmit, formState: { errors } } = useForm();
+
+
+ const onLoginSubmit = async (data: LoginFormValues) => {
+ try {
+ const response = await axios.post('http://localhost:8000/v1/auth/signin', data);
+ if (response.data.status) {
+ localStorage.setItem('accessToken', response.data.data.accessToken);
+ localStorage.setItem('refreshToken', response.data.data.refreshToken);
+
+
+ localStorage.setItem('nickname', response.data.data.name);
+
+ alert(`${response.data.data.name}๋ ํ์ํฉ๋๋ค!`);
+ navigate('/'); // ๋ฉ์ธ์ผ๋ก ์ด๋
+ }
+ } catch (error: any) {
+ alert(error.response?.data?.message || '๋ก๊ทธ์ธ ์คํจ');
+ }
+ };
+
+
+ const handleGoogleLogin = () => {
+ window.location.href = 'http://localhost:8000/v1/auth/google/login';
+ };
+
+ return (
+
+ {balls.map((ball) => (
+
+ ))}
+
+
+
+
DORI
+
๋ก๊ทธ์ธ
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default LoginPage;
\ No newline at end of file
diff --git a/week6/client/src/pages/LpDetailPage.tsx b/week6/client/src/pages/LpDetailPage.tsx
new file mode 100644
index 00000000..239811dd
--- /dev/null
+++ b/week6/client/src/pages/LpDetailPage.tsx
@@ -0,0 +1,98 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import { useState, useEffect } from 'react';
+import api from '../apis/axios';
+
+const LpDetailPage = () => {
+ const { lpid } = useParams();
+ const [order, setOrder] = useState<'asc' | 'desc'>('desc');
+ const { ref, inView } = useInView();
+
+ // 1. LP ์์ธ ์ ๋ณด ํจ์นญ
+ const { data: lp, isLoading: isLpLoading } = useQuery({
+ queryKey: ['lp', lpid],
+ queryFn: async () => {
+ const res = await api.get(`/lps/${lpid}`);
+ return res.data.data;
+ }
+ });
+
+ // 2. ๋๊ธ ๋ชฉ๋ก ๋ฌดํ ์คํฌ๋กค ํจ์นญ
+ const {
+ data: commentData,
+ fetchNextPage,
+ hasNextPage,
+ } = useInfiniteQuery({
+ queryKey: ['lpComments', lpid, order],
+ queryFn: async ({ pageParam = undefined }) => {
+ const res = await api.get(`/lps/${lpid}/comments`, {
+ params: {
+ cursor: pageParam,
+ limit: 10,
+ order
+ }
+ });
+ return res.data.data;
+ },
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => {
+ return lastPage.hasNext ? lastPage.nextCursor : undefined;
+ },
+});
+
+ useEffect(() => {
+ if (inView && hasNextPage) fetchNextPage();
+ }, [inView, hasNextPage]);
+
+ if (isLpLoading) return ์ ๋ฆฌ ํํธ ์กฐ๋ฆฝ ์ค...
;
+
+ return (
+
+ {/* LP ์์ธ ์นด๋ (๊ธฐ์กด ๋์์ธ ์ ์ง) */}
+
+
{lp?.title}
+
+

+
+
"{lp?.content}"
+
+
+ {/* ๋๊ธ ์น์
*/}
+
+
+
๋๊ธ
+
+
+
+
+
+
+ {/* ๋๊ธ ์์ฑ๋ */}
+
+
+
+
+
+ {/* ๋๊ธ ๋ชฉ๋ก */}
+
+ {commentData?.pages.map((page) =>
+ page.data.map((comment: any) => (
+
+
+ {comment.nickname}
+ {comment.createdAt}
+
+
{comment.content}
+
+ ))
+ )}
+ {/* ๋ฌดํ ์คํฌ๋กค ํธ๋ฆฌ๊ฑฐ */}
+
+
+
+
+ );
+};
+
+export default LpDetailPage;
\ No newline at end of file
diff --git a/week6/client/src/pages/LpListPage.tsx b/week6/client/src/pages/LpListPage.tsx
new file mode 100644
index 00000000..8284ec9d
--- /dev/null
+++ b/week6/client/src/pages/LpListPage.tsx
@@ -0,0 +1,80 @@
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import { useEffect, useState } from 'react';
+import api from '../apis/axios';
+import LpCard from '../components/LpCard';
+
+const LpListPage = () => {
+ const [sort, setSort] = useState<'asc' | 'desc'>('desc');
+ const { ref, inView } = useInView(); // ์คํฌ๋กค ๊ฐ์ง์ฉ ์ผ์
+
+ const {
+ data,
+ isLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage
+ } = useInfiniteQuery({
+ queryKey: ['lps', sort],
+ queryFn: async ({ pageParam = undefined }) => {
+ const res = await api.get('/lps', {
+ params: {
+ order: sort,
+ cursor: pageParam,
+ limit: 10
+ }
+ });
+ return res.data.data;
+ },
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => {
+ return lastPage.hasNext ? lastPage.nextCursor : undefined;
+ },
+});
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage]);
+
+ return (
+
+ {/* ์ ๋ ฌ ๋ฒํผ */}
+
+ {['desc', 'asc'].map((order) => (
+
+ ))}
+
+
+ {/* ์นด๋ ๊ทธ๋ฆฌ๋ */}
+
+ {/* 1. ์ด๊ธฐ ๋ก๋ฉ ์ค์ผ๋ ํค UI (์ฒดํฌ๋ฆฌ์คํธ ๋ฐ์) */}
+ {isLoading && [...Array(10)].map((_, i) => (
+
+ ))}
+
+ {/* 2. ์ค์ ๋ฐ์ดํฐ ๋ ๋๋ง */}
+ {data?.pages.map((page) =>
+ page.data.map((lp: any) =>
)
+ )}
+
+ {/* 3. ์ถ๊ฐ ๋ก๋ฉ ์ค์ผ ๋ ํ๋จ ์ค์ผ๋ ํค UI */}
+ {isFetchingNextPage && [...Array(5)].map((_, i) => (
+
+ ))}
+
+
+ {/* 4. ์คํฌ๋กค ํธ๋ฆฌ๊ฑฐ ์ง์ */}
+
+
+ );
+};
+
+export default LpListPage;
\ No newline at end of file
diff --git a/week6/client/src/pages/MyPage.tsx b/week6/client/src/pages/MyPage.tsx
new file mode 100644
index 00000000..a604b2b5
--- /dev/null
+++ b/week6/client/src/pages/MyPage.tsx
@@ -0,0 +1,111 @@
+import { useRef, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useBallAnimation } from '../hooks/useBallAnimation';
+import api from '../apis/axios';
+
+
+interface UserData {
+ id: number;
+ name: string;
+ email: string;
+}
+
+const MyPage = () => {
+ const navigate = useNavigate();
+ const containerRef = useRef(null);
+ const balls = useBallAnimation(containerRef);
+
+
+ const [user, setUser] = useState(null);
+
+ useEffect(() => {
+ const fetchUserData = async () => {
+ try {
+
+ const token = localStorage.getItem('accessToken');
+
+
+ const response = await api.get('/users/me', {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+
+ if (response.data.status) {
+ setUser(response.data.data);
+ }
+ } catch (error) {
+ console.error("๋ด ์ ๋ณด ๋ถ๋ฌ์ค๊ธฐ ์คํจ:", error);
+
+ }
+ };
+
+ fetchUserData();
+ }, [navigate]);
+
+ const handleLogout = () => {
+ localStorage.removeItem('accessToken');
+ alert('๋ก๊ทธ์์ ๋์์ต๋๋ค.');
+ navigate('/login', { replace: true });
+ };
+
+ return (
+
+
+ {balls.map((ball) => (
+
+ ))}
+
+
+
+
+
DORI
+
๋ง์ดํ์ด์ง
+
+
+
+
+
+
+ {user ? user.name : '...'}๋, ํ์ํฉ๋๋ค!
+
+
+ {user ? user.email : '๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค์
๋๋ค'}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MyPage;
\ No newline at end of file
diff --git a/week4/client/src/pages/SignupPage.tsx b/week6/client/src/pages/SignupPage.tsx
similarity index 100%
rename from week4/client/src/pages/SignupPage.tsx
rename to week6/client/src/pages/SignupPage.tsx
diff --git a/week6/client/src/pages/UploadPage.tsx b/week6/client/src/pages/UploadPage.tsx
new file mode 100644
index 00000000..fc658aec
--- /dev/null
+++ b/week6/client/src/pages/UploadPage.tsx
@@ -0,0 +1,4 @@
+const UploadPage = () => {
+ return ์ฌ๊ธฐ๋ ์
๋ก๋ ํ์ด์ง์
๋๋ค! (์ค๋น ์ค )
;
+};
+export default UploadPage;
\ No newline at end of file
diff --git a/week4/client/src/utils/validate.ts b/week6/client/src/utils/validate.ts
similarity index 100%
rename from week4/client/src/utils/validate.ts
rename to week6/client/src/utils/validate.ts
diff --git a/week6/client/src/vite-env.d.ts b/week6/client/src/vite-env.d.ts
new file mode 100644
index 00000000..41237e8b
--- /dev/null
+++ b/week6/client/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+
+///
+///
+
+declare module '*.svg?react' {
+ import React = require('react');
+ export const ReactComponent: React.FC>;
+ const src: React.FC>;
+ export default src;
+}
\ No newline at end of file
diff --git a/week4/client/tailwind.config.js b/week6/client/tailwind.config.js
similarity index 100%
rename from week4/client/tailwind.config.js
rename to week6/client/tailwind.config.js
diff --git a/week4/client/tsconfig.app.json b/week6/client/tsconfig.app.json
similarity index 100%
rename from week4/client/tsconfig.app.json
rename to week6/client/tsconfig.app.json
diff --git a/week4/client/tsconfig.json b/week6/client/tsconfig.json
similarity index 100%
rename from week4/client/tsconfig.json
rename to week6/client/tsconfig.json
diff --git a/week4/client/tsconfig.node.json b/week6/client/tsconfig.node.json
similarity index 100%
rename from week4/client/tsconfig.node.json
rename to week6/client/tsconfig.node.json
diff --git a/week6/client/vite.config.ts b/week6/client/vite.config.ts
new file mode 100644
index 00000000..bb2324da
--- /dev/null
+++ b/week6/client/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import svgr from 'vite-plugin-svgr';
+
+export default defineConfig({
+ plugins: [react(), svgr()],
+});
\ No newline at end of file
diff --git a/week6/package-lock.json b/week6/package-lock.json
new file mode 100644
index 00000000..6a0af5cc
--- /dev/null
+++ b/week6/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "week6",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}