+ {/* 1. μ¬μ΄λλ° (λ°μν) */}
+
+
+ {/* μ¬μ΄λλ° μΈλΆ μμ ν΄λ¦ μ λ«κΈ° (λͺ¨λ°μΌ) */}
+ {isSidebarOpen &&
setIsSidebarOpen(false)} />}
+
+
+ {/* 2. ν€λ */}
+
+
+ {/* 3. λ©μΈ μ½ν
μΈ */}
+
+ {children}
+
+
+ {/* 4. νλ‘ν
λ²νΌ (+) */}
+
+
+
+ );
+};
+
+export default Layout;
\ No newline at end of file
diff --git a/week8/client/src/components/LpCard.tsx b/week8/client/src/components/LpCard.tsx
new file mode 100644
index 00000000..d26c7654
--- /dev/null
+++ b/week8/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/week8/client/src/components/ProtectedRoute.tsx b/week8/client/src/components/ProtectedRoute.tsx
new file mode 100644
index 00000000..c86b3e83
--- /dev/null
+++ b/week8/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/week8/client/src/hooks/useBallAnimation.ts
similarity index 100%
rename from week4/client/src/hooks/useBallAnimation.ts
rename to week8/client/src/hooks/useBallAnimation.ts
diff --git a/week8/client/src/hooks/useDebounce.ts b/week8/client/src/hooks/useDebounce.ts
new file mode 100644
index 00000000..d4963ec6
--- /dev/null
+++ b/week8/client/src/hooks/useDebounce.ts
@@ -0,0 +1,30 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * κ°μ μΌμ μκ° μ§μ°μμΌ λ°ννλ useDebounce ν
+ * @param value λλ°μ΄μ±ν μ€μκ° μ
λ ₯κ°
+ * @param delay μ§μ° μκ° (ms)
+ */
+
+
+export function useDebounce
(value: T, delay: number = 300): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ // 1. νμ΄λ¨Έ μ€μ λ‘κ·Έ (μμ κΈ°μ€)
+ console.log(`μ
λ ₯: ${value} ... debounce νμ΄λ¨Έ μ€μ `);
+
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ console.log(`π λλ°μ΄μ€ μλ£! μ΅μ’
κ° λ³κ²½ ->`, value);
+ }, delay);
+
+ // 2. μΈλ§μ΄νΈ λλ μμ‘΄μ±(value, delay) λ³κ²½ μ κΈ°μ‘΄ νμ΄λ¨Έ clear
+ return () => {
+ console.log(`β μ΄μ debounce νμ΄λ¨Έ μ·¨μ (clearTimeout) -> νμ¬κ°: ${value}`);
+ clearTimeout(timer);
+ };
+ }, [value, delay]); // delay λ³κ²½λ μ¦μ λ°μλλλ‘ μμ‘΄μ± λ°°μ΄μ μΆκ°
+
+ return debouncedValue;
+}
\ No newline at end of file
diff --git a/week4/client/src/hooks/useForm.ts b/week8/client/src/hooks/useForm.ts
similarity index 100%
rename from week4/client/src/hooks/useForm.ts
rename to week8/client/src/hooks/useForm.ts
diff --git a/week8/client/src/index.css b/week8/client/src/index.css
new file mode 100644
index 00000000..bd6b0274
--- /dev/null
+++ b/week8/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/week8/client/src/main.tsx
similarity index 100%
rename from week4/client/src/main.tsx
rename to week8/client/src/main.tsx
diff --git a/week8/client/src/pages/GoogleCallback.tsx b/week8/client/src/pages/GoogleCallback.tsx
new file mode 100644
index 00000000..643f786c
--- /dev/null
+++ b/week8/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/week8/client/src/pages/LoginPage.tsx b/week8/client/src/pages/LoginPage.tsx
new file mode 100644
index 00000000..5c819b8a
--- /dev/null
+++ b/week8/client/src/pages/LoginPage.tsx
@@ -0,0 +1,89 @@
+import { useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import { useMutation } from '@tanstack/react-query';
+import api from '../apis/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();
+
+ // 7μ£Όμ°¨ ν΅μ¬: λ‘κ·ΈμΈ λ‘μ§μ useMutationμΌλ‘ μ ν
+ const loginMutation = useMutation({
+ mutationFn: (data: LoginFormValues) => api.post('/auth/signin', data),
+ onSuccess: (response) => {
+ if (response.data.status) {
+ const { accessToken, refreshToken, name } = response.data.data;
+
+ // ν ν° λ° μ 보 μ μ₯
+ localStorage.setItem('accessToken', accessToken);
+ localStorage.setItem('refreshToken', refreshToken);
+ localStorage.setItem('nickname', name);
+
+ alert(`${name}λ νμν©λλ€!`);
+ navigate('/'); // μ±κ³΅ μ νμΌλ‘ 리λ€μ΄λ μ
+ }
+ },
+ onError: (error: any) => {
+ alert(error.response?.data?.message || 'λ‘κ·ΈμΈμ μ€ν¨νμ΅λλ€. λ€μ νμΈν΄μ£ΌμΈμ! γ
γ
');
+ }
+ });
+
+ const handleGoogleLogin = () => {
+ window.location.href = 'http://localhost:8000/v1/auth/google/login';
+ };
+
+ return (
+
+ {balls.map((ball) => (
+
+ ))}
+
+
+
+
DORI
+
λ‘κ·ΈμΈ
+
+
+
+
+
+ {/* loginMutation.mutateλ₯Ό νΈμΆνλλ‘ μμ */}
+
+
+
+ );
+};
+
+export default LoginPage;
\ No newline at end of file
diff --git a/week8/client/src/pages/LpDetailPage.tsx b/week8/client/src/pages/LpDetailPage.tsx
new file mode 100644
index 00000000..183e978c
--- /dev/null
+++ b/week8/client/src/pages/LpDetailPage.tsx
@@ -0,0 +1,193 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useInfiniteQuery, useMutation, useQueryClient } 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 navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [order, setOrder] = useState<'asc' | 'desc'>('desc');
+ const [commentInput, setCommentInput] = useState('');
+ const { ref, inView } = useInView();
+
+ const lpIdNum = Number(lpid);
+
+ // 1. μμΈ λ°μ΄ν° μ‘°ν
+ const { data: lp, isLoading: isLpLoading } = useQuery({
+ queryKey: ['lp', lpIdNum],
+ queryFn: async () => {
+ const res = await api.get(`/lps/${lpIdNum}`);
+ return res.data.data;
+ }
+ });
+
+ // toggleLike Mutation λΆλΆλ§ μ΄ λ‘μ§μΌλ‘ μμ ν κ΅μ²΄ν΄λ΄!
+const toggleLike = useMutation({
+ mutationFn: async (isCurrentlyLiked: boolean) => {
+ return isCurrentlyLiked
+ ? api.delete(`/lps/${lpIdNum}/likes`)
+ : api.post(`/lps/${lpIdNum}/likes`);
+ },
+ onMutate: async (isCurrentlyLiked) => {
+ // 1. μ§ν μ€μΈ λͺ¨λ 리ν¨μΉ κ°μ μ·¨μ (λ§€μ° μ€μ!)
+ await queryClient.cancelQueries({ queryKey: ['lp', lpIdNum] });
+
+ const previousLp = queryClient.getQueryData(['lp', lpIdNum]);
+
+ // 2. UI μ¦μ μ
λ°μ΄νΈ (λκ΄μ μ
λ°μ΄νΈ)
+ queryClient.setQueryData(['lp', lpIdNum], (old: any) => {
+ if (!old) return old;
+ const currentLikes = old._count?.likes ?? 0;
+ return {
+ ...old,
+ isLiked: !isCurrentlyLiked,
+ _count: {
+ ...old._count,
+ likes: isCurrentlyLiked ? Math.max(0, currentLikes - 1) : currentLikes + 1
+ }
+ };
+ });
+
+ return { previousLp };
+ },
+ onError: (err: any, isCurrentlyLiked, context) => {
+ if (err.response?.status === 409) {
+
+ console.warn("β οΈ 409 μλ¬ λ°μ: μλ²μ 무κ΄νκ² UIλ₯Ό μ’μμ μνλ‘ κ³ μ ν©λλ€.");
+ queryClient.setQueryData(['lp', lpIdNum], (old: any) => ({
+ ...old,
+ isLiked: true
+ }));
+ } else {
+ // μ§μ§ ν΅μ μλ¬μΌ λλ§ λ‘€λ°±
+ queryClient.setQueryData(['lp', lpIdNum], context?.previousLp);
+ alert('μ’μμ μ²λ¦¬μ μ€ν¨νμ΄μ!');
+ }
+ },
+ onSettled: (data, error) => {
+
+ const isConflict = (error as any)?.response?.status === 409;
+
+ if (!isConflict) {
+ // 409 μλ¬κ° μλ λλ§ 0.5μ΄ λ€μ λκΈ°ν
+ setTimeout(() => {
+ queryClient.invalidateQueries({ queryKey: ['lp', lpIdNum] });
+ queryClient.invalidateQueries({ queryKey: ['myList'] });
+ }, 500);
+ } else {
+ console.log("409 μλ¬μ΄λ―λ‘ μλ² λκΈ°νλ₯Ό 건λλ°κ³ λ‘컬 UIλ₯Ό μ μ§ν©λλ€.");
+ }
+ }
+});
+
+ // 3. λκΈ λͺ©λ‘ (무νμ€ν¬λ‘€)
+ const { data: commentData, fetchNextPage, hasNextPage } = useInfiniteQuery({
+ queryKey: ['lpComments', lpIdNum, order],
+ queryFn: async ({ pageParam = undefined }) => {
+ const res = await api.get(`/lps/${lpIdNum}/comments`, { params: { cursor: pageParam, limit: 10, order } });
+ return res.data.data;
+ },
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined,
+ });
+
+ const createComment = useMutation({
+ mutationFn: (content: string) => api.post(`/lps/${lpIdNum}/comments`, { content }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['lpComments', lpIdNum] });
+ setCommentInput('');
+ }
+ });
+
+ useEffect(() => { if (inView && hasNextPage) fetchNextPage(); }, [inView, hasNextPage]);
+
+ if (isLpLoading) return Vinyl Loading...
;
+
+ return (
+
+ {/* LP μμΈ μΉ΄λ */}
+
+
+ {lp?.title}
+
+
+
+
+

+
+
+
+ {lp?.tags?.map((tag: any) => (
+
+ #{tag.name}
+
+ ))}
+
+
+
+ "{lp?.content}"
+
+
+ {/* π μ’μμ λ²νΌ μΉμ
*/}
+
+
+
+ {lp?._count?.likes || 0}
+
+
+
+
+ {/* λκΈ μΉμ
*/}
+
+
Comments
+
+ setCommentInput(e.target.value)}
+ placeholder="Write a comment..."
+ className="flex-1 bg-white/5 border border-white/10 rounded-3xl p-5 text-sm focus:border-pink-500/50 outline-none transition-all placeholder:text-white/20"
+ />
+
+
+
+
+ {commentData?.pages.map((page) =>
+ page.data.map((comment: any) => (
+
+
{comment.author.name}
+
{comment.content}
+
+ ))
+ )}
+
+
+
+
+ );
+};
+
+export default LpDetailPage;
\ No newline at end of file
diff --git a/week8/client/src/pages/LpListPage.tsx b/week8/client/src/pages/LpListPage.tsx
new file mode 100644
index 00000000..d3b8a88c
--- /dev/null
+++ b/week8/client/src/pages/LpListPage.tsx
@@ -0,0 +1,343 @@
+import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import { useEffect, useState, useRef } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import api from '../apis/axios';
+import LpCard from '../components/LpCard';
+import { useDebounce } from '../hooks/useDebounce';
+
+const LpListPage = () => {
+ const [sort, setSort] = useState<'asc' | 'desc'>('desc');
+ const { ref, inView } = useInView();
+ const queryClient = useQueryClient();
+ const fileInputRef = useRef(null);
+
+ // URLμ 쿼리 μ€νΈλ§ κ°μ§ (?searchMode=true μΌ λλ§ κ²μμ°½ νμ±ν)
+ const [searchParams] = useSearchParams();
+ const isSearchMode = searchParams.get('searchMode') === 'true';
+
+ // κ²μμ΄ λ° κ²μ νμ
μν
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchType, setSearchType] = useState<'title' | 'tag'>('title');
+
+ // λλ°μ΄μ€ ν
μ°λ (0.3μ΄ μ§μ°)
+ const debouncedQuery = useDebounce(searchQuery, 300);
+
+ // μ΅κ·Ό κ²μμ΄ μν κ΄λ¦¬ (λ‘컬μ€ν λ¦¬μ§ μ°λ)
+ const [recentSearches, setRecentSearches] = useState(() => {
+ const saved = localStorage.getItem('recentSearches');
+ return saved ? JSON.parse(saved) : [];
+ });
+
+ // λλ°μ΄μ€λ κ²μμ΄κ° μμΌλ©΄ μ΅κ·Ό κ²μμ΄ κΈ°λ‘ λ¦¬μ€νΈμ μΆκ°
+ useEffect(() => {
+ if (debouncedQuery.trim()) {
+ setRecentSearches((prev) => {
+ const filtered = prev.filter((item) => item !== debouncedQuery.trim());
+ const updated = [debouncedQuery.trim(), ...filtered].slice(0, 5); // μ΅κ·Ό 5κ° μ μ§
+ localStorage.setItem('recentSearches', JSON.stringify(updated));
+ return updated;
+ });
+ }
+ }, [debouncedQuery]);
+
+ // μ΅κ·Ό κ²μμ΄ κ°λ³ μμ
+ const handleDeleteRecent = (textToDelete: string, e: React.MouseEvent) => {
+ e.stopPropagation(); // λΆλͺ¨ ν΄λ¦ μ΄λ²€νΈ λ°©μ§
+ setRecentSearches((prev) => {
+ const updated = prev.filter((item) => item !== textToDelete);
+ localStorage.setItem('recentSearches', JSON.stringify(updated));
+ return updated;
+ });
+ };
+
+ // μ΅κ·Ό κ²μμ΄ μ 체 μμ
+ const handleClearAllRecent = () => {
+ setRecentSearches([]);
+ localStorage.removeItem('recentSearches');
+ };
+
+ // κ²μ λͺ¨λκ° μλ λλ μ
λ ₯κ° μμ ν λΉμμ£ΌκΈ°
+ useEffect(() => {
+ if (!isSearchMode) {
+ setSearchQuery('');
+ }
+ }, [isSearchMode]);
+
+ // λͺ¨λ¬ λ° LP μΆκ° μ
λ ₯ μν κ΄λ¦¬ (κΈ°μ‘΄ μ μ§)
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [tags, setTags] = useState([]);
+ const [tagInput, setTagInput] = useState('');
+ const [thumbnailUrl, setThumbnailUrl] = useState('');
+ const [previewUrl, setPreviewUrl] = useState('');
+
+ // 1. μ΄λ―Έμ§ μ
λ‘λ Mutation (κΈ°μ‘΄ μ μ§)
+ const uploadImageMutation = useMutation({
+ mutationFn: async (file: File) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await api.post('/uploads', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+ return res.data.data.imageUrl;
+ },
+ onSuccess: (url) => {
+ setThumbnailUrl(url);
+ },
+ onError: () => alert('μ΄λ―Έμ§ μ
λ‘λμ μ€ν¨νμ΄μ!')
+ });
+
+ // 2. LP κ²μκΈ μμ± Mutation (κΈ°μ‘΄ μ μ§)
+ const createLpMutation = useMutation({
+ mutationFn: (newLp: any) => api.post('/lps', newLp),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['lps'] });
+ setIsModalOpen(false);
+ resetForm();
+ },
+ onError: (error: any) => alert(error.response?.data?.message || 'LP μμ± μ€ν¨!')
+ });
+
+ // νΌ μ΄κΈ°ν
+ const resetForm = () => {
+ setTitle('');
+ setContent('');
+ setTags([]);
+ setTagInput('');
+ setThumbnailUrl('');
+ setPreviewUrl('');
+ };
+
+ // μ¬μ§ μ ν νΈλ€λ¬
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ setPreviewUrl(URL.createObjectURL(file));
+ uploadImageMutation.mutate(file);
+ }
+ };
+
+ // μ΅μ’
λ±λ‘ νΈλ€λ¬
+ const handleAddLp = () => {
+ if (!title || !content) return alert('μ λͺ©κ³Ό λ΄μ©μ μ
λ ₯ν΄μ£ΌμΈμ!');
+ if (!thumbnailUrl) return alert('μ΄λ―Έμ§κ° μ
λ‘λ μ€μ
λλ€. μ μλ§ κΈ°λ€λ €μ£ΌμΈμ!');
+
+ createLpMutation.mutate({
+ title,
+ content,
+ thumbnail: thumbnailUrl,
+ tags,
+ published: true
+ });
+ };
+
+ // 3. 무ν μ€ν¬λ‘€ λ°μ΄ν° ν¨μΉ (κ²μ 쿼리 κ°λ³ μ°λ)
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
+ queryKey: ['lps', sort, debouncedQuery, searchType, isSearchMode],
+ queryFn: async ({ pageParam = undefined }) => {
+ const params: any = {
+ order: sort,
+ cursor: pageParam,
+ limit: 10
+ };
+
+ // κ²μμ°½μ΄ μ΄λ € μκ³ , λλ°μ΄μ€λ κ²μμ΄κ° μμ λλ§ νλΌλ―Έν° λΆκΈ° μ£Όμ
+ if (isSearchMode && debouncedQuery.trim()) {
+ if (searchType === 'title') {
+ params.search = debouncedQuery;
+ } else {
+ params.tag = debouncedQuery;
+ }
+ }
+
+ const res = await api.get('/lps', { params });
+ return res.data.data;
+ },
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined,
+ });
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) fetchNextPage();
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ return (
+
+
+ {/* ββββββββββββββββββ μλ¨ 1/3 μμ: μ¬μ΄λλ° 'μ°ΎκΈ°' ν΄λ¦ μμλ§ νμ±ν ββββββββββββββββββ */}
+ {isSearchMode && (
+
+ {/* μΈν μμ */}
+
+
+ π
+ setSearchQuery(e.target.value)}
+ placeholder="κ²μμ΄λ₯Ό μ
λ ₯νμΈμ"
+ className="w-full bg-transparent outline-none text-white text-md placeholder:text-white/20"
+ />
+ {searchQuery && (
+
+ )}
+
+
+ {/* μ λ ¬ μ΅μ
μ
λ νΈ λ°μ€ */}
+
+
+
+ {/* μ΅κ·Ό κ²μμ΄ κΈ°λ‘ν */}
+
+
+ μ΅κ·Ό κ²μμ΄
+ {recentSearches.length > 0 && (
+
+ )}
+
+
+ {recentSearches.length === 0 ? (
+
μ΅κ·Ό κ²μ λ΄μμ΄ μμ΅λλ€.
+ ) : (
+
+ {recentSearches.map((item, index) => (
+
setSearchQuery(item)}
+ className="flex items-center gap-2 bg-white/5 border border-white/10 hover:border-white/30 px-3 py-1 rounded-full text-xs cursor-pointer transition-colors"
+ >
+ {item}
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ {/* ββββββββββββββββββ νλ¨ 2/3 μμ: μ λ ¬ λ° λ¦¬μ€νΈ κ³΅ν΅ λ©μΈ μμ ββββββββββββββββββ */}
+ {/* μ΅μ μ / μ€λλμ μ λ ¬ νν° */}
+
+ {['desc', 'asc'].map((order) => (
+
+ ))}
+
+
+ {/* LP μΉ΄λ λ°°μΉ κ·Έλ¦¬λ */}
+
+ {isLoading && [...Array(10)].map((_, i) => (
+
+ ))}
+ {data?.pages.map((page) =>
+ page.data.map((lp: any) =>
)
+ )}
+
+
+ {/* 무νμ€ν¬λ‘€ κ°μ§ μ€νμ΄μ‘΄ */}
+
+
+ {/* LP μΆκ° λ±λ‘ λͺ¨λ¬ */}
+ {isModalOpen && (
+
setIsModalOpen(false)}>
+
e.stopPropagation()}>
+
New Vinyl
+
+
fileInputRef.current?.click()}
+ className="relative w-44 h-44 mx-auto cursor-pointer group mb-4"
+ >
+

+
+ UPLOAD PHOTO
+
+ {uploadImageMutation.isPending && (
+
+ )}
+
+
+
+
setTitle(e.target.value)}
+ placeholder="LP Name"
+ className="bg-white/5 border border-white/10 p-4 rounded-2xl outline-none focus:border-cyan-500 transition-colors text-sm"
+ />
+
+
+ )}
+
+
+
+ );
+};
+
+export default LpListPage;
\ No newline at end of file
diff --git a/week8/client/src/pages/MyPage.tsx b/week8/client/src/pages/MyPage.tsx
new file mode 100644
index 00000000..05129cd0
--- /dev/null
+++ b/week8/client/src/pages/MyPage.tsx
@@ -0,0 +1,132 @@
+import { useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
+import api from '../apis/axios';
+import { useBallAnimation } from '../hooks/useBallAnimation';
+import LpCard from '../components/LpCard';
+import { useInView } from 'react-intersection-observer';
+
+const MyPage = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const containerRef = useRef(null);
+ const balls = useBallAnimation(containerRef);
+ const { ref, inView } = useInView();
+
+ // ν μν: 'likes' (μ’μμ ν LP), 'my' (λ΄κ° μμ±ν LP)
+ const [activeTab, setActiveTab] = useState<'likes' | 'my'>('likes');
+ const [sort, setSort] = useState<'asc' | 'desc'>('desc');
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [editName, setEditName] = useState('');
+
+ // 1. λ΄ μ 보 μ‘°ν
+ const { data: user } = useQuery({
+ queryKey: ['userMe'],
+ queryFn: async () => {
+ const res = await api.get('/users/me');
+ return res.data.data;
+ }
+ });
+
+ // 2. νμ λ°λ₯Έ 리μ€νΈ 무ν μ€ν¬λ‘€ (λͺ
μΈμ v1/lps/likes/me λλ v1/lps/user μ¬μ©)
+ const { data: listData, fetchNextPage, hasNextPage } = useInfiniteQuery({
+ queryKey: ['myList', activeTab, sort],
+ queryFn: async ({ pageParam = undefined }) => {
+ const endpoint = activeTab === 'likes' ? '/lps/likes/me' : '/lps/user';
+ const res = await api.get(endpoint, { params: { order: sort, cursor: pageParam, limit: 10 } });
+ return res.data.data;
+ },
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined,
+ });
+
+ // 3. λλ€μ λ³κ²½ λκ΄μ μ
λ°μ΄νΈ
+ const updateNickname = useMutation({
+ mutationFn: (newName: string) => api.patch('/users/nickname', { nickname: newName }),
+ onMutate: async (newName) => {
+ await queryClient.cancelQueries({ queryKey: ['userMe'] });
+ const previousUser = queryClient.getQueryData(['userMe']);
+ queryClient.setQueryData(['userMe'], (old: any) => ({ ...old, name: newName }));
+ setIsEditMode(false);
+ return { previousUser };
+ },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: ['userMe'] }),
+ });
+
+ // μ€ν¬λ‘€ κ°μ§
+ if (inView && hasNextPage) fetchNextPage();
+
+ return (
+
+ {/* λ°°κ²½ μ λλ©μ΄μ
*/}
+
+ {balls.map((ball) => (
+
+ ))}
+
+
+ {/* μλ¨ νλ‘ν μΉμ
*/}
+
+
+

+
+
+
+ {isEditMode ? (
+
+ setEditName(e.target.value)} className="bg-transparent text-2xl font-black outline-none text-center" autoFocus />
+
+
+ ) : (
+
+
{user?.name}
+
+
+ )}
+
νλ‘ νΈ μ§±
+
{user?.email}
+
+
+
+ {/* ν λ©λ΄ */}
+
+
+
+
+
+ {/* 리μ€νΈ μΉμ
*/}
+
+ {/* μ λ ¬ λ²νΌ */}
+
+ {['asc', 'desc'].map(o => (
+
+ ))}
+
+
+ {/* 그리λ 리μ€νΈ */}
+
+ {listData?.pages.map(page =>
+ page.data.map((lp: any) => )
+ )}
+
+
+
+
+ );
+};
+
+export default MyPage;
\ No newline at end of file
diff --git a/week8/client/src/pages/SearchPage.tsx b/week8/client/src/pages/SearchPage.tsx
new file mode 100644
index 00000000..5f4b077f
--- /dev/null
+++ b/week8/client/src/pages/SearchPage.tsx
@@ -0,0 +1,141 @@
+import { useState, useEffect } from 'react';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import api from '../apis/axios';
+import LpCard from '../components/LpCard';
+import { useDebounce } from '../hooks/useDebounce';
+
+const SearchPage = () => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchType, setSearchType] = useState<'title' | 'tag'>('title');
+ const debouncedQuery = useDebounce(searchQuery, 300);
+
+ // μ΅κ·Ό κ²μμ΄ μν (λ‘컬μ€ν 리μ§)
+ const [recentSearches, setRecentSearches] = useState(() => {
+ const saved = localStorage.getItem('recentSearches');
+ return saved ? JSON.parse(saved) : [];
+ });
+
+ const { ref, inView } = useInView();
+
+ // λλ°μ΄μ€λ κ²μμ΄ μ μ₯
+ useEffect(() => {
+ if (debouncedQuery.trim()) {
+ setRecentSearches((prev) => {
+ const filtered = prev.filter((item) => item !== debouncedQuery.trim());
+ const updated = [debouncedQuery.trim(), ...filtered].slice(0, 5);
+ localStorage.setItem('recentSearches', JSON.stringify(updated));
+ return updated;
+ });
+ }
+ }, [debouncedQuery]);
+
+ const handleDeleteRecent = (textToDelete: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setRecentSearches((prev) => {
+ const updated = prev.filter((item) => item !== textToDelete);
+ localStorage.setItem('recentSearches', JSON.stringify(updated));
+ return updated;
+ });
+ };
+
+ const handleClearAllRecent = () => {
+ setRecentSearches([]);
+ localStorage.removeItem('recentSearches');
+ };
+
+ // React Query λ°μ΄ν° νμΉ
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
+ queryKey: ['lps', debouncedQuery, searchType],
+ queryFn: async ({ pageParam = undefined }) => {
+ const params: any = { cursor: pageParam, limit: 10, order: 'desc' };
+ if (debouncedQuery.trim()) {
+ if (searchType === 'title') params.search = debouncedQuery;
+ else params.tag = debouncedQuery;
+ }
+ const res = await api.get('/lps', { params });
+ return res.data.data;
+ },
+ initialPageParam: undefined,
+ getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined),
+ });
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ return (
+
+
+
+
+ π
+ setSearchQuery(e.target.value)}
+ placeholder="κ²μμ΄λ₯Ό μ
λ ₯νμΈμ"
+ className="w-full bg-transparent outline-none text-white text-md placeholder:text-white/20"
+ />
+ {searchQuery && (
+
+ )}
+
+
+
+
+
+ {/* μ΅κ·Ό κ²μμ΄ */}
+
+
+ μ΅κ·Ό κ²μμ΄
+ {recentSearches.length > 0 && (
+
+ )}
+
+ {recentSearches.length === 0 ? (
+
μ΅κ·Ό κ²μ λ΄μμ΄ μμ΅λλ€.
+ ) : (
+
+ {recentSearches.map((item, index) => (
+
setSearchQuery(item)}
+ className="flex items-center gap-2 bg-white/5 border border-white/10 px-3 py-1 rounded-full text-xs cursor-pointer"
+ >
+ {item}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* ββββββββββββββββββ νλ¨ λͺ©λ‘ κ²°κ³Ό μμ ββββββββββββββββββ */}
+
+
+ {isLoading && [...Array(10)].map((_, i) =>
)}
+ {data?.pages[0]?.data.length === 0 && (
+
μΌμΉνλ LPκ° μμ΅λλ€. π₯²
+ )}
+ {data?.pages.map((page) => page.data.map((lp: any) =>
))}
+
+ {hasNextPage &&
}
+
+
+ );
+};
+
+export default SearchPage;
\ No newline at end of file
diff --git a/week4/client/src/pages/SignupPage.tsx b/week8/client/src/pages/SignupPage.tsx
similarity index 100%
rename from week4/client/src/pages/SignupPage.tsx
rename to week8/client/src/pages/SignupPage.tsx
diff --git a/week8/client/src/pages/UploadPage.tsx b/week8/client/src/pages/UploadPage.tsx
new file mode 100644
index 00000000..fc658aec
--- /dev/null
+++ b/week8/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/week8/client/src/utils/validate.ts
similarity index 100%
rename from week4/client/src/utils/validate.ts
rename to week8/client/src/utils/validate.ts
diff --git a/week8/client/src/vite-env.d.ts b/week8/client/src/vite-env.d.ts
new file mode 100644
index 00000000..41237e8b
--- /dev/null
+++ b/week8/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/week8/client/tailwind.config.js
similarity index 100%
rename from week4/client/tailwind.config.js
rename to week8/client/tailwind.config.js
diff --git a/week4/client/tsconfig.app.json b/week8/client/tsconfig.app.json
similarity index 100%
rename from week4/client/tsconfig.app.json
rename to week8/client/tsconfig.app.json
diff --git a/week4/client/tsconfig.json b/week8/client/tsconfig.json
similarity index 100%
rename from week4/client/tsconfig.json
rename to week8/client/tsconfig.json
diff --git a/week4/client/tsconfig.node.json b/week8/client/tsconfig.node.json
similarity index 100%
rename from week4/client/tsconfig.node.json
rename to week8/client/tsconfig.node.json
diff --git a/week8/client/vite.config.ts b/week8/client/vite.config.ts
new file mode 100644
index 00000000..bb2324da
--- /dev/null
+++ b/week8/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