diff --git a/LP/package.json b/LP/package.json index 3665cac..6a0a9da 100644 --- a/LP/package.json +++ b/LP/package.json @@ -16,6 +16,8 @@ "@tanstack/react-query": "^5.75.2", "axios": "^1.9.0", "clsx": "^2.1.1", + "lodash.debounce": "^4.0.8", + "lucide-react": "^0.510.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.56.1", diff --git a/LP/public/cd.png b/LP/public/cd.png new file mode 100644 index 0000000..a517df3 Binary files /dev/null and b/LP/public/cd.png differ diff --git a/LP/public/lp.png b/LP/public/lp.png new file mode 100644 index 0000000..0d7df61 Binary files /dev/null and b/LP/public/lp.png differ diff --git a/LP/src/App.tsx b/LP/src/App.tsx index f5a4fb6..0d31d1b 100644 --- a/LP/src/App.tsx +++ b/LP/src/App.tsx @@ -8,12 +8,12 @@ import SignupPage from "./pages/SignupPage"; import Mypage from "./pages/MyPage.tsx"; import GoogleCallback from "./pages/GoogleCallback"; import LPDetail from "./pages/LPDetail.tsx"; +import SearchResult from "./pages/SearchResult"; import ProtectedRoute from "../src/components/ProtectedRoute"; -import { AuthProvider } from "./context/AuthContext"; +import { AuthProvider } from "./context/AuthContext"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - const queryClient = new QueryClient(); const router = createBrowserRouter([ @@ -25,6 +25,7 @@ const router = createBrowserRouter([ { index: true, element: }, { path: "login", element: }, { path: "signup", element: }, + { path: "search", element: }, { path: "mypage", element: ( @@ -59,5 +60,4 @@ function App() { ); } - export default App; diff --git a/LP/src/api/comments.ts b/LP/src/api/comments.ts index 1158fd9..1b6ee2c 100644 --- a/LP/src/api/comments.ts +++ b/LP/src/api/comments.ts @@ -1,20 +1,25 @@ -import api from './axios'; +import api from "./axios"; export const getComments = async ({ - lpId, - cursor = 0, - limit = 10, - order = 'desc', - }: { - lpId: number; - cursor?: number; - limit?: number; - order?: 'asc' | 'desc'; - }) => { - const res = await api.get(`/lps/${lpId}/comments`, { - params: { cursor, limit, order }, - }); - await new Promise(resolve => setTimeout(resolve, 1000)); //로딩바 확인용 지연 + lpId, + cursor = 0, + limit = 10, + order = "desc", +}: { + lpId: number; + cursor?: number; + limit?: number; + order?: "asc" | "desc"; +}) => { + const res = await api.get(`/lps/${lpId}/comments`, { + params: { cursor, limit, order }, + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); //로딩바 확인용 지연 - return res.data.data; - }; \ No newline at end of file + console.log("getComments 반환값:", res.data); + return { + comments: res.data.data.data, //댓글 리스트 + nextCursor: res.data.data.nextCursor, + hasNext: res.data.data.hasNext, + }; +}; diff --git a/LP/src/api/lp.ts b/LP/src/api/lp.ts index 4038755..fb69762 100644 --- a/LP/src/api/lp.ts +++ b/LP/src/api/lp.ts @@ -42,4 +42,4 @@ export const fetchLpDetail = async (lpId: string | number): Promise => { }; return withTagIds; -}; \ No newline at end of file +}; diff --git a/LP/src/components/CommentSection.tsx b/LP/src/components/CommentSection.tsx new file mode 100644 index 0000000..98fa5d3 --- /dev/null +++ b/LP/src/components/CommentSection.tsx @@ -0,0 +1,253 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useComments } from "../hooks/useComments"; +import api from "../api/axios"; + +interface CommentSectionProps { + LPid: number; + user: { id: number; name: string } | null; +} + +const CommentSection = ({ LPid, user }: CommentSectionProps) => { + const [order, setOrder] = useState<"asc" | "desc">("desc"); + const { + comments, + comment, + setComment, + submitComment, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useComments(LPid, order); + + const [menuOpenId, setMenuOpenId] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingContent, setEditingContent] = useState(""); + + const loadMoreRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage) { + fetchNextPage(); + } + }, + { threshold: 1.0 } + ); + if (loadMoreRef.current) observer.observe(loadMoreRef.current); + return () => { + if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); + }; + }, [hasNextPage, fetchNextPage]); + + return ( +
+
+ + +
+
{ + e.preventDefault(); + if (!comment.trim()) return; + + if (editingCommentId) { + // Only patch if content changed + const editingComment = comments?.find( + (c) => c.id === editingCommentId + ); + if (editingComment && editingComment.content === comment) { + setEditingCommentId(null); + setComment(""); + return; + } + try { + await api.patch(`/lps/${LPid}/comments/${editingCommentId}`, { + content: comment, + }); + setEditingCommentId(null); + setComment(""); + await fetchNextPage({ pageParam: 0 }); // simulate refetch + } catch (err) { + console.error("댓글 수정 실패:", err); + } + } else { + await submitComment(comment); + } + }} + className="flex items-center gap-2 mb-4" + > + setComment(e.target.value)} + placeholder="댓글을 입력하세요" + className="flex-1 p-2 rounded bg-gray-800 text-white border border-gray-600" + /> + +
+ {/* Conditional rendering: skeleton or comments */} +
+ {isLoading ? ( +
    + {Array.from({ length: 5 }).map((_, idx) => ( +
  • +
    +
    +
    +
    +
    +
  • + ))} +
+ ) : ( +
    + {comments?.map((c) => + c && c.author ? ( +
  • + {`${c.author.name} +
    +
    +
    {c.author.name}
    + {user?.id === c.author.id && ( +
    + + {menuOpenId === c.id && ( +
    + + +
    + )} +
    + )} +
    + {editingCommentId === c.id ? ( +
    + setEditingContent(e.target.value)} + className="flex-1 p-1 px-2 text-sm text-white bg-transparent border border-gray-500 rounded" + /> + + +
    + ) : ( +
    {c.content}
    + )} +
    +
  • + ) : null + )} +
+ )} +
+
+
+ ); +}; + +export default CommentSection; diff --git a/LP/src/components/DeleteUserModal.tsx b/LP/src/components/DeleteUserModal.tsx new file mode 100644 index 0000000..882bfaf --- /dev/null +++ b/LP/src/components/DeleteUserModal.tsx @@ -0,0 +1,36 @@ +// src/components/DleteUserModal.tsx +import React from "react"; + +interface DeleteUserModalProps { + onDelete: () => void; + onCancel: () => void; +} + +const DeleteUserModal = ({ onDelete, onCancel }: DeleteUserModalProps) => { + return ( +
+
+ +

정말 탈퇴하시겠습니까?

+
+ + +
+
+
+ ); +}; + +export default DeleteUserModal; diff --git a/LP/src/components/FAB.tsx b/LP/src/components/FAB.tsx new file mode 100644 index 0000000..7e731ad --- /dev/null +++ b/LP/src/components/FAB.tsx @@ -0,0 +1,21 @@ +import { Plus } from "lucide-react"; + +interface FABProps { + onClick: () => void; + icon?: React.ReactNode; + ariaLabel?: string; +} + +const FAB = ({ onClick, icon = , ariaLabel = "Add" }: FABProps) => { + return ( + + ); +}; + +export default FAB; diff --git a/LP/src/components/Header.tsx b/LP/src/components/Header.tsx index 069902a..fffa309 100644 --- a/LP/src/components/Header.tsx +++ b/LP/src/components/Header.tsx @@ -1,6 +1,7 @@ +import { useState } from "react"; import { useAuthContext } from "../context/AuthContext"; import { useNavigate } from "react-router-dom"; -import { FiSearch } from "react-icons/fi"; +import { FiSearch } from "react-icons/fi"; interface HeaderProps { onToggleSidebar: () => void; @@ -16,10 +17,7 @@ const Header = ({ onToggleSidebar }: HeaderProps) => {
{/* 왼쪽: 햄버거 + 로고 */}
-

{ {/* 오른쪽: 검색 + 로그인 영역 */}
{/* 검색 아이콘 */} - - +
+ +
{isLoggedIn ? ( <> - + {user?.name ?? "회원"}님 반갑습니다. + + + ) : ( + <> + + + + )} +
+ ); +}; + +export default LPControls; diff --git a/LP/src/components/LPHeader.tsx b/LP/src/components/LPHeader.tsx new file mode 100644 index 0000000..7a75970 --- /dev/null +++ b/LP/src/components/LPHeader.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Lp } from "../types/lp"; + +interface LPHeaderProps { + lp: Lp; +} + +const LPHeader = ({ lp }: LPHeaderProps) => { + return ( +
+
+ LP +
+ + {new Date(lp.createdAt).toLocaleDateString("ko-KR")} + +
+
+ ); +}; + +export default LPHeader; diff --git a/LP/src/components/LPModal.tsx b/LP/src/components/LPModal.tsx new file mode 100644 index 0000000..46bd6d8 --- /dev/null +++ b/LP/src/components/LPModal.tsx @@ -0,0 +1,121 @@ +// LPModal.tsx +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; +import api from "../api/axios"; + +interface Props { + onClose: () => void; +} + +const LPModal = ({ onClose }: Props) => { + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState([]); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + + const { mutate } = useMutation({ + mutationFn: async () => { + const payload = { + title, + content, + thumbnail: "https://example.com/thumbnail.png", // 임시 + tags, + published: true, + }; + const res = await api.post("/lps", payload); + return res.data; + }, + onSuccess: () => { + onClose(); // 성공 시 모달 닫기 + }, + onError: (error) => { + console.error("LP 생성 실패:", error); + }, + }); + + const handleAddTag = () => { + if (tagInput.trim() === "") return; + setTags((prev) => [...prev, tagInput.trim()]); + setTagInput(""); + }; + + const handleRemoveTag = (tagToRemove: string) => { + setTags((prev) => prev.filter((tag) => tag !== tagToRemove)); + }; + + return ( +
+
e.stopPropagation()} // 모달 내부 클릭시 닫히지 않도록 + > +
+ +
+
+ +
+ LP +
+ + setTitle(e.target.value)} + className="w-full mb-2 p-2 bg-black border border-gray-600 rounded" + placeholder="LP Name" + /> + setContent(e.target.value)} + className="w-full mb-2 p-2 bg-black border border-gray-600 rounded" + placeholder="LP Content" + /> +
+ setTagInput(e.target.value)} + className="flex-1 p-2 bg-black border border-gray-600 rounded" + placeholder="LP Tag" + /> + +
+ + {/* ✅ 태그 리스트 출력 */} +
+ {tags.map((tag) => ( +
+ {tag} + +
+ ))} +
+ + +
+
+ ); +}; + +export default LPModal; diff --git a/LP/src/components/LikeButton.tsx b/LP/src/components/LikeButton.tsx new file mode 100644 index 0000000..b810f79 --- /dev/null +++ b/LP/src/components/LikeButton.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +interface LikeButtonProps { + liked: boolean; + likeCount: number; + handleLike: () => void; +} + +const LikeButton = ({ liked, likeCount, handleLike }: LikeButtonProps) => { + return ( + + ); +}; + +export default LikeButton; diff --git a/LP/src/components/Sidebar.tsx b/LP/src/components/Sidebar.tsx index 84a99c6..9d6bd0f 100644 --- a/LP/src/components/Sidebar.tsx +++ b/LP/src/components/Sidebar.tsx @@ -1,18 +1,21 @@ // src/components/Sidebar.tsx import { Link, useLocation } from "react-router-dom"; +import { useState } from "react"; interface SidebarProps { isOpen: boolean; onClose: () => void; + onRequestDelete: () => void; // 추가 } -const Sidebar = ({ isOpen, onClose }: SidebarProps) => { +const Sidebar = ({ isOpen, onClose, onRequestDelete }: SidebarProps) => { const location = useLocation(); - const isActive = (path: string) => location.pathname === path; + const isActive = (path: string) => + location.pathname === path || (path === "/" && location.pathname === ""); return ( <> - {/* 오버레이: 클릭 시 닫힘 (모바일에서만) */} + {/* 오버레이 */}
{ }`} /> - {/* 사이드바 본체 (항상 렌더링, 모바일은 슬라이드 / 데스크탑은 고정) */} + {/* 사이드바 */} ); }; -export default Sidebar; \ No newline at end of file +export default Sidebar; diff --git a/LP/src/context/AuthContext.tsx b/LP/src/context/AuthContext.tsx index 2ac1077..16b89d4 100644 --- a/LP/src/context/AuthContext.tsx +++ b/LP/src/context/AuthContext.tsx @@ -1,92 +1,103 @@ import { - createContext, - useContext, - useState, - useEffect, - PropsWithChildren, - } from "react"; - import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "../constants/key"; - - interface AuthTokens { - accessToken: string; - refreshToken: string; - user: { name: string; email: string }; // user 추가 - } + createContext, + useContext, + useState, + useEffect, + PropsWithChildren, +} from "react"; +import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "../constants/key"; - interface AuthContextType { - isLoggedIn: boolean; - accessToken: string | null; - refreshToken: string | null; - user: { name: string } | null; - isLoading: boolean; // 로딩 상태 추가 - logout: () => void; - login: (tokens: AuthTokens) => Promise; - } - - const AuthContext = createContext(undefined); - - export const AuthProvider = ({ children }: PropsWithChildren) => { - const [accessToken, setAccessToken] = useState(null); - const [refreshToken, setRefreshToken] = useState(null); - const [isLoading, setIsLoading] = useState(true); // 초기 로딩 - const [user, setUser] = useState<{ name: string } | null>(null); - - useEffect(() => { - const storedAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY); - const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); - const storedUser = localStorage.getItem("user"); - - if (storedAccessToken) setAccessToken(storedAccessToken); - if (storedRefreshToken) setRefreshToken(storedRefreshToken); - - try { - if (storedUser) { - setUser(JSON.parse(storedUser)); - } else { - setUser(null); // 없으면 null - } - } catch (error) { - console.warn("Invalid JSON in localStorage 'user':", storedUser); - setUser(null); // 파싱 에러 시 fallback +interface AuthTokens { + accessToken: string; + refreshToken: string; + user: { id: number; name: string; email: string }; // id 추가 +} + +interface AuthContextType { + isLoggedIn: boolean; + accessToken: string | null; + refreshToken: string | null; + user: { id: number; name: string; email?: string } | null; // id 포함, email optional + isLoading: boolean; // 로딩 상태 추가 + logout: () => void; + login: (tokens: AuthTokens) => Promise; + setUser: (user: User | null) => void; +} + +interface User { + id: number; + name: string; + email?: string; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: PropsWithChildren) => { + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); // 초기 로딩 + const [user, setUser] = useState(null); + + useEffect(() => { + const storedAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY); + const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + const storedUser = localStorage.getItem("user"); + + if (storedAccessToken) setAccessToken(storedAccessToken); + if (storedRefreshToken) setRefreshToken(storedRefreshToken); + + try { + if (storedUser) { + const parsedUser = JSON.parse(storedUser); + console.log("복원된 user:", parsedUser); + setUser(parsedUser); + } else { + setUser(null); // 없으면 null } - - setIsLoading(false); - }, []); - - const logout = () => { - localStorage.removeItem(ACCESS_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem("user"); - setAccessToken(null); - setRefreshToken(null); - setUser(null); - }; + } catch (error) { + console.warn("Invalid JSON in localStorage 'user':", storedUser); + setUser(null); // 파싱 에러 시 fallback + } - const login = async ({ accessToken, refreshToken, user }: AuthTokens) => { - console.log("✅ login called with user:", user); // 이거 추가해서 확인 - setAccessToken(accessToken); - setRefreshToken(refreshToken); - setUser(user); - localStorage.setItem("accessToken", accessToken); - localStorage.setItem("refreshToken", refreshToken); - localStorage.setItem("user", JSON.stringify(user)); // ✅ 저장 - }; - - const value: AuthContextType = { - isLoggedIn: !!accessToken, - accessToken, - refreshToken, - logout, - login, - user, - isLoading, - }; - - return {children}; + setIsLoading(false); + }, []); + + const logout = () => { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem("user"); + setAccessToken(null); + setRefreshToken(null); + setUser(null); + }; + + const login = async ({ accessToken, refreshToken, user }: AuthTokens) => { + console.log("✅ login called with user:", user); // 이거 추가해서 확인 + setAccessToken(accessToken); + setRefreshToken(refreshToken); + setUser(user); + localStorage.setItem("accessToken", accessToken); + localStorage.setItem("refreshToken", refreshToken); + localStorage.setItem("user", JSON.stringify(user)); // ✅ 저장 }; - - export const useAuthContext = () => { - const context = useContext(AuthContext); - if (!context) throw new Error("useAuthContext must be used within AuthProvider"); - return context; - }; \ No newline at end of file + + const value: AuthContextType = { + isLoggedIn: !!accessToken, + accessToken, + refreshToken, + logout, + login, + user, + isLoading, + setUser, + }; + + return {children}; +}; + +export const useAuthContext = () => { + const context = useContext(AuthContext); + if (!context) + throw new Error("useAuthContext must be used within AuthProvider"); + return context; +}; diff --git a/LP/src/hooks/useComments.ts b/LP/src/hooks/useComments.ts index 299b01e..2d21137 100644 --- a/LP/src/hooks/useComments.ts +++ b/LP/src/hooks/useComments.ts @@ -1,11 +1,50 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; -import { getComments } from '../api/comments'; - -export const useComments = (lpId: number, order: 'asc' | 'desc') => - useInfiniteQuery({ - queryKey: ['comments', lpId, order], - queryFn: ({ pageParam = 0 }) => - getComments({ lpId, cursor: pageParam, order }), - getNextPageParam: (lastPage) => - lastPage.hasNext ? lastPage.nextCursor : undefined, - }); \ No newline at end of file +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { getComments } from "../api/comments"; +import api from "../api/axios"; +import { useState } from "react"; + +// 댓글 등록 API 호출 함수 +const postComment = async (lpId: number, content: string) => { + const res = await api.post(`/lps/${lpId}/comments`, { content }); + return res.data; +}; + +export const useComments = (lpId: number, order: "asc" | "desc") => { + const [comment, setComment] = useState(""); + const queryClient = useQueryClient(); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = + useInfiniteQuery({ + queryKey: ["comments", lpId, order], + queryFn: async ({ pageParam = 0 }) => { + const res = await getComments({ lpId, cursor: pageParam, order }); + console.log("댓글 페이지 응답:", res); + return res; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage?.hasNext ? lastPage.nextCursor : undefined, + }); + + const comments = data?.pages.flatMap((page) => page?.comments ?? []) ?? []; + + const submitComment = async (content: string) => { + try { + await postComment(lpId, content); + setComment(""); + refetch(); // 새로고침으로 댓글 목록 업데이트 + } catch (error) { + console.error("댓글 등록 실패:", error); + } + }; + + return { + comments, + comment, + setComment, + submitComment, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }; +}; diff --git a/LP/src/hooks/useDebounce.ts b/LP/src/hooks/useDebounce.ts new file mode 100644 index 0000000..965236c --- /dev/null +++ b/LP/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/LP/src/hooks/useThrottle.ts b/LP/src/hooks/useThrottle.ts new file mode 100644 index 0000000..eacfefc --- /dev/null +++ b/LP/src/hooks/useThrottle.ts @@ -0,0 +1,21 @@ +import { useRef, useCallback } from "react"; + +export function useThrottle void>( + callback: T, + delay: number +): T { + const lastCall = useRef(0); + + const throttledFn = useCallback( + (...args: Parameters) => { + const now = Date.now(); + if (now - lastCall.current >= delay) { + lastCall.current = now; + callback(...args); + } + }, + [callback, delay] + ); + + return throttledFn as T; +} diff --git a/LP/src/layouts/HomeLayout.tsx b/LP/src/layouts/HomeLayout.tsx index fa7a9b8..870ef1c 100644 --- a/LP/src/layouts/HomeLayout.tsx +++ b/LP/src/layouts/HomeLayout.tsx @@ -1,21 +1,44 @@ import { useState, useEffect } from "react"; import Header from "../components/Header"; import Sidebar from "../components/Sidebar"; -import { Outlet } from "react-router-dom"; +import DeleteUserModal from "../components/DeleteUserModal"; // added import +import { Outlet, useNavigate } from "react-router-dom"; // added useNavigate const HomeLayout = () => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const navigate = useNavigate(); + + const handleDeleteAccount = async () => { + try { + const token = localStorage.getItem("accessToken"); + const res = await fetch(`${import.meta.env.VITE_API_URL}/users`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + Accept: "*/*", + }, + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "회원 탈퇴 실패"); + } + + localStorage.removeItem("accessToken"); + navigate("/login"); + } catch (err: any) { + console.error("회원 탈퇴 실패:", err); + alert(`회원 탈퇴에 실패했습니다: ${err.message}`); + } + }; useEffect(() => { const handleResize = () => { - if (window.innerWidth >= 768) { - setIsSidebarOpen(true); - } else { - setIsSidebarOpen(false); - } + setIsSidebarOpen(false); }; - handleResize(); // initialize once + handleResize(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); @@ -26,15 +49,32 @@ const HomeLayout = () => {
setIsSidebarOpen((prev) => !prev)} />
- setIsSidebarOpen(false)} /> +
+ setIsSidebarOpen(false)} + onRequestDelete={() => setShowDeleteModal(true)} + /> +
- {/* Main content */}
+ {showDeleteModal && ( + setShowDeleteModal(false)} + /> + )} +
© 2025 돌려돌려 LP판. All rights reserved.
@@ -42,4 +82,4 @@ const HomeLayout = () => { ); }; -export default HomeLayout; \ No newline at end of file +export default HomeLayout; diff --git a/LP/src/pages/GoogleCallback.tsx b/LP/src/pages/GoogleCallback.tsx index fe66c7a..e0516df 100644 --- a/LP/src/pages/GoogleCallback.tsx +++ b/LP/src/pages/GoogleCallback.tsx @@ -25,18 +25,21 @@ const GoogleCallback = () => { const fetchEmailAndLogin = async () => { try { - const res = await axios.get(`${import.meta.env.VITE_API_URL}/users/${userId}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const res = await axios.get( + `${import.meta.env.VITE_API_URL}/users/${userId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); const { email } = res.data.data; await login({ accessToken, refreshToken, - user: { name, email }, + user: { id: Number(userId), name, email }, }); alreadyHandled.current = true; @@ -54,4 +57,4 @@ const GoogleCallback = () => { return
구글 로그인 처리 중입니다...
; }; -export default GoogleCallback; \ No newline at end of file +export default GoogleCallback; diff --git a/LP/src/pages/HomePage.tsx b/LP/src/pages/HomePage.tsx index 0646940..9935ac3 100644 --- a/LP/src/pages/HomePage.tsx +++ b/LP/src/pages/HomePage.tsx @@ -2,11 +2,19 @@ import { useState, useRef, useEffect } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { useAuthContext } from "../context/AuthContext"; -import api from "../api/axios"; +import api from "../api/axios"; import LPCard from "../components/LPCard"; +import LPModal from "../components/LPModal"; +import FAB from "../components/FAB"; // LP 리스트 요청 함수 -const fetchLps = async ({ pageParam = 0, order }: { pageParam?: number; order: "asc" | "desc" }) => { +const fetchLps = async ({ + pageParam = 0, + order, +}: { + pageParam?: number; + order: "asc" | "desc"; +}) => { const res = await api.get("lps", { params: { order, @@ -29,23 +37,25 @@ const HomePage = () => { const { isLoggedIn } = useAuthContext(); const navigate = useNavigate(); const loadMoreRef = useRef(null); + const [showModal, setShowModal] = useState(false); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useInfiniteQuery({ - queryKey: ["lps", order], - queryFn: ({ pageParam = 0 }) => fetchLps({ pageParam, order }), - getNextPageParam: (lastPage) => lastPage.nextCursor, - initialPageParam: 0, - }); + const handleFABClick = () => { + setShowModal(true); + }; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + queryKey: ["lps", order], + queryFn: ({ pageParam = 0 }) => fetchLps({ pageParam, order }), + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialPageParam: 0, + }); const handleCardClick = (id: number) => { if (!isLoggedIn) { - const shouldLogin = window.confirm("로그인이 필요한 서비스입니다. 로그인 해주세요!"); + const shouldLogin = window.confirm( + "로그인이 필요한 서비스입니다. 로그인 해주세요!" + ); if (shouldLogin) navigate("/login"); return; } @@ -83,7 +93,9 @@ const HomePage = () => {
)) : data?.pages.flatMap((page) => - page.items.map((lp: { - id: number; - thumbnail: string; - title: string; - createdAt: string; - likeCount?: number; - }) => ( - handleCardClick(lp.id)} - /> - )) + page.items.map( + (lp: { + id: number; + thumbnail: string; + title: string; + createdAt: string; + likeCount?: number; + likes?: any[]; + }) => ( + handleCardClick(lp.id)} + /> + ) + ) )}
@@ -135,8 +150,10 @@ const HomePage = () => {
+ + {showModal && setShowModal(false)} />}
); }; -export default HomePage; \ No newline at end of file +export default HomePage; diff --git a/LP/src/pages/LPDetail.tsx b/LP/src/pages/LPDetail.tsx index 244802c..e1d3352 100644 --- a/LP/src/pages/LPDetail.tsx +++ b/LP/src/pages/LPDetail.tsx @@ -1,17 +1,27 @@ -// NOTE: clsx가 설치되어 있지 않다면 'npm install clsx' 또는 'yarn add clsx' 필요 -import { useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { fetchLpDetail } from "../api/lp"; -import { useState, useEffect, useRef } from "react"; +import api from "../api/axios"; +import { useState } from "react"; import { useAuthContext } from "../context/AuthContext"; -import clsx from "clsx"; -import { useComments } from "../hooks/useComments"; +import LPHeader from "../components/LPHeader"; +import LPContent from "../components/LPContent"; +import LPControls from "../components/LPControls"; +import LikeButton from "../components/LikeButton"; +import CommentSection from "../components/CommentSection"; const LPDetail = () => { const { LPid } = useParams(); const { user } = useAuthContext(); + const navigate = useNavigate(); const [liked, setLiked] = useState(false); const [likeCount, setLikeCount] = useState(0); + const [editMode, setEditMode] = useState(false); + const [editTitle, setEditTitle] = useState(""); + const [editContent, setEditContent] = useState(""); + const [editThumbnail, setEditThumbnail] = useState(""); + const [editTags, setEditTags] = useState(""); + const [isLiking, setIsLiking] = useState(false); const { data: lp, @@ -22,193 +32,145 @@ const LPDetail = () => { queryFn: () => fetchLpDetail(LPid!), enabled: !!LPid, onSuccess: (data) => { - setLiked(data.likes.some((like) => like.userId === user?.id)); + setLiked(data.likes.some((like: any) => like.userId === user?.id)); setLikeCount(data.likes.length); }, }); + // 좋아요 토글 서버 요청 (Simplified) + const { mutate: toggleLike } = useMutation({ + mutationFn: async (prevLiked: boolean) => { + if (prevLiked) { + return await api.delete(`/lps/${LPid}/likes`); + } else { + return await api.post(`/lps/${LPid}/likes`); + } + }, + onMutate: (prevLiked) => { + const nextLiked = !prevLiked; + setLiked(nextLiked); + setLikeCount((count) => (nextLiked ? count + 1 : count - 1)); + return prevLiked; + }, + onError: (err: any, prevLiked) => { + setLiked(prevLiked); + if (err.response?.status === 409) { + alert("이미 좋아요가 되어 있거나 오류가 발생했습니다."); + } else { + console.error("좋아요 처리 실패", err); + alert("좋아요 처리에 실패했습니다."); + } + }, + onSuccess: async () => { + try { + const res = await api.get(`/lps/${LPid}`); + const updatedLp = res.data.data; + setLiked(updatedLp.likes.some((like: any) => like.userId === user?.id)); + } catch (err) { + console.error("좋아요 상태 재조회 실패", err); + } + }, + }); + const handleLike = () => { - setLiked((prev) => !prev); - setLikeCount((prev) => (liked ? prev - 1 : prev + 1)); - // TODO: 실제 서버에 좋아요 요청 보내기 + if (isLiking) return; + setIsLiking(true); + toggleLike(liked, { + onSettled: () => { + setIsLiking(false); + }, + }); }; const isAuthor = user?.id === lp?.author?.id; - const [order, setOrder] = useState<'asc' | 'desc'>('desc'); - const { data: comments, fetchNextPage, hasNextPage, isLoading: isLoadingComments, isFetchingNextPage } = useComments(Number(LPid), order); - - const loaderRef = useRef(null); - - useEffect(() => { - if (!hasNextPage || !loaderRef.current) return; - - const observer = new IntersectionObserver(([entry]) => { - if (entry.isIntersecting) { - fetchNextPage(); - } - }); - - observer.observe(loaderRef.current); + // LP 삭제 기능 + const { mutate: deleteLp } = useMutation({ + mutationFn: async () => { + return await api.delete(`/lps/${LPid}`); + }, + onSuccess: () => { + alert("삭제되었습니다."); + navigate("/"); + }, + }); - return () => observer.disconnect(); - }, [hasNextPage, fetchNextPage]); + const { mutate: editLp } = useMutation({ + mutationFn: async () => { + return await api.patch(`/lps/${LPid}`, { + title: editTitle, + content: editContent, + thumbnail: editThumbnail, + tags: editTags.split(",").map((tag) => tag.trim()), + published: true, + }); + }, + onSuccess: () => { + alert("수정되었습니다."); + setEditMode(false); + window.location.reload(); + }, + }); if (isLoading) { return

로딩 중...

; } if (isError || !lp) { - return

LP 정보를 불러올 수 없습니다.

; + return ( +

+ LP 정보를 불러올 수 없습니다. +

+ ); } return (
-
- 🟢 {lp.author.name} - - {new Date(lp.createdAt).toLocaleDateString("ko-KR")} - -
- -

{lp.title}

- -
-
- LP -
-
- -
- -

{lp.content}

- -
- {lp.tags.map((tag) => ( - - #{tag.name} - - ))} -
- -
- {isAuthor && ( -
- - -
- )} - - -
- -
-
- - -
-
- - -
- - {isLoadingComments && !isFetchingNextPage ? ( -
- {Array.from({ length: 4 }).map((_, idx) => ( -
-
-
-
-
-
-
- ))} -
- ) : ( -
- {comments?.pages.map((page) => - page.data.map((comment) => ( -
-
- {comment.author.avatar ? ( - avatar - ) : ( -
- {comment.author.name.charAt(0)} -
- )} -
-
-
- {comment.author.name} - - {new Date(comment.createdAt).toLocaleString()} - -
-

{comment.content}

-
-
- )) - )} - {hasNextPage &&
} -
- )} - {isFetchingNextPage && ( -
- {Array.from({ length: 2 }).map((_, idx) => ( -
-
-
-
-
-
-
- ))} -
- )} -
+ + {editMode ? ( + + ) : ( + + )} + + {isAuthor && ( + + )} + + {!editMode && ( + + )} + {!editMode && }
); }; -export default LPDetail; \ No newline at end of file +export default LPDetail; diff --git a/LP/src/pages/LoginPage.tsx b/LP/src/pages/LoginPage.tsx index 8acb216..5ba59dd 100644 --- a/LP/src/pages/LoginPage.tsx +++ b/LP/src/pages/LoginPage.tsx @@ -3,8 +3,9 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { signin } from "../api/auth"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuthContext } from "../context/AuthContext"; +import { useMutation } from "@tanstack/react-query"; const loginSchema = z.object({ email: z.string().email("유효한 이메일을 입력해주세요."), @@ -14,7 +15,7 @@ const loginSchema = z.object({ type LoginForm = z.infer; const LoginPage = () => { - const navigate = useNavigate(); + const navigate = useNavigate(); const location = useLocation(); const isLogin = location.pathname === "/login"; const { @@ -34,19 +35,21 @@ const LoginPage = () => { window.location.href = `${API_URL}/auth/google/login`; }; - const onSubmit = async (values: LoginForm) => { - try { - const res = await signin(values); - const { accessToken, refreshToken, name, email } = res.data.data; + const { mutateAsync: loginMutate } = useMutation({ + mutationFn: signin, + onSuccess: async (res) => { + const { accessToken, refreshToken, name, email, id } = res.data.data; alert(`환영합니다, ${name}님!`); - - - await login({ accessToken, refreshToken, user: { name, email } }); - + await login({ accessToken, refreshToken, user: { id, name, email } }); navigate("/"); - } catch (err: any) { + }, + onError: (err: any) => { alert("로그인 실패: " + (err.response?.data?.message || err.message)); - } + }, + }); + + const onSubmit = (values: LoginForm) => { + loginMutate(values); }; return ( @@ -56,21 +59,28 @@ const LoginPage = () => {

-
+
<

로그인

@@ -79,7 +89,8 @@ const LoginPage = () => { @@ -118,14 +129,18 @@ const LoginPage = () => { }`} /> {errors?.password && touchedFields.password && ( - {errors.password.message} + + {errors.password.message} + )} + +
+
+ ) : ( +
+ {user.avatar ? ( + 프로필 이미지 + ) : ( +
+ 👤 +
+ )} +
+

{user.name}

+

{user.email}

+ +
+
+ ) + ) : ( +

로딩 중...

+ )} -
- - - +
+ + +
); }; -export default Mypage; \ No newline at end of file +export default Mypage; diff --git a/LP/src/pages/SearchResult.tsx b/LP/src/pages/SearchResult.tsx new file mode 100644 index 0000000..d82217a --- /dev/null +++ b/LP/src/pages/SearchResult.tsx @@ -0,0 +1,177 @@ +import { useSearchParams } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import api from "../api/axios"; +import LPCard from "../components/LPCard"; +import { useDebounce } from "../hooks/useDebounce"; +import { useThrottle } from "../hooks/useThrottle"; +import { FiSearch } from "react-icons/fi"; +import { BiSortUp, BiSortDown } from "react-icons/bi"; + +const SearchResult = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const search = searchParams.get("search") || ""; + const sort = searchParams.get("sort") || "desc"; + + const [inputValue, setInputValue] = useState(search); + const [searchType, setSearchType] = useState<"title" | "tag">( + searchParams.get("type") === "tag" ? "tag" : "title" + ); + const [sortOrder, setSortOrder] = useState<"latest" | "oldest">( + sort === "asc" ? "oldest" : "latest" + ); + const debouncedSearch = useDebounce(inputValue, 500); + + const handleSearch = () => { + const newParams = new URLSearchParams(); + newParams.set("search", inputValue); + newParams.set("sort", sortOrder === "latest" ? "desc" : "asc"); + newParams.set("type", searchType); + setSearchParams(newParams); + }; + + useEffect(() => { + const newParams = new URLSearchParams(); + newParams.set("search", debouncedSearch); + newParams.set("sort", sortOrder === "latest" ? "desc" : "asc"); + newParams.set("type", searchType); + setSearchParams(newParams); + }, [debouncedSearch, sortOrder, searchType]); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteQuery({ + queryKey: ["search", debouncedSearch, sortOrder, searchType], + queryFn: async ({ pageParam = 0 }) => { + console.log("fetching LPs..."); + const res = await api.get("/lps", { + params: { + search: debouncedSearch, + order: sortOrder === "latest" ? "desc" : "asc", + type: searchType, + cursor: pageParam, + limit: 12, + }, + }); + console.log(res.data); + return res.data.data; + }, + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : undefined; + }, + }); + + const throttledFetchNext = useThrottle(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, 3000); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY; + const viewportHeight = window.innerHeight; + const fullHeight = document.documentElement.scrollHeight; + + if (scrollTop + viewportHeight >= fullHeight - 300) { + throttledFetchNext(); + } + }; + + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, [throttledFetchNext, hasNextPage, isFetchingNextPage]); + + return ( +
+

🔍 검색 결과

+
+ + + +
+ setInputValue(e.target.value)} + className="mb-4 w-full px-4 py-2 rounded-md bg-[#2e2e2e] text-white" + placeholder="검색어를 입력하세요" + /> + {isLoading ? ( +

검색 중...

+ ) : isError ? ( +

+ 검색 결과를 불러오지 못했습니다. +

+ ) : ( + <> +
+ {data?.pages.flatMap((page) => + page.data.map((lp: any) => { + return ( +
+ (window.location.href = `/lp/${lp.id}`)} + /> +
+ {lp.tags.map((tag: any) => ( + + #{tag.name} + + ))} +
+
+ ); + }) + )} +
+ + )} +
+ ); +}; + +export default SearchResult; diff --git a/LP/src/pages/SignupPage.tsx b/LP/src/pages/SignupPage.tsx index 5e1a607..f49e9f5 100644 --- a/LP/src/pages/SignupPage.tsx +++ b/LP/src/pages/SignupPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -34,6 +34,7 @@ const SignupPage = () => { const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false); // 추가 const [confirmError, setConfirmError] = useState(""); const [nickname, setNickname] = useState(""); + const navigate = useNavigate(); const { register, handleSubmit, @@ -81,16 +82,17 @@ const SignupPage = () => { const handleSignup = async () => { try { const res = await signup({ - name: nickname, // nickname을 name으로 매핑 + name: nickname, // nickname을 name으로 매핑 email: getValues("email"), password: getValues("password"), - bio: "", // 선택값: 필요 시 다른 입력 필드로 받아도 됨 - avatar: "", // 선택값: 추후 URL을 받을 수 있음 + bio: "", // 선택값: 필요 시 다른 입력 필드로 받아도 됨 + avatar: "", // 선택값: 추후 URL을 받을 수 있음 }); const { accessToken, refreshToken } = res.data.data; setAccessToken(accessToken); setRefreshToken(refreshToken); alert("회원가입 완료!"); + navigate("/login"); } catch (err: any) { alert("회원가입 실패: " + (err.response?.data?.message || err.message)); } @@ -157,7 +159,8 @@ const SignupPage = () => { {!!errors.password && !!touchedFields.password && ( - {errors.password.message} + + {errors.password.message} + )}
diff --git a/LP/yarn.lock b/LP/yarn.lock index 6567712..7b04e70 100644 --- a/LP/yarn.lock +++ b/LP/yarn.lock @@ -1407,11 +1407,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lucide-react@^0.510.0: + version "0.510.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.510.0.tgz#933b19d893b30ac5cb355e4b91eeefc13088caa3" + integrity sha512-p8SQRAMVh7NhsAIETokSqDrc5CHnDLbV29mMnzaXx+Vc/hnqQzwI2r0FMWCcoTXnbw2KEjy48xwpGdEL+ck06Q== + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"